Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
100 commits
Select commit Hold shift + click to select a range
aa1ea34
writer: make the workspace record-session port structural
callumalpass Oct 10, 2026
d7793aa
feat: adapt Writer records to shared native SDK leases
callumalpass Oct 10, 2026
a2eb73a
chore: pin qualified shared read-helper SDK successor
callumalpass Oct 10, 2026
5d0c0d7
chore: pin qualified explicit-environment SDK successor
callumalpass Oct 10, 2026
b69984d
add public SDK sign-in UI with explicit collection boundary (#22)
callumalpass Oct 10, 2026
9cfbcdf
reuse shared controls for native sign-in actions (#23)
callumalpass Oct 10, 2026
5387283
Close the owned Connect popup after consent completes (#24)
callumalpass Oct 10, 2026
71a1a6e
Pin release-qualified SDK819 archive (#25)
callumalpass Oct 10, 2026
318868f
Pin release-qualified SDK837 archive (#26)
callumalpass Oct 11, 2026
00717a6
Port Writer workspace to the public SDK application entry
callumalpass Oct 11, 2026
d277748
Pin Writer to the original native-containing SDK development cut
callumalpass Oct 11, 2026
5d8595f
Adopt the live SDK selection and original write recovery boundary
callumalpass Oct 11, 2026
cb57799
Default Writer LAB builds to the declaration-only SDK singleton
callumalpass Oct 11, 2026
e7a1010
writer: adopt atomic contracts and public metadata projections
callumalpass Oct 11, 2026
c68e0b1
writer: isolate catalog diagnostics and renew owned change watches
callumalpass Oct 11, 2026
6ec87fd
writer: pin original DEV LAB SDK 6a576a5f
callumalpass Oct 11, 2026
8cb783d
writer: review setup changes before applying them
callumalpass Oct 11, 2026
303e8fe
writer: setup review reports unconfirmed outcomes, skips empty plans,…
callumalpass Oct 11, 2026
47ccea9
writer: keep setup outcome helper module-private
callumalpass Oct 11, 2026
973d969
writer: show untyped notes, degrade per type, reconnect the change watch
callumalpass Oct 11, 2026
68ba3e8
writer: consolidate SDK readiness, setup review and type degradation …
callumalpass Oct 11, 2026
7076eff
Pin SDK 4c, renew sign-in and hide unavailable native image upload
callumalpass Oct 11, 2026
8d62803
Pin SDK 48b9f094
callumalpass Oct 11, 2026
381898c
Pin SDK 20c347ad
callumalpass Oct 12, 2026
3bcea71
Pin SDK ac3bff98
callumalpass Oct 12, 2026
2631e3c
Keep the setup review open while setup applies
callumalpass Oct 12, 2026
17be206
Pin SDK 1f7b4f7a
callumalpass Oct 12, 2026
5d8c186
Pin SDK 8bc12525
callumalpass Oct 12, 2026
ae6e813
Pin SDK cf294bdb
callumalpass Oct 12, 2026
be62f80
Pin SDK a965bd26
callumalpass Oct 12, 2026
594e714
Pin SDK 5f2c3d5d
callumalpass Oct 12, 2026
431dfa1
Pin SDK 8b18a6c7
callumalpass Oct 12, 2026
9f76603
writer: compare contract digests in the wire's sha256: form
callumalpass Oct 12, 2026
0170ad8
writer: space the collection controls on the gate and Manuscripts header
callumalpass Oct 12, 2026
e993f33
Pin SDK 0f9a3614
callumalpass Oct 12, 2026
224bc95
writer: open a collection whose catalog has no settings (UX-001)
callumalpass Oct 12, 2026
29ec11d
writer: judge a created record by its confirmed read, not the optimis…
callumalpass Oct 12, 2026
eff8e12
Pin SDK 1dddb04c
callumalpass Oct 12, 2026
518b1bb
Pin SDK 5710ab13
callumalpass Oct 12, 2026
9b70314
Pin SDK 39ba2b8a
callumalpass Oct 12, 2026
601ded7
Pin SDK 53643b29
callumalpass Oct 12, 2026
8183986
Pin SDK b1c47a32
callumalpass Oct 12, 2026
e9bd9c0
Pin SDK fb3308a8
callumalpass Oct 12, 2026
fa41d21
Use the shared collection screens from @mdbase-dev/ui
callumalpass Oct 12, 2026
d45ca35
Pin SDK 4728a54c
callumalpass Oct 12, 2026
079b25b
Pin @mdbase-dev/ui 5df37dc9
callumalpass Oct 12, 2026
807b3df
Let the collection gate start the SDK entry
callumalpass Oct 12, 2026
45bec57
Leave earlier sessions' writes to the SDK write ledger
callumalpass Oct 12, 2026
036541b
Test with the SDK's createTestApp
callumalpass Oct 12, 2026
7e7ae95
Pin SDK 0aeaada6
callumalpass Oct 12, 2026
4eacd91
Pin SDK 47225957
callumalpass Oct 12, 2026
4385719
Pin SDK fd0c4a67
callumalpass Oct 12, 2026
af8c01e
Expect an unconfirmed write to be saved on this device
callumalpass Oct 12, 2026
a1aa085
Say saved on this device, waiting for sync, for changes kept offline
callumalpass Oct 12, 2026
447697b
Serve a not-found page for unknown paths
callumalpass Oct 12, 2026
f62f6af
Pin @mdbase-dev/ui 975fc4a0
callumalpass Oct 12, 2026
9889f03
Pin SDK 013c7e56
callumalpass Oct 13, 2026
58ee54d
Pin @mdbase-dev/ui 7ff0ce02
callumalpass Oct 13, 2026
b5d42fc
Create manuscripts and chapters once when a create is retried
callumalpass Oct 13, 2026
51f2c65
Wait for the native scope in the Writer root test
callumalpass Oct 13, 2026
aa4402d
Pin SDK 608bb0d5
callumalpass Oct 13, 2026
3947518
Pin SDK a8fdbdce
callumalpass Oct 13, 2026
1a9986b
Pin @mdbase-dev/ui 27fc243f
callumalpass Oct 13, 2026
6b462b7
Hand the Private collection consent result back from the popup
callumalpass Oct 13, 2026
76654a9
Pin SDK fb9691b0
callumalpass Oct 13, 2026
f8247fc
Show offline, reconnecting and waiting-for-sync state
callumalpass Oct 13, 2026
aea7687
Pin SDK d230034f
callumalpass Oct 13, 2026
9543e5c
Create a comment once when its create is retried
callumalpass Oct 13, 2026
e7caca1
Declare replace and move file actions for record edits
callumalpass Oct 13, 2026
8dad23a
Pin SDK 1629d11e
callumalpass Oct 13, 2026
e68fba9
Render SDK storage warnings and shared Private collection entry
callumalpass Oct 13, 2026
068a3d3
Pin SDK e00c1ff8
callumalpass Oct 13, 2026
22a97b5
Pin SDK af29edc9
callumalpass Oct 13, 2026
4c3228a
Pin SDK 12c28ef1
callumalpass Oct 13, 2026
3007c17
Pin SDK 23a10ee4
callumalpass Oct 13, 2026
a59755a
Pin SDK ca8f0682
callumalpass Oct 13, 2026
d6a8068
fix: cache offline app shell
callumalpass Oct 13, 2026
363a951
Pin SDK abc08f3e
callumalpass Oct 13, 2026
10cdb6e
fix: preserve figure labels and clarify export warnings
callumalpass Oct 13, 2026
1bbc5f1
fix: keep soft line breaks visible while editing paragraphs
callumalpass Oct 13, 2026
0fa6c2d
Pin SDK e4450c26
callumalpass Oct 13, 2026
e3a5572
fix(writer): make recovered-draft editing pauses explicit
callumalpass Oct 13, 2026
81776af
Pin SDK 3491a619
callumalpass Oct 13, 2026
b9c68a6
fix(writer): keep install invitation above phone navigation
callumalpass Oct 13, 2026
c26248f
Preserve visible source lines and end automatic lists cleanly
callumalpass Oct 13, 2026
6d5d31c
Pin SDK 6f2b1e51
callumalpass Oct 13, 2026
acdc17d
fix(writer): retain confirmed export outcomes and report download fai…
callumalpass Oct 13, 2026
db10ce1
fix(writer): keep find option labels beside their checkboxes
callumalpass Oct 13, 2026
d94bbe2
Pin SDK 4c10b9a5
callumalpass Oct 13, 2026
8d6e9ae
Pin SDK 5c7b9592
callumalpass Oct 13, 2026
63d7252
Pin SDK 6f14ebe1
callumalpass Oct 13, 2026
5bfd6d7
Pin SDK 0f5724ef
callumalpass Oct 13, 2026
e647c5f
Pin SDK 106d36ff
callumalpass Oct 14, 2026
5835153
Pin SDK f1aa67e3
callumalpass Oct 14, 2026
fc73b91
Pin SDK bef3fe45
callumalpass Oct 14, 2026
451902f
fix(writer): retain ready discovery snapshots while refreshing
callumalpass Oct 14, 2026
15fe19f
Pin SDK 3f417b6d
callumalpass Oct 14, 2026
055209f
Pin unified SDK 72f8ea6 and defer Native popup close to its original …
callumalpass Oct 14, 2026
32b3eee
Use declaration-bound Private setup and atomic shared-lock SDK
callumalpass Oct 15, 2026
ff6dc64
Retain LAB Private manuscript edit and reload acceptance
callumalpass Oct 15, 2026
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
9 changes: 7 additions & 2 deletions apps/writer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"icons": "node scripts/write-pwa-icons.mjs",
"build": "pnpm manifest && pnpm manifest:validate && vite build",
"build:lab": "VITE_MDBASE_ENV=lab pnpm build",
"dev": "pnpm manifest && vite --host 127.0.0.1",
"manifest": "node scripts/write-manifest.mjs",
"manifest:validate": "mdbase-connect-dev validate-manifest public/.well-known/mdbase-app.json",
Expand All @@ -32,23 +33,27 @@
"@lezer/highlight": "^1.2.3",
"@lezer/markdown": "^1.4.3",
"@mdbase-dev/connect": "0.1.0-beta.125",
"@mdbase-dev/ui": "0.1.0-beta.125",
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz",
"@mdbase-dev/sdk": "file:../../vendor/mdbase-dev-sdk-dev-9a033b25.tgz",
"@mdbase-dev/ui": "file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz",
"@mdbase-writer/core": "workspace:*",
"@myriaddreamin/typst-ts-renderer": "0.7.0",
"@myriaddreamin/typst-ts-web-compiler": "0.7.0",
"@myriaddreamin/typst.ts": "0.7.0",
"pandoc-wasm": "1.1.0",
"@phosphor-icons/react": "^2.1.10",
"pandoc-wasm": "1.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@callumalpass/mdbase": "0.3.0-rc.5",
"@mdbase-dev/connect-dev": "0.1.0-beta.125",
"@mdbase-dev/connect-testing": "0.1.0-beta.125",
"@testing-library/react": "16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"jsdom": "29.1.1",
"playwright": "1.61.1",
"vite": "^8.3.1",
"yaml": "^2.9.0"
Expand Down
53 changes: 53 additions & 0 deletions apps/writer/public/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Page not found · mdbase writer</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
:root {
color-scheme: light dark;
--canvas: oklch(99.5% 0.002 255);
--ink: oklch(21% 0.018 255);
--muted: oklch(54% 0.01 255);
--line: oklch(92% 0.006 255);
--accent: oklch(47% 0.1 238);
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: oklch(17.5% 0.012 255);
--ink: oklch(92% 0.008 255);
--muted: oklch(62% 0.011 255);
--line: oklch(29% 0.012 255);
--accent: oklch(73% 0.105 238);
}
}
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
box-sizing: border-box;
background: var(--canvas);
color: var(--ink);
font: 14px/1.5 "Atkinson Hyperlegible", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main { max-width: 32rem; }
.code { margin: 0 0 0.5rem; color: var(--muted); font: 12px/1.4 "Azeret Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: 0.04em; }
h1 { margin: 0 0 0.5rem; font-size: 20px; line-height: 1.25; }
p { margin: 0 0 1rem; color: var(--muted); }
a { color: var(--accent); text-underline-offset: 2px; }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
</head>
<body>
<main>
<p class="code">404</p>
<h1>Page not found</h1>
<p>There is nothing at this address.</p>
<a href="/">Open mdbase writer</a>
</main>
</body>
</html>
67 changes: 67 additions & 0 deletions apps/writer/scripts/browser-export-review-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Loopback-only, real Pandoc conversion and browser downloads; no LAB credentials.
import assert from "node:assert/strict";
import {mkdir} from "node:fs/promises";
import {chromium} from "playwright";
const base = process.argv[2] ?? "http://127.0.0.1:48332/";
const origin = new URL(base).origin;
assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname));
await mkdir("out", {recursive: true});
const browser = await chromium.launch({executablePath: process.env.CHROME});
const context = await browser.newContext({viewport: {width: 1280, height: 900}});
await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
const page = await context.newPage();
let downloads = 0;
page.on("download", () => downloads++);
try {
await page.goto(`${base}?demo`);
await page.locator(".manuscript-title").filter({hasText: "Patient Observation"}).click();
await page.locator(".cm-content[contenteditable=true]").click();
await page.keyboard.press("Control+a");
await page.keyboard.insertText("# Unresolved citation test\n\n[test] Word export with [@bugfix3missing, p. 3].\n");
await page.getByRole("button", {name: "Export formats", exact: true}).click();
await page.getByRole("menuitem", {name: /Word/}).click();
const review = page.getByRole("dialog", {name: "Review export problems", exact: true});
await review.waitFor({timeout: 120000});
assert.match(await review.innerText(), /bugfix3missing/);
assert.equal(downloads, 0);
await review.getByRole("button", {name: "Cancel and fix problems", exact: true}).click();
await review.waitFor({state: "hidden"});
assert.equal(downloads, 0);
console.log("PASS unresolved citation review and cancellation download nothing");

await page.getByRole("button", {name: "Export Word", exact: true}).click();
await review.waitFor();
// Listen BEFORE the click: the browser may emit download before click() resolves.
const [download] = await Promise.all([
page.waitForEvent("download", {timeout: 20000}),
review.getByRole("button", {name: "Export anyway", exact: true}).click(),
]);
await download.saveAs("out/unresolved-citation.docx");
assert.equal(await download.failure(), null);
assert.match(download.suggestedFilename(), /\.docx$/);
await review.waitFor({state: "hidden"});
await page.waitForTimeout(800);
assert.match(await page.getByRole("alert").innerText(), /Exported Word with/);
assert.equal(downloads, 1);
console.log("PASS consent downloads DOCX and leaves its warning visible after native close");

await page.evaluate(() => {
const original = URL.createObjectURL.bind(URL);
URL.createObjectURL = blob => {
if (blob.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") throw new Error("Controlled download refusal.");
return original(blob);
};
});
await page.getByRole("button", {name: "Export Word", exact: true}).click();
await review.waitFor();
await review.getByRole("button", {name: "Export anyway", exact: true}).click();
await review.waitFor({state: "hidden"});
await page.waitForTimeout(800);
assert.match(await page.getByRole("alert").innerText(), /The file could not be downloaded\. Controlled download refusal\./);
assert.equal(downloads, 1);
await page.getByRole("button", {name: "Retry export", exact: true}).waitFor();
console.log("PASS failed download leaves an actionable error after native close");
} catch (error) {
await page.screenshot({path: "out/export-review-failure.png"});
throw error;
} finally { await context.close(); await browser.close(); }
57 changes: 57 additions & 0 deletions apps/writer/scripts/browser-find-panel-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Isolated loopback demo and real CodeMirror/CSS; no LAB credentials.
import assert from "node:assert/strict";
import {mkdir} from "node:fs/promises";
import {chromium} from "playwright";
const base = process.argv[2] ?? "http://127.0.0.1:48332/";
const origin = new URL(base).origin;
assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname));
await mkdir("out", {recursive: true});
const browser = await chromium.launch({executablePath: process.env.CHROME});
const context = await browser.newContext({viewport: {width: 1280, height: 900}});
await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
const page = await context.newPage();
try {
await page.goto(`${base}?demo`);
await page.locator(".manuscript-title").filter({hasText: "Patient Observation"}).click();
for (const width of [1280, 390, 320]) {
await page.setViewportSize({width, height: width === 1280 ? 900 : 844});
if (width < 900) await page.getByRole("navigation", {name: "View", exact: true}).getByRole("button", {name: /Write/}).click();
const panel = page.locator(".cm-search");
if (!await panel.isVisible()) {
await page.locator(".cm-content[contenteditable=true]").click();
await page.keyboard.press("Control+f");
}
await panel.waitFor();
const geometry = await panel.locator("label").evaluateAll(labels => labels.map(label => {
const input = label.querySelector("input");
const text = [...label.childNodes].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
const range = document.createRange();
range.selectNodeContents(text);
const box = input.getBoundingClientRect(), caption = range.getBoundingClientRect(), target = label.getBoundingClientRect();
return {text: label.innerText, width: box.width, gap: caption.left - box.right, center: Math.abs((caption.top + caption.bottom - box.top - box.bottom) / 2), targetHeight: target.height};
}));
assert.equal(geometry.length, 3);
for (const option of geometry) {
assert.ok(option.width <= 24, `${width}px stretched checkbox: ${JSON.stringify(option)}`);
assert.ok(option.gap >= 0 && option.gap <= 12, `${width}px separated label: ${JSON.stringify(option)}`);
assert.ok(option.center <= 6, `${width}px separate rows: ${JSON.stringify(option)}`);
if (width < 900) assert.ok(option.targetHeight >= 44, `${width}px small touch label: ${JSON.stringify(option)}`);
}
for (const name of ["match case", "regexp", "by word"]) {
const checkbox = panel.getByRole("checkbox", {name, exact: true});
const before = await checkbox.isChecked();
await panel.locator("label").filter({hasText: name}).click();
assert.equal(await checkbox.isChecked(), !before);
await checkbox.focus();
await page.keyboard.press("Space");
assert.equal(await checkbox.isChecked(), before);
}
const bounds = await panel.boundingBox();
assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= width + 1);
await page.screenshot({path: `out/find-panel-${width}.png`});
console.log(`PASS ${width}px adjacent labels, label/keyboard toggles and panel fits`);
}
} catch (error) {
await page.screenshot({path: "out/find-panel-failure.png"});
throw error;
} finally { await context.close(); await browser.close(); }
51 changes: 51 additions & 0 deletions apps/writer/scripts/browser-install-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Isolated loopback demo, real Writer CSS and install invitation; no LAB credentials.
import assert from "node:assert/strict";
import {chromium} from "playwright";
const base = process.argv[2] ?? "http://127.0.0.1:48332/";
const origin = new URL(base).origin;
assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname));
const browser = await chromium.launch({executablePath: process.env.CHROME});
const context = await browser.newContext({viewport: {width: 390, height: 844}});
await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
const page = await context.newPage();
try {
await page.goto(`${base}?demo`);
await page.locator(".manuscript-title").filter({hasText: "Patient Observation"}).click();
await page.locator(".pane-tabs").waitFor();
// A controlled browser invitation, never an actual installation.
await page.evaluate(() => {
const event = new Event("beforeinstallprompt", {cancelable: true});
Object.assign(event, {prompt: async () => {}, userChoice: Promise.resolve({outcome: "dismissed"})});
window.dispatchEvent(event);
});
await page.locator(".pwa-install").waitFor({timeout: 35000});
for (const [width, height] of [[390, 844], [320, 568], [844, 390]]) {
await page.setViewportSize({width, height});
for (const name of ["Sidebar", "Write", "Preview"]) {
await page.getByRole("navigation", {name: "View", exact: true}).getByRole("button", {name: new RegExp(`^${name}`)}).click();
const geometry = await page.evaluate(() => {
const banner = document.querySelector(".pwa-install");
const prompt = banner.getBoundingClientRect();
const nav = document.querySelector(".pane-tabs").getBoundingClientRect();
const withPrompt = document.documentElement.scrollWidth;
banner.hidden = true;
const withoutPrompt = document.documentElement.scrollWidth;
banner.hidden = false;
return {gap: nav.top - prompt.bottom, top: prompt.top, fits: prompt.left >= 0 && prompt.right <= innerWidth, withPrompt, withoutPrompt};
});
assert.ok(geometry.gap >= 8, `${width}px ${name}: ${JSON.stringify(geometry)}`);
assert.ok(geometry.top >= 0); assert.equal(geometry.fits, true);
// A zoomed document preview may itself be wider; the invitation must add no overflow.
assert.equal(geometry.withPrompt, geometry.withoutPrompt, `${width}px ${name}: ${JSON.stringify(geometry)}`);
}
console.log(`PASS install invitation leaves all three mode controls reachable at ${width}x${height}`);
}
await page.setViewportSize({width: 1280, height: 900});
assert.equal(await page.locator(".pwa-install").evaluate(e => getComputedStyle(e).bottom), "16px");
await page.setViewportSize({width: 390, height: 844});
await page.getByRole("button", {name: "Manuscripts", exact: true}).click();
assert.equal(await page.locator(".pwa-install").evaluate(e => getComputedStyle(e).bottom), "16px");
await page.getByRole("button", {name: "Not now", exact: true}).click();
assert.equal(await page.locator(".pwa-install").count(), 0);
console.log("PASS desktop/home positioning and dismissal");
} catch (error) {await page.screenshot({path: "out/install-prompt-failure.png"}); throw error;} finally {await context.close(); await browser.close();}
45 changes: 44 additions & 1 deletion apps/writer/scripts/browser-reliability-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,49 @@ try {
assert.equal(await body(page, main), before);
});

for (const list of [false, true]) await scenario(`${list ? "list" : "paragraph"} Enter stays visible while typing and undo redo retain exact source`, async (page) => {
await open(page);
await page.evaluate((main) => window.writer.workspace.setBody(main, "# [test] typing\n\n"), main);
await page.locator(".cm-content").click(); await page.keyboard.press("ControlOrMeta+End");
for (const text of [list ? "- One" : "One", "Two", "Three"]) {
await page.keyboard.type(text); await page.keyboard.press("Enter");
}
if (list) await page.keyboard.press("Enter");
await page.keyboard.type("After"); await page.keyboard.press("Enter");
const expected = `# [test] typing\n\n${list ? "- One\n- Two\n- Three\n" : "One\nTwo\nThree"}\nAfter\n`;
assert.equal(await body(page, main), expected);
const lines = await page.locator(".cm-line").allTextContents();
assert.ok(lines.includes(list ? "- Three" : "Three"), "the third line must not merge with After");
assert.ok(lines.includes("After"), "the new line is visible, not an atomic hidden range");
await page.waitForTimeout(600); // Start a separate typing history group.
await page.keyboard.type("Undo marker.");
assert.equal(await body(page, main), `${expected}Undo marker.`, "typing after Enter keeps the newline");
await page.keyboard.press("ControlOrMeta+z"); assert.equal(await body(page, main), expected, "Undo restores the exact prior body");
// A real shifted letter reports key="Z". Playwright's lowercase +Shift+z
// reports key="z", which CodeMirror correctly handles as the Undo binding.
await page.keyboard.press("ControlOrMeta+Shift+Z"); assert.equal(await body(page, main), `${expected}Undo marker.`, "Redo restores the exact typed body");
assert.ok((await page.locator(".cm-line").allTextContents()).includes("Undo marker."));
if (!await page.evaluate(() => /Mac/.test(navigator.platform))) {
await page.keyboard.press("Control+z"); assert.equal(await body(page, main), expected);
await page.keyboard.press("Control+y"); assert.equal(await body(page, main), `${expected}Undo marker.`);
}
// Reload starts with the caret elsewhere. Moving it away must never make
// a stored Enter vanish from the displayed lines (the old folding bug).
await page.keyboard.press("ControlOrMeta+Home");
assert.ok((await page.locator(".cm-line").allTextContents()).includes("After"));
assert.ok((await page.locator(".cm-line").allTextContents()).includes(list ? "- Three" : "Three"));
assert.equal(await body(page, main), `${expected}Undo marker.`);
});

await scenario("explicit bullet after automatic continuation is not doubled", async (page) => {
await open(page);
await page.evaluate((path) => window.writer.workspace.setBody(path, "# [test] manual markers\n\n"), main);
await page.locator(".cm-content").click(); await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.type("- Staghorn corals"); await page.keyboard.press("Enter");
await page.keyboard.type("- Brain corals");
assert.equal(await body(page, main), "# [test] manual markers\n\n- Staghorn corals\n- Brain corals");
});

await scenario("chapter switching retains undo redo selection and scroll", async (page) => {
await open(page); await painted(page);
const chapter = "chapters/worms.md";
Expand Down Expand Up @@ -258,7 +301,7 @@ try {
await page.locator(".cm-content").evaluate((el) => el.focus({ preventScroll: true }));
await page.keyboard.press("ControlOrMeta+z");
assert.equal(await body(page, chapter), before);
await page.keyboard.press("ControlOrMeta+Shift+z");
await page.keyboard.press("ControlOrMeta+Shift+Z");
assert.match(await body(page, chapter), /Retained history marker/);
await page.keyboard.type(" Cursor kept.");
assert.equal((await body(page, chapter)).endsWith("Retained history marker. Cursor kept."), true);
Expand Down
14 changes: 9 additions & 5 deletions apps/writer/scripts/browser-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ async function typeAtEndOfParagraph(needle, text) {
await step("home lists the demo manuscripts", async () => {
await page.goto(`${base}?demo`);
await page.getByRole("heading", { name: "Manuscripts" }).waitFor();
const titles = await page.locator(".manuscript-title").allTextContents();
// Recovery drafts share the title class, but are not registered manuscripts.
const titles = await page.locator(".manuscript-row[data-path] .manuscript-title").allTextContents();
assert.deepEqual(titles, ["Patient Observation", "Small Agents, Large Effects: Darwin and the Science of Slow Change"]);
});

Expand Down Expand Up @@ -226,8 +227,9 @@ await step("an embedded Reader annotation shows and typesets as its quotation, a
await step("a toolbar over a selection formats it and finds a source for it", async () => {
const before = await editorText();
const bar = page.locator(".cm-selection-bar");
// Double-click "Charles" (hard-wrapped lines are joined), then take "Lyell" too.
const charles = await page.evaluate(() => {
// Scrolling the larger source-line view updates CodeMirror's viewport on a
// frame. Wait for its text nodes, then double-click Charles and take Lyell.
const charles = await page.waitForFunction(() => {
const walker = document.createTreeWalker(document.querySelector(".cm-content"), NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const at = node.textContent.indexOf("Charles Lyell, who");
Expand All @@ -240,8 +242,10 @@ await step("a toolbar over a selection formats it and finds a source for it", as
}
return null;
});
assert.ok(charles, "found “Charles Lyell, who” in the editor");
await page.mouse.dblclick(charles.x, charles.y);
const position = await charles.jsonValue();
await charles.dispose();
assert.ok(position, "found “Charles Lyell, who” in the editor");
await page.mouse.dblclick(position.x, position.y);
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
await bar.waitFor({ timeout: 5_000 });
await bar.getByRole("button", { name: "Bold" }).click();
Expand Down
Loading
Loading