This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 13:38:59 +02:00
1 parent df403c0aa7
commit 9e133628e5
17 files changed
+270 -34

No files matched your search

+3
View File
@@ -10,6 +10,9 @@ RUSTPAD_PORT=8200
# SQLite — default
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc
# http compression:
HTTP_COMPRESSION=true
# PostgreSQL
# DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad
Generated
+107 -1
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -11,6 +17,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -86,6 +107,18 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "async-compression"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.91"
@@ -712,6 +745,27 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "brotli"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -787,6 +841,24 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "compression-codecs"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1149,6 +1221,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "flume"
version = "0.11.1"
@@ -2021,6 +2104,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
@@ -2581,7 +2674,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.87"
version = "0.2.88"
dependencies = [
"argon2",
"aws-config",
@@ -2835,6 +2928,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@@ -3350,6 +3449,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"async-compression",
"bitflags",
"bytes",
"futures-core",
@@ -4026,6 +4126,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.87"
version = "0.2.88"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -28,7 +28,7 @@ slug = "0.1.6"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
tower = "0.5.3"
tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] }
tower-http = { version = "0.7", features = ["fs", "trace", "set-header", "compression-br", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
hex = "0.4"
+29
View File
@@ -0,0 +1,29 @@
# Rustpad 0.2.88 — wyniki testów wydajności
Zmiana: w `src/websocket/pad.rs` i `src/websocket/mod.rs` ograniczono sprawdzanie dostępu przy przychodzących zdarzeniach broadcast do najwyżej jednego sprawdzenia na sekundę na połączenie. Kontrola cykliczna i zdarzenia zmiany hasła pozostają aktywne. W zestawie pozostają optymalizacje renderowania z poprzedniej iteracji.
## Symulacja deterministyczna (Node.js)
Założenia: 10 s, 2 edycje/s na użytkownika, każda edycja rozsyłana do wszystkich N odbiorców (w tym nadawcy), bez rzeczywistej sieci, bazy danych, WebSocket czy renderowania. Symulacja porównuje liczby sprawdzeń dostępu, a **nie** rzeczywistą przepustowość ani opóźnienia Rustpada.
| Użytkownicy | Dostawy zdarzeń | Sprawdzenia przed | Sprawdzenia po | Redukcja |
|---:|---:|---:|---:|---:|
| 5 | 500 | 500 | 50 | 90,00% |
| 100 | 200 000 | 200 000 | 1 000 | 99,50% |
| 300 | 1 800 000 | 1 800 000 | 3 000 | 99,83% |
| 500 | 5 000 000 | 5 000 000 | 5 000 | 99,90% |
Uruchomienie: `node tests/broadcast-load-simulation.mjs`.
## Testy
- `node tests/feature-smoke-test.mjs` — PASS
- `node tests/mention-autocomplete-test.mjs` — PASS
- `node tests/mention-navigation-test.mjs` — PASS
- `node --check static/js/*.js` (osobno dla plików) — PASS
- `cargo test` — NIEURUCHOMIONY (brak Rust/cargo w środowisku)
- Test end-to-end z setkami aktywnych przeglądarek i prawdziwym WebSocket/PostgreSQL — NIEURUCHOMIONY.
## Ograniczenia
Nie można zagwarantować płynnej pracy kilkuset użytkowników bez testu na docelowym sprzęcie i bazie. Każdy zapis nadal przechodzi przez serializację zmian i trwałe utrwalenie w bazie; duża liczba operacji może nasycić bazę i sieć. Zmiana odświeżania uprawnień może wydłużyć wykrycie odwołania dostępu w trakcie ciągłego broadcastu do około 1 sekundy; istniejący timer kontroli okresowej pozostaje. Wersja 0.2.88 jest wydaniem z nową poprawką na życzenie użytkownika i nie należy jej mylić z wcześniej wydaną paczką 0.2.88.
+10
View File
@@ -371,3 +371,13 @@ python3 tests/random_data.py \
```
`--notes-in-workspaces` is applied to every workspace. The example creates 10,000 standalone notes and another 10,000 notes inside 10 workspaces. Wikipedia mode never falls back to generated content. Use `--wikipedia-images`, `--wikipedia-attempts`, `--workers`, `--source-pool-size`, `--scheme https`, `--base-url`, or `--dry-run` as needed. The login value may be a local account e-mail or an LDAP/AD username.
### HTTP response compression
Rustpad optionally supports Brotli and gzip for HTTP responses. Add to `.env` (or the YAML config):
```dotenv
HTTP_COMPRESSION=true
```
Defaults to `false`. When enabled, the server selects Brotli or gzip supported by the browser via `Accept-Encoding`, and sends ordinary responses otherwise. This does **not** compress live WebSocket messages. When a reverse proxy already applies HTTP compression, avoid double configuration where possible.
+8
View File
@@ -20,6 +20,7 @@ use axum::{
use pages::*;
use tower::{ServiceBuilder, service_fn};
use tower_http::{
compression::CompressionLayer,
services::ServeDir,
set_header::SetResponseHeaderLayer,
trace::{MakeSpan, TraceLayer},
@@ -48,6 +49,7 @@ pub fn router(
static_dir: &str,
upload_body_limit_bytes: usize,
asset_cache_max_age_seconds: u64,
http_compression: bool,
) -> Router {
let asset_version = state.asset_version.clone();
let asset_not_found = service_fn(move |_request| {
@@ -259,6 +261,12 @@ pub fn router(
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
.layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(apply_response_header_policy))
// Negotiate HTTP compression only when enabled; WebSocket frames are unaffected.
.layer(
CompressionLayer::new()
.br(http_compression)
.gzip(http_compression),
)
.with_state(state)
}
+2
View File
@@ -59,6 +59,7 @@ pub struct Config {
pub guest_upload_max_size_bytes: usize,
pub asset_version: String,
pub asset_cache_max_age_seconds: u64,
pub http_compression: bool,
pub file_cache_max_age_seconds: u64,
pub files_public_url: Option<String>,
pub smtp: Option<crate::state::SmtpConfig>,
@@ -174,6 +175,7 @@ impl Config {
guest_upload_max_size_mb,
)?,
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
http_compression: values.bool("HTTP_COMPRESSION", false)?,
asset_cache_max_age_seconds: values
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
file_cache_max_age_seconds: values
+1
View File
@@ -22,6 +22,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
"GUEST_UPLOAD_ENABLED",
"GUEST_UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS",
"HTTP_COMPRESSION",
"FILE_CACHE_MAX_AGE_SECONDS",
"REGISTRATION_ENABLED",
"ACCOUNT_CONFIRMATION_REQUIRED",
+2
View File
@@ -68,6 +68,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
guest_upload_enabled = config.guest_upload_enabled,
guest_upload_max_size_bytes = config.guest_upload_max_size_bytes,
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
http_compression = config.http_compression,
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
registration_enabled = config.registration_enabled,
@@ -173,6 +174,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
&config.static_dir,
config.upload_body_limit_bytes(),
config.asset_cache_max_age_seconds,
config.http_compression,
);
let address = SocketAddr::new(config.host, config.port);
let listener = TcpListener::bind(address).await?;
+18 -11
View File
@@ -863,6 +863,7 @@ async fn handle_socket(
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
access_refresh.tick().await;
let (mut sender, mut receiver) = socket.split();
let mut last_broadcast_access_check = Instant::now() - Duration::from_secs(2);
if send_split(
&mut sender,
&ServerMessage::Diagnostics {
@@ -1053,17 +1054,23 @@ async fn handle_socket(
}
continue;
}
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
// Broadcasts can arrive hundreds of times a second per connection.
// Keep access checks off the per-message hot path; the separate
// access_refresh ticker also enforces revocation while idle.
if last_broadcast_access_check.elapsed() >= Duration::from_secs(1) {
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
last_broadcast_access_check = Instant::now();
}
match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;},
+18 -11
View File
@@ -356,6 +356,7 @@ async fn handle_pad_socket(
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let mut last_broadcast_access_check = Instant::now() - Duration::from_secs(2);
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
access_refresh.tick().await;
@@ -552,17 +553,23 @@ async fn handle_pad_socket(
}
continue;
}
let (read_allowed, _) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed {
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
// Broadcasts can arrive hundreds of times a second per connection.
// Keep access checks off the per-message hot path; the separate
// access_refresh ticker also enforces revocation while idle.
if last_broadcast_access_check.elapsed() >= Duration::from_secs(1) {
let (read_allowed, _) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
).await;
if !read_allowed {
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
last_broadcast_access_check = Instant::now();
}
match update {
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;},
+10
View File
@@ -4099,6 +4099,16 @@ dialog::backdrop {
line-height: inherit;
}
.markdown-body .task-list-item--cancelled>.list-item-content {
text-decoration: line-through;
color: var(--text-subtle);
}
.markdown-body .task-list-item--cancelled>.task-checkbox {
cursor: not-allowed;
opacity: .6;
}
.markdown-body .task-list-item>ul,
.markdown-body .task-list-item>ol {
min-width: 0;
+5 -4
View File
@@ -274,7 +274,7 @@ function renderStandaloneMedia(line, sourceLine) {
}
function listLine(line) {
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/);
const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX~])\]\s+)?(.+)$/);
if (!match) return null;
const indent = match[1].replace(/\t/g, " ").length;
return {
@@ -283,6 +283,7 @@ function listLine(line) {
type: match[3] ? "ol" : "ul",
number: match[3] ? Number(match[3]) : null,
checked: match[4] == null ? null : match[4].toLowerCase() === "x",
cancelled: match[4] === "~",
text: match[5],
marker: match[3] ? `${match[3]}. ` : `${match[2]} `
};
@@ -302,14 +303,14 @@ function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType
if (!item || item.indent < indent || item.indent !== indent || item.type !== type) break;
const sourceLine = index + lineOffset + 1;
const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.checked ? "x" : " "}] `}`;
const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.cancelled ? "~" : item.checked ? "x" : " "}] `}`;
const valueAttr = "";
const taskClass = item.checked == null ? "" : " task-list-item";
const taskClass = item.checked == null ? "" : ` task-list-item${item.cancelled ? " task-list-item--cancelled" : ""}`;
hasTask ||= item.checked != null;
const gutterOffset = `${(depth + 1) * 1.75}em`;
body += `<li class="preview-source-line list-source-line${taskClass}" data-source-line="${sourceLine}"${valueAttr} style="--list-gutter-offset:${gutterOffset}">`;
if (item.checked != null) {
body += `<input type="checkbox" class="task-checkbox" data-source-line="${sourceLine}"${item.checked ? " checked" : ""}>`;
body += `<input type="checkbox" class="task-checkbox" data-source-line="${sourceLine}"${item.checked ? " checked" : ""}${item.cancelled ? " disabled aria-label=\"Cancelled task\"" : ""}>`;
}
body += `<span class="preview-editable list-item-content" data-source-line="${sourceLine}" data-source-prefix="${escapeHtml(prefix)}">${inline(item.text)}</span>`;
index++;
+19 -4
View File
@@ -1247,6 +1247,21 @@ export function startNoteEditor(adapter) {
});
}
const render = createRenderQueue(renderNow);
// Collaborative edits can arrive many times per second. Keep typing and
// WebSocket processing responsive by batching expensive Markdown/DOM work.
let deferredRenderTimer = null;
let firstDeferredRenderAt = 0;
function scheduleDocumentRender() {
const now = performance.now();
if (deferredRenderTimer === null) firstDeferredRenderAt = now;
else clearTimeout(deferredRenderTimer);
const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt)));
deferredRenderTimer = setTimeout(() => {
deferredRenderTimer = null;
firstDeferredRenderAt = 0;
render();
}, delay);
}
function queueCollaborativeOperation(operation, ownerReplacements = []) {
if (!collaboration.ready || !canEditDocument()) return false;
try {
@@ -1407,7 +1422,7 @@ export function startNoteEditor(adapter) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
editHistory.syncCurrent();
renderGutter();
scheduleDocumentRender();
requestAnimationFrame(revealLinkedLine);
return;
}
@@ -1427,7 +1442,7 @@ export function startNoteEditor(adapter) {
applyingRemote = false;
if (resetHistory) editHistory.reset();
else editHistory.syncCurrent();
render();
scheduleDocumentRender();
editor.scrollTop = initialLoad ? 0 : scrollTop;
editor.scrollLeft = scrollLeft;
syncEditorLayers();
@@ -2238,7 +2253,7 @@ export function startNoteEditor(adapter) {
});
preview.addEventListener("change", event => {
const checkbox = event.target.closest(".task-checkbox");
if (!checkbox) return;
if (!checkbox || checkbox.disabled) return;
if (!canEditDocument()) {
checkbox.checked = !checkbox.checked;
saveState.textContent = "Read only";
@@ -2488,7 +2503,7 @@ export function startNoteEditor(adapter) {
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
previousContent = nextContent;
render();
scheduleDocumentRender();
if (applyingRemote || applyingHistory) return;
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
editHistory.record(event.inputType || "");
+1 -1
View File
@@ -60,7 +60,7 @@ async function renderMediaPlayers() { const nodes = content.querySelectorAll("[d
function lockPublicContent(allowTaskUpdates) {
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = box.disabled || !allowTaskUpdates; box.title = box.disabled ? t("public.task.update", {}, "Update this task") : t("public.task.disabled", {}, "Task updates are disabled by the owner"); });
}
function publicAnchorTarget(hash) {
const rawId = String(hash || "").replace(/^#/, "");
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
// Deterministic broadcast/access-check simulation. Not a live WebSocket/DB benchmark.
const durationSeconds = 10;
const editsPerUserPerSecond = 2;
for (const users of [5, 100, 300, 500]) {
const checks = new Int32Array(users);
const lastCheckSecond = new Int32Array(users).fill(-1);
const start = performance.now();
let fanouts = 0;
for (let second = 0; second < durationSeconds; second++) {
for (let event = 0; event < users * editsPerUserPerSecond; event++) {
for (let recipient = 0; recipient < users; recipient++) {
fanouts++;
if (lastCheckSecond[recipient] !== second) {
checks[recipient]++;
lastCheckSecond[recipient] = second;
}
}
}
}
const optimized = checks.reduce((a,b) => a+b, 0);
const baseline = fanouts;
assert.equal(optimized, users * durationSeconds);
assert.ok(optimized <= baseline);
console.log(`${users} clients | ${fanouts.toLocaleString('en-US')} broadcast deliveries | auth reads: ${baseline.toLocaleString('en-US')} -> ${optimized.toLocaleString('en-US')} | reduction ${(100*(1-optimized/baseline)).toFixed(2)}% | simulation ${(performance.now()-start).toFixed(1)} ms`);
}
+8
View File
@@ -51,3 +51,11 @@ html = renderMarkdown('Hello @a[1]\\x and @<f>&" but not @a[1]\\xy');
assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames');
assert.doesNotMatch(html, /<f>/, 'no raw HTML from nickname');
console.log('PASS: special-character nicknames remain escaped');
html = renderMarkdown('- [ ] Open\n- [x] Done\n- [~] Cancelled\n - [~] Nested cancelled');
has(/task-list-item--cancelled/, html, 'cancelled task has its own class');
assert.equal((html.match(/task-list-item--cancelled/g) || []).length, 2, 'both cancelled tasks recognized');
has(/data-source-line="3" disabled aria-label="Cancelled task"/, html, 'cancelled checkbox cannot be clicked');
has(/data-source-prefix="- \[~\] "/, html, 'cancelled markdown marker preserved');
has(/data-source-line="1"(?! disabled)/, html, 'open task remains interactive');
console.log('PASS: cancelled tasks are disabled and retain their source markers');