Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/writer/scripts/browser-reliability-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const main = "manuscripts/patient-observation.md";
async function scenario(name, run) {
const context = await browser.newContext({ viewport: { width: 1500, height: 950 }, acceptDownloads: true });
const page = await context.newPage();
if (process.env.COLD_CACHE) {
await context.route("**/*", (route) => route.continue());
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await cdp.send("Network.setBypassServiceWorker", { bypass: true });
}
try { await run(page); console.log(`ok ${name}`); }
catch (error) {
failures++;
Expand Down Expand Up @@ -113,6 +120,29 @@ try {
await page.waitForFunction(() => window.writer.workspace.getSnapshot().records.has("chapters/worms.md"));
});

await scenario("self-contained preview does not wait for the collection index; exports do", async (page) => {
await page.goto(`${base}?demo`);
await page.locator(".manuscript-row").first().waitFor();
await page.evaluate(async (path) => {
const backend = window.writer.backend;
const index = await backend.index();
backend.authority.editElsewhere(path, { body: "# Self-contained\n\nA plain paragraph.\n" });
backend.index = () => new Promise((resolve) => { window.finishIndex = () => resolve(index); });
}, main);
await page.locator(".manuscript-row", { hasText: "Patient Observation" }).click();
await painted(page);
assert.equal(await page.evaluate(() => window.writer.workspace.getSnapshot().indexLoad.phase), "loading");
await page.getByText("Dependencies not checked").waitFor();
await page.evaluate(() => {
window.exportFinished = false;
window.pendingBundle = window.writer.workspace.exportBundle().then(() => { window.exportFinished = true; });
});
await page.waitForTimeout(100);
assert.equal(await page.evaluate(() => window.exportFinished), false);
await page.evaluate(async () => { window.finishIndex(); await window.pendingBundle; });
assert.equal(await page.evaluate(() => window.writer.workspace.getSnapshot().indexLoad.phase), "ready");
});

await scenario("preview starts while annotation discovery is held", async (page) => {
await page.goto(`${base}?demo`);
await page.locator(".manuscript-row").first().waitFor();
Expand Down
8 changes: 8 additions & 0 deletions apps/writer/scripts/browser-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ mkdirSync("out", { recursive: true });
const browser = await chromium.launch({ executablePath });
const context = await browser.newContext({ viewport: { width: 1500, height: 950 }, acceptDownloads: true });
const page = await context.newPage();
if (process.env.COLD_CACHE) {
// Context routing also disables HTTP caching for dedicated-worker requests.
await context.route("**/*", (route) => route.continue());
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await cdp.send("Network.setBypassServiceWorker", { bypass: true });
}
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
page.on("console", (m) => {
Expand Down
21 changes: 20 additions & 1 deletion apps/writer/scripts/perf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,23 @@ PROFILE_NAME=after PROFILE_OUTPUT=out/perf/browser-after.json pnpm -C apps/write
pnpm -C apps/writer test:browser
```

`BASE`, `CHROME`, `PROFILE_NAME`, `PROFILE_OUTPUT` are configurable. The script captures editor/preview visible geometry at animation frames, CDP main-isolate JS heap, >50 ms main-thread long tasks during two ~10 s typing phases, source paging (historically Show all), 300-highlight expansion, citation/embed completion, page errors and `out/large-{sources,comments}-{before,after}.png`. One run is observational, not a statistical timing assertion; heap excludes workers/WASM and is not GC-controlled, and visible canvas is not pixel verification. The default JSON output is `out/perf/browser-profile.json`. Historical before was captured before Round 4 UI changes; after uses a corrected reply counter. Interpretation and deferred work are in `docs/perf-review.md`.
`BASE`, `CHROME`, `PROFILE_NAME`, `PROFILE_OUTPUT` are configurable.
HTTP cache is explicitly disabled and service workers bypassed on every profile.
The profiler now also captures startup tasks, first drawn-revision time, worker
messages/resource timings, main heap after forced GC/full discovery and worker
heap usage. `PROFILE_CPU=out/perf/main.cpuprofile`,
`PROFILE_HEAP=out/perf/main.heapsnapshot`, and
`PROFILE_WORKER_HEAP=out/perf/worker` optionally save detailed evidence.
Summarise a snapshot with:

```sh
pnpm -C apps/writer exec node scripts/perf/heap-summary.mjs out/perf/main.heapsnapshot > apps/writer/out/perf/heap-summary.json
COLD_CACHE=1 pnpm -C apps/writer test:browser
```

On the fixture-corrected product comparison, cold preview went from 3,426 to
2,134 ms and the GC-controlled main heap from 60.91 to 58.28 MiB.
Uncollected heap is still recorded for historical comparisons, not treated as a
retention measurement.

The script also captures editor/preview visible geometry at animation frames, CDP main-isolate JS heap, >50 ms main-thread long tasks during two ~10 s typing phases, source paging (historically Show all), 300-highlight expansion, citation/embed completion, page errors and `out/large-{sources,comments}-{before,after}.png`. One run is observational, not a statistical timing assertion; the main heap field excludes workers/native backing storage, and visible canvas is not pixel verification. The original Round 4 heap field was not GC-controlled. The default JSON output is `out/perf/browser-profile.json`. Historical before was captured before Round 4 UI changes; after uses a corrected reply counter. Interpretation and deferred work are in `docs/perf-review.md`.
98 changes: 93 additions & 5 deletions apps/writer/scripts/perf/browser-profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,42 @@ import { dirname } from "node:path";
const browser = await chromium.launch({ executablePath: process.env.CHROME });
const page = await browser.newPage({ viewport: { width: 1500, height: 950 } });
const errors = []; page.on("pageerror", (e) => errors.push(e.message));
const workers = []; page.on("worker", (worker) => workers.push(worker));
const cdp = await page.context().newCDPSession(page);
await cdp.send("Network.enable"); await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await cdp.send("Network.setBypassServiceWorker", { bypass: true });
await cdp.send("Performance.enable");
if (process.env.PROFILE_CPU) { await cdp.send("Profiler.enable"); await cdp.send("Profiler.start"); }
await page.addInitScript(() => {
window.uiTasks = [];
window.workerProfile = { messages: [], events: [] };
const NativeWorker = window.Worker;
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args);
window.workerProfile.events.push({ type: "created", url: String(args[0]), at: performance.now() });
this.addEventListener("message", ({ data }) => {
window.workerProfile.events.push({ type: data.type, at: performance.now(), initMs: data.initMs, timings: data.timings });
});
}
postMessage(message, ...args) {
const start = performance.now(); super.postMessage(message, ...args);
window.workerProfile.messages.push({ type: message.type, at: start, sendMs: performance.now() - start,
items: message.library?.length, records: message.recordPaths?.length,
// Only in the opt-in profiler: logical size, not wire bytes.
bytes: message.type === "init" || message.type === "collection" ? new TextEncoder().encode(JSON.stringify(message)).length : undefined });
}
};
new PerformanceObserver((list) => window.uiTasks.push(...list.getEntries().map((e) => ({ start: e.startTime, duration: e.duration })))).observe({ type: "longtask", buffered: true });
const watch = () => {
if (!window.editorPaint && document.querySelector(".cm-content")?.getBoundingClientRect().height) window.editorPaint = performance.now();
if (!window.previewPaint && document.querySelector(".preview-pages canvas.page")?.getBoundingClientRect().height) window.previewPaint = performance.now();
if (!window.previewPaint) requestAnimationFrame(watch);
if (!window.previewRendered && Number(document.querySelector(".preview-pages")?.dataset.renderedRevision) > 0) window.previewRendered = performance.now();
if (!window.previewRendered) requestAnimationFrame(watch);
}; requestAnimationFrame(watch);
});
const base = process.env.BASE ?? "http://127.0.0.1:5320/";
const report = { boundary: "Chromium main-thread long tasks; visible editor/canvas at animation frame (not pixel verification); CDP JS heap excludes workers/WASM; synthetic transport 20ms pages", phases: {} };
const report = { boundary: "Cold browser cache; Chromium main-thread long tasks; editor/canvas geometry and rendered-revision marker at animation frame (not pixel verification); main CDP JS heap excludes worker heaps and native/WASM backing storage; heap sampled after complete metadata; synthetic transport 20ms pages", phases: {} };
const measure = async (name, run) => {
const start = await page.evaluate(() => performance.now()); await run(); await page.waitForTimeout(100);
report.phases[name] = await page.evaluate((start) => {
Expand All @@ -25,10 +50,73 @@ const measure = async (name, run) => {
};
try {
await page.goto(`${base}?demo=large&manuscript=manuscripts/main.md`);
await page.waitForFunction(() => window.previewPaint && window.writer.workspace.getSnapshot().commentsLoad.phase === "ready", null, { timeout: 120000 });
report.paint = await page.evaluate(() => ({ editorMs: window.editorPaint, previewMs: window.previewPaint }));
const cdp = await page.context().newCDPSession(page); await cdp.send("Performance.enable");
await page.waitForFunction(() => window.previewRendered && window.writer.workspace.getSnapshot().commentsLoad.phase === "ready", null, { timeout: 120000 });
report.paint = await page.evaluate(() => ({ editorMs: window.editorPaint, previewMs: window.previewPaint, renderedMs: window.previewRendered }));
// An early self-contained preview can precede discovery. Compare retained heap
// only after the same complete metadata workload, never a partial collection.
await page.waitForFunction(() => ["indexLoad", "libraryLoad", "annotationsLoad", "commentsLoad"].every((key) => window.writer.workspace.getSnapshot()[key].phase === "ready"), null, { timeout: 120000 });
report.metadataReadyMs = await page.evaluate(() => performance.now());
await page.waitForTimeout(100);
report.heapAfterLoadBytes = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value;
report.startup = await page.evaluate(() => ({ timeOrigin: performance.timeOrigin, tasks: window.uiTasks, worker: window.workerProfile,
resources: performance.getEntriesByType("resource").map(({ name, startTime, duration, transferSize }) => ({ name, startTime, duration, transferSize })) }));
report.workers = await Promise.all(workers.map(async (worker) => ({ url: worker.url(), ...await worker.evaluate(() => ({
timeOrigin: performance.timeOrigin, now: performance.now(),
marks: performance.getEntriesByType("mark").map(({ name, startTime }) => ({ name, startTime })),
resources: performance.getEntriesByType("resource").map(({ name, startTime, duration, transferSize }) => ({ name, startTime, duration, transferSize })),
})) })));
if (process.env.PROFILE_CPU) {
const { profile } = await cdp.send("Profiler.stop");
mkdirSync(dirname(process.env.PROFILE_CPU), { recursive: true });
writeFileSync(process.env.PROFILE_CPU, JSON.stringify(profile));
}
await cdp.send("HeapProfiler.collectGarbage");
report.heapAfterGCBytes = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value;
// Dedicated workers aren't included in the page's Performance metrics.
const browserCdp = await browser.newBrowserCDPSession();
report.workerHeaps = [];
for (const target of (await browserCdp.send("Target.getTargets")).targetInfos.filter((t) => t.type === "worker")) {
const { sessionId } = await browserCdp.send("Target.attachToTarget", { targetId: target.targetId });
let id = 0;
const request = (method) => new Promise((resolve, reject) => {
const requestId = ++id;
const timeout = setTimeout(() => { browserCdp.off("Target.receivedMessageFromTarget", receive); reject(new Error(`Worker ${method} timed out`)); }, 60000);
const receive = (event) => {
if (event.sessionId !== sessionId) return;
const response = JSON.parse(event.message);
if (response.id !== requestId) return;
clearTimeout(timeout);
browserCdp.off("Target.receivedMessageFromTarget", receive);
response.error ? reject(new Error(response.error.message)) : resolve(response.result);
};
browserCdp.on("Target.receivedMessageFromTarget", receive);
void browserCdp.send("Target.sendMessageToTarget", { sessionId, message: JSON.stringify({ id: requestId, method }) }).catch(reject);
});
await request("HeapProfiler.collectGarbage");
report.workerHeaps.push({ url: target.url, ...await request("Runtime.getHeapUsage") });
if (process.env.PROFILE_WORKER_HEAP) {
const chunks = [];
const receive = (event) => {
if (event.sessionId !== sessionId) return;
const message = JSON.parse(event.message);
if (message.method === "HeapProfiler.addHeapSnapshotChunk") chunks.push(message.params.chunk);
};
browserCdp.on("Target.receivedMessageFromTarget", receive);
await request("HeapProfiler.takeHeapSnapshot");
browserCdp.off("Target.receivedMessageFromTarget", receive);
const output = `${process.env.PROFILE_WORKER_HEAP}-${target.targetId}.heapsnapshot`;
mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, chunks.join(""));
report.workerHeaps.at(-1).snapshot = output;
}
await browserCdp.send("Target.detachFromTarget", { sessionId });
}
await browserCdp.detach();
if (process.env.PROFILE_HEAP) {
const chunks = []; cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => chunks.push(chunk));
await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false });
mkdirSync(dirname(process.env.PROFILE_HEAP), { recursive: true });
writeFileSync(process.env.PROFILE_HEAP, chunks.join(""));
}
await page.getByRole("tab", { name: /Sources/ }).click();
const type = async () => {
await page.locator(".cm-content").click(); await page.keyboard.press("ControlOrMeta+End");
Expand Down
18 changes: 13 additions & 5 deletions apps/writer/scripts/perf/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@ export const main = "manuscripts/main.md";
const pad = (n: number) => String(n).padStart(5, "0");
export function generateCollection(seed: (path: string, type: string, fm: JsonObject, body: string) => void, highlightsPerBook?: number, browser = false): string {
const body = "# Main {#sec-main}\n\n" + Array.from({ length: 100 }, (_, i) => `${browser ? `## Section ${i} {#sec-${i}}\n\n` : ""}Paragraph ${i}: quoted passage ${i}. A sustained cited argument [@source${pad(i)}].\n\n`).join("");
// Share the fixture's intentionally identical text instead of retaining one
// flattened copy per seeded record. Cardinality/logical payloads are unchanged.
// This is a fixture correction, not a real-collection memory optimisation.
const sourceBody = "Source body not needed for citation.\n".repeat(60);
const annotationBody = `> ${"A quotation with evidence from the source. ".repeat(8)}\n\n${"Private reading note. ".repeat(32)}\n`;
const noteBody = "Ordinary collection note.\n".repeat(80);
const commentBody = "A comment discussing the passage. ".repeat(16);
const abstract = "Source metadata. ".repeat(40);
for (let i = 0; i < counts.manuscripts; i++) seed(i === 0 ? main : `manuscripts/paper-${pad(i)}.md`, "writer-manuscript", { title: `Paper ${pad(i)}`, csl: "apa", template: "article" }, body);
for (let i = 0; i < counts.sources; i++) seed(`sources/source${pad(i)}.md`, "reader-source", {
title: `Synthetic source ${pad(i)}`,
csl: { id: `source${pad(i)}`, type: "article-journal", title: `Synthetic research ${pad(i)}: evidence and collection performance`, author: [{ family: `Author${pad(i % 997)}`, given: "Alex" }], issued: { "date-parts": [[2000 + i % 25]] }, "container-title": "Journal of Synthetic Data", DOI: `10.0000/${i}`, abstract: "Source metadata. ".repeat(40) },
}, "Source body not needed for citation.\n".repeat(60));
for (let i = 0; i < counts.annotations; i++) seed(`annotations/highlight-${pad(i)}.md`, "reader-annotation", { source: `[[sources/source${pad(highlightsPerBook === undefined ? i % counts.sources : i < highlightsPerBook ? 0 : 1 + i % (counts.sources - 1))}]]`, locator: { label: `p. ${i % 300 + 1}` } }, `> ${"A quotation with evidence from the source. ".repeat(8)}\n\n${"Private reading note. ".repeat(32)}\n`);
for (let i = 0; i < counts.notes; i++) seed(`notes/note-${pad(i)}.md`, "note", { title: `Note ${pad(i)}`, tags: ["synthetic", "research"] }, "Ordinary collection note.\n".repeat(80));
csl: { id: `source${pad(i)}`, type: "article-journal", title: `Synthetic research ${pad(i)}: evidence and collection performance`, author: [{ family: `Author${pad(i % 997)}`, given: "Alex" }], issued: { "date-parts": [[2000 + i % 25]] }, "container-title": "Journal of Synthetic Data", DOI: `10.0000/${i}`, abstract },
}, sourceBody);
for (let i = 0; i < counts.annotations; i++) seed(`annotations/highlight-${pad(i)}.md`, "reader-annotation", { source: `[[sources/source${pad(highlightsPerBook === undefined ? i % counts.sources : i < highlightsPerBook ? 0 : 1 + i % (counts.sources - 1))}]]`, locator: { label: `p. ${i % 300 + 1}` } }, annotationBody);
for (let i = 0; i < counts.notes; i++) seed(`notes/note-${pad(i)}.md`, "note", { title: `Note ${pad(i)}`, tags: ["synthetic", "research"] }, noteBody);
for (let i = 0; i < counts.comments; i++) seed(`comments/comment-${pad(i)}.md`, "comment", {
document: i % 10 === 0 ? "[[manuscripts/main]]" : `[[notes/note-${pad(i % counts.notes)}]]`, created_at: "2026-01-01T00:00:00Z", status: browser ? "open" : i % 3 === 0 ? "resolved" : "open", motivation: "commenting", target: { quote: { exact: `quoted passage ${i % 100}` } },
...(browser && i > 0 && i % 10 === 0 && i < 1500 ? { in_reply_to: "[[comments/comment-00000]]" } : {}),
}, "A comment discussing the passage. ".repeat(16));
}, commentBody);
return body;
}
Loading