diff --git a/AGENTS.md b/AGENTS.md index 569d0431..bce678f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,23 @@ compiler cannot check — a Tauri command name, an i18n key, a second copy of a behavior. A green run is not a claim that every behavior is covered; a source-shape assertion passes as long as the line it matches is still spelled that way. +### Where a test goes + +Tests are grouped by feature, not by the fix that added them: + +- `scripts/.test.ts` and `scripts/.spec.ts` hold behavior tests (the + runner rules below decide which of the two). +- `scripts/.checks.test.ts` / `.checks.spec.ts` hold the cases that read source as + text, kept apart from the behavior tests. +- `scripts/..test.ts` / `.spec.ts` is for a part of a feature that must run + in its own process (module mocks, a process-wide shim such as `installShimDom()`) or would + make the main file unreadable. + +A new case goes into the existing file for its feature, as a `test` inside the `describe` +it belongs to or a new `describe`. A new file is for a new feature or one of the reasons +above, not for a new fix. Name a test by the behavior it claims; put the issue number and +the bug history in a comment above it. + ### Which runner a test belongs to **Write it as `*.spec.ts` (vitest) when the subject is a `.svelte.ts` runes module, or when @@ -215,25 +232,10 @@ unchanged. Two things bite: answer, so a bare `invoke: () => Promise.resolve(null)` leaves `osType` null and the font defaults it indexes undefined. -One shimmed file is still where it is, because a rename does not reach what is wrong -with it: - -- `foldStatePerDocument.test.ts` cuts functions out of `.svelte` files with its own - brace-pairing scanner. The logic has to move into a `.svelte.ts` module first. - -Of the other two: - -- `tabReadingPosition.test.ts` called `installShimDom()`, which assigns - `globalThis.document` — under vitest that replaces jsdom's. Its anchor harness was - rewritten onto the real DOM (`tabReadingPosition.spec.ts`), which it could be because - `findAnchorElement` reads structure and attributes and measures nothing. -- `checkedReadMigration.test.ts` was two files in one, which is why neither answer fit - it. Six of its tests are the never-migrate categories — an unchecked command existing - nowhere in `src` *or* in the Rust crate, and four statement-order claims about a - `.svelte` component — and stayed. The three that drive `ensureFullContent` are a runes - module run for real, and are `checkedReadMigration.spec.ts` now. A file that shims - runes is worth opening before it is worth renaming: the split may be per test rather - than per file. +A test that shims runes is worth reading before it is worth moving: the split may be per +test rather than per file. A harness that calls `installShimDom()` assigns +`globalThis.document`, which under vitest replaces jsdom's; such a harness is rewritten onto +the real DOM rather than carried over. ### A real DOM is not a layout @@ -241,8 +243,8 @@ jsdom implements the DOM, not CSS layout: `offsetTop`, `offsetHeight` and every `getBoundingClientRect()` are 0. So migrating a file buys real answers about structure, attributes, traversal and parsing, and buys nothing at all about geometry. A test whose subject is a position on screen has to construct the boxes either way — -`scrollSyncAcrossFolds.test.ts` lays a document out with collapsed folds in it, and -`anchorJumpUnderZoom.spec.ts` builds elements whose rects carry a zoom factor while their +`layout.scrollSync.test.ts` lays a document out with collapsed folds in it, and +`navigation.spec.ts` builds elements whose rects carry a zoom factor while their `offsetWidth` does not. Neither is worse for being under `node --test` or better for being under vitest; the geometry is the fixture. @@ -283,7 +285,7 @@ mandatory for `tauri-plugin-updater` to compare versions correctly. The lock file is the one that gets forgotten, because nothing in the editing loop reads it — a bump without it lands green and stays wrong until the next person's `cargo build` rewrites the line and leaves them a dirty working tree. -`scripts/versionSync.test.ts` asserts the three agree. +`scripts/release.checks.test.ts` asserts the three agree. ## Releasing diff --git a/RELEASING.md b/RELEASING.md index 4115dcac..8f78a3fa 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,7 +44,7 @@ If you ever lose the private key: ### 5. `alecdotdev/Markpad` is load-bearing -The updater endpoint in `src-tauri/tauri.conf.json` points at **`sftwrdotdev/Markpad`**. (`scripts/releaseWorkflow.test.ts` holds that name and the endpoint together — if the repository ever moves again, this line moves with it.) +The updater endpoint in `src-tauri/tauri.conf.json` points at **`sftwrdotdev/Markpad`**. (`scripts/release.checks.test.ts` holds that name and the endpoint together — if the repository ever moves again, this line moves with it.) That only helps builds made from here on. The endpoint is compiled into the binary, so every copy up to and including v2.7.0 asks GitHub for `https://github.com/alecdotdev/Markpad/releases/latest/download/latest.json` and reaches the current feed only because GitHub answers with a 301. Those installs use that redirect for as long as they run. @@ -112,7 +112,7 @@ Back it up in the same place as the minisign private key, and treat it with the ## Per-release workflow -The workflow uses `npm ci`, so its installed dependency graph is exactly the committed lockfile. Do not replace it with `npm install` in release jobs. `scripts/releaseWorkflow.test.ts` guards that. +The workflow uses `npm ci`, so its installed dependency graph is exactly the committed lockfile. Do not replace it with `npm install` in release jobs. `scripts/release.checks.test.ts` guards that. [`snapcraft.yaml`](snapcraft.yaml) builds nothing. It packages the `.deb` the release already shipped (`plugin: dump`, `source-type: deb`), so the snap carries the same binary as the `.deb` and the AppImage. The same test asserts it is packaging rather than building. diff --git a/package.json b/package.json index b2b85621..c949d138 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "preview": "vite preview", "test": "node --test --import tsx \"scripts/*.test.ts\"", "test:vitest": "vitest run", - "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts scripts/frontMatterProseBlock.test.ts", - "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts", + "test:frontmatter": "node --test --import tsx scripts/editing.test.ts scripts/editing.checks.test.ts", + "test:workflows": "node --test --import tsx scripts/export.test.ts scripts/shortcuts.toolbar.test.ts", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "build:test-bundle": "node scripts/build-test-bundle.mjs", diff --git a/scripts/anchorJumpUnderZoom.spec.ts b/scripts/anchorJumpUnderZoom.spec.ts deleted file mode 100644 index 7658b477..00000000 --- a/scripts/anchorJumpUnderZoom.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { anchorScrollTop, previewZoomFactor, PREVIEW_ANCHOR_OFFSET } from '../src/lib/utils/previewAnchor.js'; - -/* - * #621: "Custom viewport zoom bogusly offsets anchor jumps (in preview mode)". - * At 150% a link scrolled past its heading, at 50% it stopped short, and at - * 100% it was right. - * - * The preview sets CSS `zoom` on `.markdown-container`, an ANCESTOR of the - * scrolling element. `getBoundingClientRect()` reports viewport pixels, which - * carry that zoom; `scrollTop` counts the scroller's own pixels, which do not. - * Both call sites added one straight to the other, so the error was exactly the - * zoom factor — invisible at 100%, and the wrong sign on either side of it. - * - * jsdom has no layout, so the elements here are stand-ins that report the - * geometry a browser would at a given zoom: a rect scaled by the factor, and an - * `offsetWidth` that is not. That is the whole relationship under test. - */ - -/** An element whose rect is `zoom`× its layout box, as a zoomed ancestor makes it. */ -function zoomedElement(layoutTop: number, layoutWidth: number, zoom: number) { - return { - offsetWidth: layoutWidth, - getBoundingClientRect: () => ({ top: layoutTop * zoom, width: layoutWidth * zoom }), - } as unknown as HTMLElement; -} - -function scroller(zoom: number, scrollTop: number) { - const el = zoomedElement(0, 800, zoom) as unknown as { scrollTop: number }; - el.scrollTop = scrollTop; - return el as unknown as HTMLElement; -} - -test('the zoom factor is measured off the container, not read from settings', () => { - assert.equal(previewZoomFactor(scroller(1, 0)), 1); - assert.equal(previewZoomFactor(scroller(1.5, 0)), 1.5); - assert.equal(previewZoomFactor(scroller(0.5, 0)), 0.5); -}); - -test('a container with no layout reports 1 rather than a division by zero', () => { - const detached = { - offsetWidth: 0, - scrollTop: 0, - getBoundingClientRect: () => ({ top: 0, width: 0 }), - } as unknown as HTMLElement; - assert.equal(previewZoomFactor(detached), 1); - assert.ok(Number.isFinite(anchorScrollTop(detached, detached))); -}); - -test('an anchor lands the same distance down the document at every zoom', () => { - // The heading sits 1000 layout pixels below the top of the scroller, which - // is already scrolled to 200. Wherever the zoom is, the answer is the same - // number of the scroller's own pixels. - const expected = 200 + 1000 - PREVIEW_ANCHOR_OFFSET; - - for (const zoom of [0.5, 1, 1.5, 2]) { - const container = scroller(zoom, 200); - const heading = zoomedElement(1000, 400, zoom); - assert.equal( - anchorScrollTop(container, heading), - expected, - `zoom ${zoom} must land at ${expected}`, - ); - } -}); - -test('the pre-fix arithmetic is what overshot, and by exactly the zoom factor', () => { - // Kept as the shape of the defect: rect delta added straight to scrollTop. - // It is only correct at 100%, which is why the bug went unseen — and the - // error is the factor itself, matching the report's "way past" at 150% and - // "way before" at 50%. - const preFix = (container: HTMLElement, el: HTMLElement) => - el.getBoundingClientRect().top - - container.getBoundingClientRect().top + - container.scrollTop - - PREVIEW_ANCHOR_OFFSET; - - const correct = 200 + 1000 - PREVIEW_ANCHOR_OFFSET; - - for (const [zoom, sign] of [ - [1.5, 'past'], - [0.5, 'short'], - ] as const) { - const container = scroller(zoom, 200); - const heading = zoomedElement(1000, 400, zoom); - const old = preFix(container, heading); - assert.notEqual(old, correct, `at zoom ${zoom} the old form must be wrong (${sign})`); - // The whole error is the un-divided delta. - assert.equal(old - correct, 1000 * zoom - 1000); - assert.equal(anchorScrollTop(container, heading), correct); - } - - // …and identical at 100%, which is why nobody saw it. - const container = scroller(1, 200); - const heading = zoomedElement(1000, 400, 1); - assert.equal(preFix(container, heading), anchorScrollTop(container, heading)); -}); diff --git a/scripts/assetScopeDotDirs.test.ts b/scripts/assetScopeDotDirs.test.ts deleted file mode 100644 index 0a916cff..00000000 --- a/scripts/assetScopeDotDirs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -// `![](.res/pic.jpg)` rendered blank on Linux and macOS (#981). Tauri's fs -// scope defaults `requireLiteralLeadingDot` to true on Unix, so `**` does not -// match a path segment that starts with a dot and the `asset:` handler answers -// 403. Windows defaults to false, which is why it only showed up off Windows. -test('asset scope lets `**` match dot-directories on every platform', () => { - const conf = JSON.parse(readSource('src-tauri/tauri.conf.json')); - assert.deepEqual(conf.app.security.assetProtocol.scope, { - allow: ['**'], - requireLiteralLeadingDot: false, - }); -}); diff --git a/scripts/backgroundEditorPosition.test.ts b/scripts/backgroundEditorPosition.test.ts deleted file mode 100644 index aa324c67..00000000 --- a/scripts/backgroundEditorPosition.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { outgoingTabAnchorLine, type StoredTabPosition } from '../src/lib/utils/editorPosition.js'; -import { asRendererLine, EDITOR_ANCHOR_LINE_OFFSET } from '../src/lib/utils/lineCoordinates.js'; -import { readSource, offsetOf, sliceFrom } from './sourceTree.js'; - -// A tab left in EDIT-ONLY mode in the background is the one population neither -// writer of `Tab.anchorLine` covers: the preview writes only the active tab and -// is not on screen for this one, and the single editor has moved on to another -// model. What covers it in memory is `editorViewState`, which survives neither -// the window-state snapshot nor the cross-window payload -- so the anchor is -// recovered from it at those two gates. Everything below is about which tabs -// that applies to, because the tabs it must NOT apply to already have a better -// record than the view state. - -/** A view state as Monaco writes one, cut down to the field the derivation reads. */ -function viewStateAtLine(lineNumber: number) { - return { viewState: { firstPosition: { lineNumber, column: 1 }, firstPositionDeltaTop: 0 } }; -} - -function tab(over: Partial = {}): StoredTabPosition { - return { - id: 'background', - isEditing: true, - isSplit: false, - editorViewState: viewStateAtLine(40), - rawContent: 'body\n'.repeat(200), - anchorLine: asRendererLine(0), - ...over, - }; -} - -test('a background edit-only tab is recorded where its view state says it was', () => { - // This is the defect: nothing wrote `anchorLine` for this tab, so `0` is - // what would be persisted -- the top of the document. - assert.equal( - outgoingTabAnchorLine(tab(), 'some-other-tab'), - 40 + EDITOR_ANCHOR_LINE_OFFSET, - ); -}); - -test('the recovered anchor crosses out of the editor numbering, front matter and all', () => { - // `firstPosition.lineNumber` is a BUFFER line and the tab's anchor is a - // RENDERER line. The crossing has one home and this reuses it rather than - // making its own -- the defect shape `lineCoordinates.ts` exists to prevent. - const rawContent = '---\ntitle: t\n---\n\n' + 'body\n'.repeat(200); - assert.equal( - outgoingTabAnchorLine(tab({ rawContent, editorViewState: viewStateAtLine(40) }), null), - 40 + EDITOR_ANCHOR_LINE_OFFSET - 4, - ); -}); - -test('a tab that left edit mode keeps the position its preview wrote', () => { - // THE regression this change could introduce. Nothing clears - // `editorViewState` when a tab leaves edit mode, so a tab the reader edited, - // switched to reading mode and then scrolled carries a stale view state - // under a fresh `anchorLine`. Deriving there would replace a correct record - // with an older one, on the commoner path. - const stale = tab({ isEditing: false, anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(stale, 'some-other-tab'), 120); -}); - -test('a split tab keeps the position its preview wrote', () => { - // A split tab has its preview on screen writing `anchorLine` on every scroll - // event, and the editor beside it is scrolled independently unless - // `isScrollSynced`. Its record is a real reading position; the view state is - // a second opinion about a different pane. - const split = tab({ isSplit: true, anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(split, 'some-other-tab'), 120); -}); - -test('the active tab keeps what the live editor just flushed onto it', () => { - // #735 flushes the active tab from the live editor immediately before both - // gates, and that reading is the better one AND a different one: Monaco's - // `saveViewState` records the first PARTIALLY visible line, while - // `getVisibleRanges()` -- what the flush reads -- starts at the first - // completely visible one. Deriving over the flush would move the active tab - // by a line at most scroll positions. - const active = tab({ id: 'live', anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(active, 'live'), 120); -}); - -test('a tab with no saved view state is left exactly as it is', () => { - // Restored from disk, or transferred in: `editorViewState` starts null on - // both routes and there is nothing to recover from. - const fresh = tab({ editorViewState: null, anchorLine: asRendererLine(7) }); - assert.equal(outgoingTabAnchorLine(fresh, null), 7); -}); - -test('a view state written by an older Monaco is left exactly as it is', () => { - // `firstPosition` is what `reduceRestoreState` falls back from when it is - // missing, so the shape is real and reachable rather than defensive. - const legacy = tab({ editorViewState: { scrollTop: 900 }, anchorLine: asRendererLine(7) }); - assert.equal(outgoingTabAnchorLine(legacy, null), 7); -}); - -test('a percentage is not invented to go with the anchor', () => { - // The view state carries `scrollTop` but neither height, so no fraction of - // the scrollable range can be computed from it. `anchorLine` is the first - // entry of both restore cascades, so recovering it is what decides where the - // tab opens; writing a percentage nobody can justify is not. - const source = readSource('src/lib/utils/editorPosition.ts'); - const derivation = sliceFrom(source, 'export function outgoingTabAnchorLine'); - assert.doesNotMatch(derivation, /scrollPercentage/); -}); - -test('both gates that drop the view state ask for the anchor first', () => { - // The two moments a tab's position leaves memory. Neither carries - // `editorViewState`: `serializeState` omits it and `restoreState` seeds - // null, and `tabTransfer.ts` excludes it as a live Monaco object. - const store = readSource('src/lib/stores/tabs.svelte.ts'); - const serialize = sliceFrom(store, 'serializeState(extra: Record = {}): string {'); - assert.match(serialize, /anchorLine: outgoingTabAnchorLine\(t, this\.activeTabId\)/); - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - const payload = sliceFrom(viewer, 'transferPayload: (tabId) => {'); - assert.match(payload, /anchorLine: outgoingTabAnchorLine\(tab, tabManager\.activeTabId\)/); - // After the spread, or the snapshot's own field would win. - assert.ok(offsetOf(payload, '...snapshotTab(tab)') < offsetOf(payload, 'outgoingTabAnchorLine')); -}); diff --git a/scripts/calloutMarker.test.ts b/scripts/calloutMarker.test.ts deleted file mode 100644 index a993d900..00000000 --- a/scripts/calloutMarker.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { installShimDom, parseHtml } from './renderProtocolDom.ts'; - -installShimDom(); - -const { processMarkdownHtml } = await import('../src/lib/utils/markdown.ts'); - -// Only the text a quote opens with can make it a callout, as in Obsidian and -// GitHub. The marker used to be looked for in every text node of the quote, so -// a `[!note]` shown in inline code, a code block or a nested quote turned the -// whole quote into a callout and the matched text was deleted. - -function render(html: string) { - return parseHtml(processMarkdownHtml(html, '/doc.md', new Set())); -} - -test('a marker in inline code is text, not a callout', () => { - const root = render('
\n

Use [!note] to make a callout

\n
'); - assert.equal(root.querySelector('.markdown-alert'), null); - assert.equal(root.querySelector('code')?.textContent, '[!note]'); -}); - -test('a marker at the top of a code block is text, not a callout', () => { - const root = render('
\n
[!warning] Title\n
\n
'); - assert.equal(root.querySelector('.markdown-alert'), null); - assert.equal(root.querySelector('code')?.textContent, '[!warning] Title\n'); -}); - -test('a callout in a nested quote leaves the outer quote alone', () => { - const root = render( - '
\n

Outer

\n
\n

[!tip] Inner

\n
\n
', - ); - const outer = root.querySelector('blockquote'); - assert.ok(outer, 'the outer quote is still a quote'); - assert.equal(outer.querySelector('p')?.textContent, 'Outer'); - const tip = outer.querySelector('.markdown-alert-tip'); - assert.ok(tip, 'the inner quote is the callout'); - assert.equal(tip.querySelector('.callout-title-inner')?.textContent, 'Inner'); -}); - -test('a quote that opens with the marker is still a callout, fold and title included', () => { - const root = render('
\n

[!note]+ My title
\nbody

\n
'); - const callout = root.querySelector('.markdown-alert-note'); - assert.ok(callout); - assert.ok(callout.classList.contains('callout-foldable')); - assert.equal(callout.querySelector('.callout-title-inner')?.textContent, 'My title'); - assert.match(callout.querySelector('.content-inner')?.textContent ?? '', /body/); -}); diff --git a/scripts/checkedReadMigration.spec.ts b/scripts/checkedReadMigration.spec.ts deleted file mode 100644 index 5e25bb82..00000000 --- a/scripts/checkedReadMigration.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * The behavioural half of `checkedReadMigration.test.ts`: `ensureFullContent`, - * driven for real. - * - * #379 made every read that fills a WRITABLE buffer report whether the decode - * was lossy, so the tab can refuse to write U+FFFD back over a file that was - * merely in another encoding. Three reads kept the bare command: - * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and - * the split pane. They were safe — but only because each re-read a file whose - * tab `loadMarkdown` had already flagged. That is an invariant held by call - * sites agreeing with each other, and the failure mode when one stops agreeing - * is a destroyed document. It is now held by the code: every one of them reads - * the fidelity itself. - * - * These three tests are here rather than in the `.test.ts` because they run the - * store and the session, and both are runes modules. Under `node --test` they - * only ran at all because the file installed `$state`/`$derived`/`$effect` onto - * `globalThis` itself, and a hand-written rune is not the compiler's: no deep - * proxying, no `$derived` laziness, and `$effect` never re-runs. `tab.isTruncated` - * and `tab.hasReplacementChars` are read straight back out of the store here, - * so the shim was standing exactly where the assertions look. - * - * The six that stayed behind are the two never-migrate categories: that the - * unchecked command exists nowhere in `src` and is gone from the Rust crate, - * and the four `.svelte` source-shape assertions about the component call - * sites. Nothing this file could run would observe any of them. - */ - -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -const PARTIAL = 'first half'; -const FULL = 'first half and the rest'; - -/** Set per test. `get_os_type` is the settings store booting on import. */ -let handleInvoke: (cmd: string, args: Record) => unknown = (cmd) => { - if (cmd === 'get_os_type') return 'macos'; - throw new Error(`unexpected invoke: ${cmd}`); -}; -const errors: string[] = []; - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin. Only the Tauri backend, which jsdom cannot provide, is stubbed. -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (command: string, args: Record) => - Promise.resolve(handleInvoke(command.replace(/^plugin:[^|]*\|/, ''), args ?? {})), - transformCallback: (fn: unknown) => fn, -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); - -function makeSession() { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '

x

', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: () => {}, - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: (message) => errors.push(message), - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - }); -} - -/** Open a >50KB file and leave the background full read pending forever. */ -async function openPartial() { - tabManager.closeAll(); - errors.length = 0; - handleInvoke = (cmd) => { - if (cmd === 'open_markdown_preview') return [PARTIAL, false, false]; - if (cmd === 'read_file_content_checked') return new Promise(() => {}); - throw new Error(`unexpected invoke: ${cmd}`); - }; - const session = makeSession(); - await session.loadMarkdown('/docs/big.md'); - const tab = tabManager.activeTab!; - assert.equal(tab.isTruncated, true, 'precondition: the buffer is partial'); - assert.equal(tab.hasReplacementChars, false, 'precondition: the preview decoded cleanly'); - return { session, tab }; -} - -test("completing a partial buffer carries the tail's own verdict", async () => { - // The case the old comment called safe: the preview covered the first 50KB - // and decoded cleanly, but a file can be valid UTF-8 up to there and not - // after. With the bare command the tab kept the preview's verdict and the - // next auto-save wrote U+FFFD over the file. - const { session, tab } = await openPartial(); - - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, true]; - throw new Error(`unexpected invoke: ${cmd}`); - }; - - assert.equal(await session.ensureFullContent(tab.id), true); - assert.equal(tab.rawContent, FULL); - assert.equal(tab.hasReplacementChars, true, 'the completed buffer must carry its own fidelity'); -}); - -test('completing a clean tail also clears a stale flag', async () => { - // The same mechanism in the other direction: a verdict that is decided on - // every read cannot go stale. - const { session, tab } = await openPartial(); - tabManager.setTabDecodedLossy(tab.id, true); - - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, false]; - throw new Error(`unexpected invoke: ${cmd}`); - }; - - assert.equal(await session.ensureFullContent(tab.id), true); - assert.equal(tab.hasReplacementChars, false); -}); - -test('the completed buffer is refused or accepted according to that verdict', async () => { - // End to end: the flag is not decoration, it decides the write. - const { session, tab } = await openPartial(); - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, true]; - if (cmd === 'save_file_content') return null; - throw new Error(`unexpected invoke: ${cmd}`); - }; - await session.ensureFullContent(tab.id); - - assert.equal(await session.saveContent(tab.id), false, 'a lossy buffer must not overwrite its file'); - assert.equal(session.isLossySaveRefused(tab.id), true); - assert.ok(errors.length > 0, 'and the user is told once'); -}); diff --git a/scripts/checkedReadMigration.test.ts b/scripts/checkedReadMigration.test.ts deleted file mode 100644 index 536bfd87..00000000 --- a/scripts/checkedReadMigration.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { offsetOf, readRustBackend, readSource, readSourceFiles, sliceBetween } from './sourceTree.js'; - -/* - * #379 made every read that fills a WRITABLE buffer report whether the decode - * was lossy, so the tab can refuse to write U+FFFD back over a file that was - * merely in another encoding. Three reads kept the bare command: - * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and - * the split pane. They were safe — but only because each re-read a file whose - * tab `loadMarkdown` had already flagged. That is an invariant held by call - * sites agreeing with each other, and the failure mode when one stops agreeing - * is a destroyed document. It is now held by the code: every one of them reads - * the fidelity itself. - * - * WHAT IS LEFT IN THIS FILE, AND WHY IT IS HERE RATHER THAN UNDER VITEST - * - * Every assertion below reads source text, and each one is of a kind no run can - * reach: - * - * - that the unchecked command exists NOWHERE. An absence is not observable - * at run time: executing the checked read cannot prove there is no other - * caller of the unchecked one. The same claim then crosses into the Rust - * crate, where nothing but the spelling connects the two sides. - * - the two MarkdownViewer call sites, and the two auto-save ones. They are - * in a `.svelte` component, and mounting one here would put jsdom in for - * Monaco, mermaid and KaTeX to earn a weaker claim than the text does — - * what these pin is the ORDER of statements inside a handler (flag the tab - * before the buffer is published; recognise the refusal before raising the - * generic toast), which is a property of the code as written. - * - * `ensureFullContent` is not here: it is a runes module driven for real, and - * running it under a hand-written `$state` was standing exactly where the - * assertions look. It is `checkedReadMigration.spec.ts` now. `lossyDecodeSaveGuard.test.ts` - * and `lossySaveRefusalScope.spec.ts` cover the store behaviour these source - * assertions deliberately stop short of. - * - * The second half of this file is the toast the guard's refusal used to - * trigger every 1.5 seconds. - */ - -// --- the two component call sites ------------------------------------------- - -const viewer = readSource('src/lib/MarkdownViewer.svelte'); - -test('the unchecked read command is gone, and nothing calls it', () => { - // This was a three-file allowlist — the files #379 migrated. That is the - // wrong shape for a rule that means "nobody, anywhere": a fourth file - // added the call and the test still passed. Two changes fix it. The scan - // below covers the whole tree, so a new file cannot slip under it. And the - // Rust command itself is deleted, which is what turns the rule from a - // convention into a fact: `read_file_content` is not registered any more, - // so invoking it fails loudly instead of quietly filling a buffer with - // mojibake nothing flagged. - const offenders = readSourceFiles('src') - .filter(({ text }) => /invoke\('read_file_content'/.test(text)) - .map(({ path }) => path); - assert.deepEqual(offenders, [], 'read_file_content no longer exists; read_file_content_checked is the read'); - - const rust = readRustBackend(); - assert.doesNotMatch(rust, /\basync fn read_file_content\(/, 'the unchecked command must stay deleted'); - assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); -}); - -test('entering the editor reads the fidelity and stores it', () => { - const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function saveContent'); - assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const read = offsetOf(toggle, 'read_file_content_checked'); - const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); - assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); -}); - -test('entering split view reads the fidelity and stores it', () => { - const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); - assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); - assert.ok(flag < store, 'flag the tab before the buffer is published'); -}); - -// --- the repeating toast ------------------------------------------------------ - -test('the session can tell a refusal from a failure', () => { - // `saveContent` returns false for both, which is why the auto-save timer - // could not tell them apart. The predicate is no longer set membership on - // its own: the set records what was SAID, and whether the refusal still - // stands is asked of the tab. `lossySaveRefusalScope.spec.ts` exercises - // both halves for real; this only pins that the tab is consulted at all, - // since a predicate that answers from memory alone is the defect. - const body = sliceBetween( - readSource('src/lib/sessions/documentSession.svelte.ts'), - 'function isLossySaveRefused(', - 'function updateLoading', - ); - assert.match(body, /lossySaveWarnedTabs\.has\(tabId\)/); - assert.match(body, /hasReplacementChars/, 'the live tab decides whether anything is still being refused'); -}); - -test('a refused save does not add a generic toast to its own explanation', () => { - const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); - const check = offsetOf(body, 'if (documentSession.isLossySaveRefused(s.id)) return;'); - const toast = offsetOf(body, "t('toast.autoSaveFailed'"); - assert.ok(check < toast, 'the refusal must be recognised before the generic toast is raised'); -}); - -test('a tab that can only be refused stops re-arming the timer', () => { - // Auto-save re-arms on every keystroke. Without this the guard was reached - // again every 1.5s for as long as the user kept typing — each time - // producing the console warning, the wasted round trip, and (before the - // test above) the toast. - const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); - assert.match(body, /decodedLossily: tab\.hasReplacementChars/); - assert.match(body, /const eligible = [^;]*!\(s\.decodedLossily && documentSession\.isLossySaveRefused\(s\.id\)\)/); - // The FIRST attempt must still happen: it is what produces the explanation. - // `isLossySaveRefused` is false until the guard has spoken, so eligibility - // only drops afterwards — and "Save As" to a new file clears - // `hasReplacementChars`, which restores it. - assert.match( - readSource('src/lib/sessions/documentSession.svelte.ts'), - /lossySaveWarnedTabs\.delete\(tab\.id\)/, - ); -}); diff --git a/scripts/ciCacheBudget.test.ts b/scripts/ciCacheBudget.test.ts deleted file mode 100644 index 49615255..00000000 --- a/scripts/ciCacheBudget.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -const workflows = ['build.yml', 'test.yml', 'test_build.yml', 'publish-packages.yml'].map( - (name) => ({ name, text: readSource(`.github/workflows/${name}`) }), -); - -// GitHub gives a repository 10 GB of Actions cache and evicts -// least-recently-used over it. Both assertions below exist because that budget -// was measured being exceeded — 13.22 GB across 117 entries — and because the -// entries that overflow it are evicted in the order that hurts most: the -// largest first, which is exactly the cargo caches the budget is being spent -// on. Neither of these is a law of nature; both are trades with numbers behind -// them, recorded in the workflow comments. Change them by re-doing the -// measurement, not by deleting the assertion. - -test('a cargo cache is written on master and nowhere else', () => { - // Without `save-if` every pull request writes its own copy, and a copy is - // 1.0-1.2 GB per platform: one run of #572 wrote 4.45 GB. Two pull requests - // in flight overflow the budget on their own, and what gets evicted is the - // cargo caches — so the cache becomes a slower way of not caching. - // - // This is also why test_build.yml runs on push to master. The two are one - // decision: `save-if` makes master the only writer, so master has to run. - for (const { name, text } of workflows) { - const uses = [...text.matchAll(/uses: Swatinem\/rust-cache@v2\n([\s\S]{0,400}?)(?=\n\s*- name:|\n\n)/g)]; - for (const [, block] of uses) { - assert.match( - block, - /save-if: \$\{\{ github\.ref == 'refs\/heads\/master' \}\}/, - `${name} caches cargo output without restricting the save to master`, - ); - } - } -}); - -test('setup-node does not cache npm', () => { - // It was the largest consumer of the same budget: 113 entries, 8.76 GB, - // across 50 refs, most of them pull requests closed weeks earlier. Caches - // are scoped by ref, so one is written per branch per platform and nothing - // can bound it the way `save-if` bounds the cargo caches. - // - // What it bought, on runs that hit it: `npm ci` in 6s on Linux, 9s on - // macOS, 15-17s on Windows. It caches `~/.npm`, not `node_modules`, so - // `npm ci` still runs and still links every package — the saving is the - // registry fetch alone. - for (const { name, text } of workflows) { - assert.doesNotMatch( - text, - /^\s*cache: '?npm'?\s*$/m, - `${name} re-enables the npm cache; re-read the budget note in test.yml first`, - ); - } -}); diff --git a/scripts/clipboardOwnershipX11.test.ts b/scripts/clipboardOwnershipX11.test.ts deleted file mode 100644 index 79f2b30a..00000000 --- a/scripts/clipboardOwnershipX11.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource, sliceBetween } from './sourceTree.js'; - -// #729: on Debian XFCE, everything copied out of Markpad reached an empty -// clipboard, while pasting into it worked. X11 has no clipboard store — the -// copying process owns the CLIPBOARD selection and serves the bytes on -// request — and arboard tears that ownership down when the last `Clipboard` -// handle drops, which was the moment `clipboard_write_text` returned. -// -// A source-shape assertion because the proposition cannot be run: the failure -// needs an X server and a desktop without a clipboard manager, and CI has -// neither. It names the helper on purpose; nothing but the call connects the -// two, and the function exists for no other reason. -test('the copy command keeps X11 clipboard ownership past its own return', () => { - const body = sliceBetween( - readSource('src-tauri/src/commands.rs'), - 'pub fn clipboard_write_text', - '\n}', - ); - assert.match(body, /retain_clipboard_ownership\(\)/); -}); - -test('the retained handle is never dropped', () => { - // `retain_clipboard_ownership` holding a `Clipboard` in a local would be a - // no-op: the point is that the handle outlives the call, so the invisible - // window arboard serves selection requests from is never destroyed. - const helper = sliceBetween( - readSource('src-tauri/src/commands.rs'), - 'fn retain_clipboard_ownership', - '\n}', - ); - assert.match(helper, /std::mem::forget\(/); -}); diff --git a/scripts/codeFence.test.ts b/scripts/codeFence.test.ts deleted file mode 100644 index e4f56460..00000000 --- a/scripts/codeFence.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { isInFencedCode } from '../src/lib/utils/codeFence.js'; - -/** Every line of `text`, 1-based, answered in order. */ -function inside(text: string): boolean[] { - const lines = text.split('\n'); - const doc = { getLineContent: (line: number) => lines[line - 1] }; - return lines.map((_, index) => isInFencedCode(doc, index + 1)); -} - -test('the lines after an opening fence are inside it, up to and including the closing fence', () => { - assert.deepEqual(inside('a\n```\n1. x\n```\nb'), [false, false, true, true, false]); - assert.deepEqual(inside('~~~ts\n|a|\n~~~\n|a|'), [false, true, true, false]); -}); - -test('only the same character, at least as long, with nothing after it, closes', () => { - assert.deepEqual(inside('````\n```\nx\n````\ny'), [false, true, true, true, false]); - assert.deepEqual(inside('```\n~~~\nx\n```\ny'), [false, true, true, true, false]); - assert.deepEqual(inside('```\n``` ts\nx\n``` \ny'), [false, true, true, true, false]); -}); - -test('an unclosed fence runs to the end of the document', () => { - assert.deepEqual(inside('```\na\nb'), [false, true, true]); -}); - -test('a backtick run with a backtick after it is inline code, not a fence', () => { - assert.deepEqual(inside('```a```\n1. x'), [false, false]); - // A tilde fence's info string may say anything. - assert.deepEqual(inside('~~~ a`b\nx\n~~~'), [false, true, true]); -}); - -test('a fence nested in a list item or a quote counts; two backticks do not', () => { - assert.deepEqual(inside('1. a\n ```\n 2. b\n ```\n3. c'), [false, false, true, true, false]); - assert.deepEqual(inside('> ```\n> - x\n> ```\n- y'), [false, true, true, false]); - assert.deepEqual(inside('``\n- x'), [false, false]); -}); - -test('a fence opened on the list marker line counts, and so does its closing line', () => { - assert.deepEqual(inside('- item\n- ```js\n code\n ```\n- next\n - child'), [false, false, true, true, false, false]); - assert.deepEqual(inside('1. ```bash\n ls\n ```\n2. b'), [false, true, true, false]); - assert.deepEqual(inside('* [ ] ```\n x\n ```\n+ y'), [false, true, true, false]); - // Containers nest: a quote holding a list, a list holding a list. - assert.deepEqual(inside('> - ```\n> x\n> ```\n- y'), [false, true, true, false]); - assert.deepEqual(inside('> 1. - ```\n> x\n> ```\n- y'), [false, true, true, false]); -}); - -test('a list item with inline code spans is not a fence', () => { - assert.deepEqual(inside('- use ```x``` here\n- y'), [false, false]); - assert.deepEqual(inside('- ```x```\n- y'), [false, false]); -}); - -test('inside a fence a marker line is code, never the close', () => { - // A Markdown sample showing a fence on a list item, and a diff that deletes - // one: both lines are code, and taking them for the close inverted the rest. - assert.deepEqual(inside('```md\n- ```\n x\n```\n- item\n- item2'), [false, true, true, true, false, false]); - assert.deepEqual(inside('```diff\n- ```\n+ ```ts\n```\n- item'), [false, true, true, true, false]); -}); - -test('a task box opens a fence only after a list marker', () => { - assert.deepEqual(inside('[x] ```\na\nb'), [false, false, false]); -}); diff --git a/scripts/coldStartDoesNotStealFocus.test.ts b/scripts/coldStartDoesNotStealFocus.test.ts deleted file mode 100644 index 787bdb51..00000000 --- a/scripts/coldStartDoesNotStealFocus.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readRustBackend } from './sourceTree.js'; - -const backend = readRustBackend(); - -function showWindowSource(): string { - const match = /pub async fn show_window\([\s\S]*?\n\}/.exec(backend); - assert.ok(match, 'show_window is gone: this file no longer states anything'); - return match[0]; -} - -test('the launch window is shown without being activated on a cold start', () => { - const showWindow = showWindowSource(); - - // The defect (#702): the window is built hidden and revealed once the - // frontend mounts, seconds after launch, and the reveal called `set_focus` - // unconditionally — pulling the user out of whatever they had switched to. - // The label and the one-shot flag together are what make it the FIRST show - // of the LAUNCH window that stays quiet. - assert.match(showWindow, /window\.label\(\)\s*==\s*LAUNCH_LABEL/); - assert.match(showWindow, /MAIN_WINDOW_SHOWN\.swap\(true,/); - - // Not vacuous: the other callers — a detached tab window, the close walk - // needing its dialog visible — still activate, so this cannot pass by - // `set_focus` having been deleted outright. - assert.match(showWindow, /set_focus\(\)/); - - // And the quiet path returns before reaching it. - const guard = showWindow.indexOf('return;'); - const focus = showWindow.indexOf('set_focus()'); - assert.ok(guard > 0 && guard < focus, 'the cold-start path falls through to set_focus'); -}); - -test('no window builder opts out of focus', () => { - // `.focused(false)` on a window built hidden leaves WebView2 on Windows with - // no drop target, so every file dragged onto the window is refused (#768, - // tauri-apps/wry#1639). Comments are stripped first: the builder explains - // why the call is absent by naming it. - const code = backend.replace(/\/\/.*$/gm, ''); - const found = code.match(/\.focused\(false\)/g)?.length ?? 0; - assert.equal(found, 0, `found ${found} .focused(false) in the Rust backend`); -}); diff --git a/scripts/copyLabelFlash.spec.ts b/scripts/copyLabelFlash.spec.ts deleted file mode 100644 index b253125c..00000000 --- a/scripts/copyLabelFlash.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Clicking a code block's Copy label twice within 1.5 s left it reading - * "Copied!" for good: the second click saved "Copied!" as the label to restore. - */ - -import { afterEach, expect, test, vi } from 'vitest'; - -import { flashCopiedLabel } from '../src/lib/utils/richContent.js'; - -afterEach(() => { - vi.useRealTimers(); -}); - -test('a second copy inside the 1.5 s still restores the language name', () => { - vi.useFakeTimers(); - const label = document.createElement('button'); - label.textContent = 'typescript'; - - flashCopiedLabel(label); - vi.advanceTimersByTime(500); - flashCopiedLabel(label); - expect(label.innerHTML).toBe('Copied!'); - - vi.advanceTimersByTime(1500); - expect(label.innerHTML).toBe('typescript'); - expect(label.classList.contains('copied')).toBe(false); - - // And the label still works after that. - flashCopiedLabel(label); - expect(label.innerHTML).toBe('Copied!'); - vi.advanceTimersByTime(1500); - expect(label.innerHTML).toBe('typescript'); -}); diff --git a/scripts/copyReferenceStyle.test.ts b/scripts/copyReferenceStyle.test.ts deleted file mode 100644 index 55f9bce1..00000000 --- a/scripts/copyReferenceStyle.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * "Copy Reference" writes the spelling the document is already written in. - * - * Markpad understands two ways to link a heading — `[[note#Setup]]`, which it - * rewrites before the parse, and `[Setup](note.md#setup)`, which every reader - * resolves — and this menu always produced the first. Which one is right - * depends on the person, not on the app: a vault full of `[[…]]` wants - * another one; a document written for GitHub wants a link that survives - * leaving Markpad. - * - * The document is the evidence, because a person's habit is already written - * down in the file they are working in. The inference has a floor: says - * nothing, or says both, and the answer is what the menu always produced. So - * it can be better than the old behaviour and never worse — which is the only - * reason inferring is defensible here at all. - * - * Known and accepted: a reference is usually pasted into a DIFFERENT document, - * so following this one is a guess about that one. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -import { - DEFAULT_REFERENCE_STYLE, - headingReference, - preferredReferenceStyle, -} from '../src/lib/utils/headingReference.js'; - -const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - -/* ------------------------------------------------------ reading the document */ - -test('a document that links one way gets another of the same', () => { - assert.equal( - preferredReferenceStyle('See [[Setup#Install]] for details.\n'), - 'wikilink', - ); - assert.equal( - preferredReferenceStyle('See [Install](setup.md#install) for details.\n'), - 'inline', - ); - // A same-document anchor counts as well — it is the same spelling. - assert.equal(preferredReferenceStyle('Back to [the top](#introduction).\n'), 'inline'); - // And a bare note link, which the renderer leaves literal, still says what - // the writer's habit is. - assert.equal(preferredReferenceStyle('See [[Setup]].\n'), 'wikilink'); -}); - -test('silence and disagreement both fall back to what the menu always did', () => { - assert.equal(DEFAULT_REFERENCE_STYLE, 'wikilink'); - assert.equal(preferredReferenceStyle(''), DEFAULT_REFERENCE_STYLE); - assert.equal(preferredReferenceStyle('# A document\n\nWith no links at all.\n'), DEFAULT_REFERENCE_STYLE); - assert.equal( - preferredReferenceStyle('Both [[Setup#Install]] and [Install](setup.md#install).\n'), - DEFAULT_REFERENCE_STYLE, - 'a document using both is not evidence of either', - ); - // An ordinary link with no anchor is not evidence: every document has those. - assert.equal(preferredReferenceStyle('See [the site](https://example.com).\n'), DEFAULT_REFERENCE_STYLE); -}); - -test('a document ABOUT markdown does not vote with its examples', () => { - // `samples/stress-test.md` and this project's own notes are full of - // both spellings inside fences. Counting those would make the syntax - // documentation the loudest voice in every vault. - const teaching = [ - '# Linking', - '', - '```markdown', - '[[Setup#Install]]', - '```', - '', - 'Prose with [an anchor](setup.md#install) in it.', - '', - ].join('\n'); - - assert.equal(preferredReferenceStyle(teaching), 'inline', 'the fenced example must not count'); - - const tildeFenced = '~~~md\n[[Setup#Install]]\n~~~\n\nSee [x](a.md#b).\n'; - assert.equal(preferredReferenceStyle(tildeFenced), 'inline', 'tilde fences too'); -}); - -/* ------------------------------------------------------------ writing it out */ - -test('each style is written the way its own resolver reads it', () => { - const heading = { text: 'Setup', slug: 'setup', fileName: 'notes.md' as string | null }; - - // The wikilink drops the extension — `process_wikilinks` appends `.md` - // itself — and names the heading by its TEXT. - assert.equal(headingReference({ ...heading, style: 'wikilink' }), '[[notes#Setup]]'); - // The CommonMark form keeps it, because that is how the frontend claims a - // link for local navigation, and names the heading by its rendered id. - assert.equal(headingReference({ ...heading, style: 'inline' }), '[Setup](notes.md#setup)'); -}); - -test('the wikilink drops only an extension the renderer would add back', () => { - // `process_wikilinks` appends `.md` to a target with no extension, so - // `[[note#Setup]]` reaches `note.md` and never `note.markdown`. - const reference = (fileName: string) => - headingReference({ text: 'Setup', slug: 'setup', fileName, style: 'wikilink' }); - assert.equal(reference('note.markdown'), '[[note.markdown#Setup]]'); - assert.equal(reference('notes.txt'), '[[notes.txt#Setup]]'); - // Left as `v1.2`, the target would read as having a `.2` extension. - assert.equal(reference('v1.2.md'), '[[v1.2.md#Setup]]'); - assert.equal(reference('v1.2 spec.md'), '[[v1.2 spec#Setup]]'); -}); - -test('an unsaved document has no file to name', () => { - const heading = { text: 'Setup', slug: 'setup', fileName: null }; - assert.equal(headingReference({ ...heading, style: 'wikilink' }), '#Setup'); - assert.equal(headingReference({ ...heading, style: 'inline' }), '[Setup](#setup)'); -}); - -test('the CommonMark form survives a filename with a space and a bracketed heading', () => { - // `[Setup](my notes.md#setup)` stops at the space; `[Notes [draft](…)` ends - // its label early. Neither can happen in the wikilink form, which is part - // of why this only applies to one of them. - assert.equal( - headingReference({ text: 'Setup', slug: 'setup', fileName: 'my notes.md', style: 'inline' }), - '[Setup]()', - ); - assert.equal( - headingReference({ text: 'Notes [draft', slug: 'notes-draft', fileName: null, style: 'inline' }), - '[Notes \\[draft](#notes-draft)', - ); -}); - -test('a repeated heading is addressable, because the slug comes from the render', () => { - // comrak numbers them; recomputing the id here would drift from that. - assert.equal( - headingReference({ text: 'Objectives', slug: 'objectives-1', fileName: 'n.md', style: 'inline' }), - '[Objectives](n.md#objectives-1)', - ); -}); - -/* ---------------------------------------------------------------- the wiring */ - -test('all three entries go through one helper, which reads the buffer', () => { - // The preview's heading menu, the outline's right-click, and the outline's - // context menu. All three built the string inline before, which is how they - // came to disagree about the filename in the first place. - assert.equal((viewerSource.match(/copyHeadingReference\(/g) ?? []).length, 4, 'one helper, three callers'); - assert.match(viewerSource, /style: preferredReferenceStyle\(tab\?\.rawContent \?\? ''\)/); - // The id is taken off the rendered element rather than recomputed. - assert.match(viewerSource, /heading\.id \|\| heading\.querySelector\('a\.anchor'\)\?\.id/); -}); diff --git a/scripts/diagramCache.test.ts b/scripts/diagramCache.test.ts deleted file mode 100644 index 455113ca..00000000 --- a/scripts/diagramCache.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * The preview re-renders the whole article on a 16ms debounce — in split mode, - * and in edit mode with the TOC open, that is about once per keystroke — and - * `{@html sanitizedHtml}` swaps the entire block, which puts every - * `
` back. So every diagram was redrawn from
- * scratch on every keystroke. Measured in a headless browser on a document with
- * 44 diagrams and 132 code blocks, one pass cost 2.2–2.5s, of which
- * `mermaid.render` was all but ~30ms.
- *
- * These tests pin the two halves of the fix that can go wrong quietly:
- *
- *   - The *cache*: what must miss (a changed character, a changed theme) and
- *     what the bounds are. A memo that never misses is a correctness bug and a
- *     memo that never evicts is a leak; neither shows up as a failure on screen.
- *   - The *re-identification*: Mermaid bakes the id it is handed into the
- *     diagram's `` +
-		`` +
-		`` +
-		extra +
-		`
${source.trim()}
` + - `` - ); -} - -function libraries(record: Recorder, extraFor: (source: string, id: string) => string = () => '') { - let theme = 'neutral'; - return { - hljs: { getLanguage: () => null }, - // The renderer refuses to run without these; the diagrams are what is - // under test, so they do nothing. - katex: { renderToString: () => '' }, - renderMathInElement() {}, - mermaid: { - initialize(config: { theme: string }) { - theme = config.theme; - record.themes.push(config.theme); - }, - async render(id: string, source: string) { - record.renders.push({ id, source, theme }); - return { svg: svgFor(id, source, theme, extraFor(source, id)) }; - }, - }, - }; -} - -function articleWith(...sources: string[]): string { - return sources - .map((source) => `
${source}
`) - .join('\n'); -} - -function newRecorder(): Recorder { - return { renders: [], themes: [] }; -} - -let idCounter = 0; -/** Unique per call, like the real one, and readable in a failure message. */ -function ids(): (index: number) => string { - const run = idCounter++; - return (index: number) => `mermaid-${run}-${index}`; -} - -async function render( - html: string, - options: { record: Recorder; theme?: string; extraFor?: (source: string, id: string) => string }, -): Promise { - const root = (globalThis as any).document.createElement('div'); - root.innerHTML = html; - await renderRichContent({ - roots: [root], - libraries: libraries(options.record, options.extraFor) as any, - mermaidTheme: options.theme ?? 'neutral', - idFactory: ids(), - }); - return root; -} - -function idsIn(html: string): string[] { - return [...html.matchAll(/\sid="([^"]*)"/g)].map((match) => match[1]); -} - -// ------------------------------------------------------------------- tests - -test('a second pass over the same document draws nothing again', async () => { - resetDiagramCache(); - const article = articleWith('graph TD; A-->B;', 'graph LR; C-->D;'); - - const first = newRecorder(); - const before = await render(article, { record: first }); - assert.equal(first.renders.length, 2); - - const second = newRecorder(); - const after = await render(article, { record: second }); - assert.deepEqual(second.renders, [], 'the second pass must not call mermaid.render at all'); - - // Same drawing, not just the same count: identifiers aside, the reused SVG - // is what Mermaid produced. - const normalise = (html: string) => - html - .replace(/\sid="[^"]*"/g, ' id="X"') - .replace(/url\(#[^)]*\)/g, 'url(#X)') - .replace(/#mermaid-[\w-]*/g, '#X'); - assert.equal(normalise(after.innerHTML), normalise(before.innerHTML)); -}); - -test('changing one character of a diagram redraws it', async () => { - resetDiagramCache(); - const warm = newRecorder(); - await render(articleWith('graph TD; A-->B;'), { record: warm }); - - const typed = newRecorder(); - await render(articleWith('graph TD; A-->Bc;'), { record: typed }); - assert.deepEqual( - typed.renders.map((call) => call.source), - ['graph TD; A-->Bc;'], - ); -}); - -test('changing the theme redraws every diagram, and the old theme is still warm', async () => { - resetDiagramCache(); - const source = 'graph TD; Theme-->Switch;'; - - const light = newRecorder(); - const lightRoot = await render(articleWith(source), { record: light, theme: 'neutral' }); - assert.equal(light.renders.length, 1); - assert.match(lightRoot.innerHTML, /fill:#eee/); - - const dark = newRecorder(); - const darkRoot = await render(articleWith(source), { record: dark, theme: 'dark' }); - assert.equal(dark.renders.length, 1, 'a theme switch must not reuse the other theme’s colours'); - assert.match(darkRoot.innerHTML, /fill:#333/); - - // Switching back is a hit: both themes live in the cache at once, so a user - // toggling appearance does not pay twice. - const back = newRecorder(); - const backRoot = await render(articleWith(source), { record: back, theme: 'neutral' }); - assert.deepEqual(back.renders, []); - assert.match(backRoot.innerHTML, /fill:#eee/); -}); - -test('two copies of one diagram never share an id', async () => { - resetDiagramCache(); - const source = 'graph TD; Same-->Twice;'; - const record = newRecorder(); - - // Even on the very first pass the second copy is a cache hit, so this is the - // case the cache creates and the case it has to get right. - const root = await render(articleWith(source, source), { record }); - assert.equal(record.renders.length, 1); - - const all = idsIn(root.innerHTML); - assert.equal(all.length, 4, 'two diagrams, each with an svg id and a marker id'); - assert.equal(new Set(all).size, 4, `duplicate ids in the document: ${all.join(', ')}`); - - // The re-identification has to be complete, not just applied to the ``: - // each copy's style block and marker reference must point at its own id. - const [firstSvg, secondSvg] = root.innerHTML.split('#${svgId} .node rect`), `style block not re-pointed for ${svgId}`); - assert.ok(svg.includes(`url(#${svgId}_flowchart-pointEnd)`), `marker not re-pointed for ${svgId}`); - } -}); - -test('a reused diagram still carries its source for the PDF re-render', async () => { - resetDiagramCache(); - const source = 'graph TD; Print-->Me;'; - await render(articleWith(source), { record: newRecorder() }); - - const reused = newRecorder(); - const root = await render(articleWith(source), { record: reused }); - assert.deepEqual(reused.renders, []); - // `mermaidPrint` finds diagrams by this attribute; a cached container without - // it would silently print dark diagrams on paper. - assert.match(root.innerHTML, /class="mermaid-diagram" data-mermaid-source="graph TD; Print-->Me;"/); -}); - -test('ids Mermaid does not namespace are namespaced by the cache', async () => { - resetDiagramCache(); - // A sequence diagram's actor ids come from a module counter, not from the - // svg id — two copies of one diagram would otherwise both say `actor7`. - const source = 'sequenceDiagram; A->>B: hi'; - const record = newRecorder(); - const root = await render(articleWith(source, source), { - record, - extraFor: () => '', - }); - - assert.equal(record.renders.length, 1); - const all = idsIn(root.innerHTML); - assert.equal(new Set(all).size, all.length, `duplicate ids in the document: ${all.join(', ')}`); - // The drawn copy keeps exactly what Mermaid emitted — the cache changes - // nothing about a miss. The reused copy is the one that has to be moved out - // of the way, and it lands under its own svg id. - assert.equal(all.filter((id) => id === 'actor7').length, 1); - assert.equal(all.filter((id) => id !== 'actor7' && id.endsWith('-actor7')).length, 1); -}); - -test('a diagram whose stray id is referenced is not cached at all', async () => { - resetDiagramCache(); - // The cache renames stray declarations but does not rewrite references to - // them. If a future Mermaid points at one, the honest answer is to redraw. - const source = 'graph TD; Referenced-->Stray;'; - const record = newRecorder(); - await render(articleWith(source), { - record, - extraFor: () => '', - }); - - const again = newRecorder(); - await render(articleWith(source), { - record: again, - extraFor: () => '', - }); - assert.equal(again.renders.length, 1, 'an un-templatable diagram must be redrawn, not reused'); -}); - -test('the cache is bounded by entry count', async () => { - resetDiagramCache(); - // 128 entries is the cap. Fill past it and the first one must be gone. - const first = 'graph TD; N0-->X;'; - await render(articleWith(first), { record: newRecorder() }); - for (let i = 1; i <= 128; i++) { - await render(articleWith(`graph TD; N${i}-->X;`), { record: newRecorder() }); - } - - const evicted = newRecorder(); - await render(articleWith(first), { record: evicted }); - assert.equal(evicted.renders.length, 1, 'the oldest entry must have been evicted'); - - // …and the most recent one is still there. - const kept = newRecorder(); - await render(articleWith('graph TD; N128-->X;'), { record: kept }); - assert.deepEqual(kept.renders, []); -}); - -test('the cache is bounded by size, not only by count', async () => { - resetDiagramCache(); - // Eight ~400k-character diagrams are only eight entries but 3.2M characters, - // which is over the budget: a handful of enormous diagrams must not sit in - // memory forever just because they are few. - const bulk = (n: number) => `graph TD; Big${n}["${'x'.repeat(400_000)}"];`; - const first = bulk(0); - await render(articleWith(first), { record: newRecorder() }); - for (let i = 1; i < 8; i++) await render(articleWith(bulk(i)), { record: newRecorder() }); - - const evicted = newRecorder(); - await render(articleWith(first), { record: evicted }); - assert.equal(evicted.renders.length, 1); -}); - -test('a diagram is redrawn rather than reused when its own text spells out the slot', async () => { - resetDiagramCache(); - // The template marks where the id goes with a literal token. A diagram whose - // text contains that token cannot be templated without rewriting the user's - // own text, so it is left uncached. - const source = 'graph TD; A["{{markpad-diagram-id}}"];'; - await render(articleWith(source), { record: newRecorder() }); - const again = newRecorder(); - await render(articleWith(source), { record: again }); - assert.equal(again.renders.length, 1); -}); - -// ------------------------------------------------- the templating, on its own - -test('templating refuses anything it cannot re-identify', () => { - // The id it was told about is not the one in the markup: nothing to lift out. - assert.equal(templateDiagramSvg('', 'mine'), null); - // A referenced stray id. - assert.equal( - templateDiagramSvg('', 'mine'), - null, - ); - - const template = templateDiagramSvg('', 'mine'); - assert.ok(template); - assert.equal( - fillDiagramTemplate(template as string, 'fresh'), - '', - ); -}); diff --git a/scripts/diagramThemeRefresh.spec.ts b/scripts/diagramThemeRefresh.spec.ts deleted file mode 100644 index 18e82083..00000000 --- a/scripts/diagramThemeRefresh.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Switching theme has to re-draw the diagrams, and until this file existed it - * did not — in any mode, for any theme. - * - * Mermaid bakes fills, strokes and label colours into the SVG as attributes, so - * a stylesheet cannot retarget them; `mermaidPrint.ts` says why at length, and - * re-rendering is the answer it already reaches for on the way to a PDF. The - * preview's own theme change was supposed to do the same thing through - * `renderRichContent`, and could not: that function finds diagrams by - * `pre code`, and a diagram that has been drawn once has no `
` any more —
- * it was replaced by the `.mermaid-diagram` container holding the SVG.
- *
- * That was invisible while the preview was `{@html sanitizedHtml}`, because
- * every render put the whole document back as source. the block patch (#632)
- * keeps enriched nodes across renders instead, and from then on a theme change
- * reached no diagram at all. The `
` is gone, `patch.inserted` is empty on
- * a re-render of the same document, and typing does not help either: the
- * diagram's markup is unchanged, so the patch keeps the node.
- *
- * The libraries are stood in for at the same boundary `exportRichContent` and
- * `richContentIdempotence` use. What is under test is Markpad's bookkeeping:
- * which containers it decides are stale, and what it does to them.
- */
-
-import { describe, expect, test } from 'vitest';
-
-import { patchPreview } from '../src/lib/utils/previewPatch.js';
-import { readDiagramSource } from '../src/lib/utils/mermaidPrint.js';
-import { renderRichContent, type RichContentLibraries } from '../src/lib/utils/richContent.js';
-import { resetDiagramCache } from '../src/lib/utils/diagramCache.js';
-
-const SOURCE = 'graph TD;A-->B;';
-
-/** Records every draw so "did it re-render" is a count, not an inference. */
-function libraries(draws: string[]): RichContentLibraries {
-	let theme = 'unset';
-	return {
-		hljs: { getLanguage: () => undefined, highlight: () => ({ value: '' }) },
-		katex: { renderToString: (source: string) => source },
-		renderMathInElement: () => {},
-		mermaid: {
-			initialize: (config: { theme: string }) => {
-				theme = config.theme;
-			},
-			render: async (id: string, source: string) => {
-				draws.push(`${theme}:${source}`);
-				return { svg: `` };
-			},
-		},
-	} as any;
-}
-
-function article(html: string): HTMLElement {
-	const element = document.createElement('div');
-	element.innerHTML = html;
-	document.body.replaceChildren(element);
-	return element;
-}
-
-async function render(root: HTMLElement, mermaidTheme: string, draws: string[], seed = 0) {
-	await renderRichContent({
-		roots: [root],
-		libraries: libraries(draws),
-		mermaidTheme,
-		idFactory: (index) => `d${seed}-${index}`,
-	});
-}
-
-function drawnFor(root: ParentNode): string | null {
-	return root.querySelector('.mermaid-diagram svg')?.getAttribute('data-drawn-for') ?? null;
-}
-
-const PRE = `
${SOURCE}
`; - -describe('a theme change', () => { - test('re-draws a diagram that is already on screen', async () => { - resetDiagramCache(); - const draws: string[] = []; - const root = article(PRE); - - await render(root, 'dark', draws); - expect(drawnFor(root)).toBe('dark'); - - // The whole-article pass a theme change makes. There is no `
` left.
-		expect(root.querySelectorAll('pre code').length).toBe(0);
-		await render(root, 'neutral', draws, 1);
-
-		expect(drawnFor(root)).toBe('neutral');
-		expect(draws).toEqual([`dark:${SOURCE}`, `neutral:${SOURCE}`]);
-	});
-
-	test('leaves the source on the container, so the PDF path still works', async () => {
-		resetDiagramCache();
-		const root = article(PRE);
-		await render(root, 'dark', []);
-		await render(root, 'neutral', [], 1);
-
-		expect(readDiagramSource(root.querySelector('.mermaid-diagram')!)).toBe(SOURCE);
-	});
-});
-
-describe('a pass that is not a theme change', () => {
-	test('does not re-draw a diagram already in the theme being asked for', async () => {
-		resetDiagramCache();
-		const draws: string[] = [];
-		const root = article(PRE);
-
-		await render(root, 'dark', draws);
-		// `syncPreviewForPrint` and a re-activated tab both re-run this over the
-		// whole host without the theme having moved.
-		await render(root, 'dark', draws, 1);
-		await render(root, 'dark', draws, 2);
-
-		expect(draws).toEqual([`dark:${SOURCE}`]);
-	});
-
-	test('does not reach a diagram outside the roots it was given', async () => {
-		resetDiagramCache();
-		const draws: string[] = [];
-		const host = article(`${PRE}

text

`); - await render(host, 'dark', draws); - - // The keystroke path: the patch replaces the paragraph and hands only - // that block to the enrichment. The diagram is not in it, and a theme it - // no longer matches must not drag it into the pass. - const patch = patchPreview(document.createElement('div'), '

x

', '', new Set()); - await renderRichContent({ - roots: patch.inserted, - libraries: libraries(draws), - mermaidTheme: 'neutral', - idFactory: (index) => `k${index}`, - }); - - expect(draws).toEqual([`dark:${SOURCE}`]); - expect(drawnFor(host)).toBe('dark'); - }); -}); - -/** - * Mermaid's config is one global: `initialize` writes it at once, and `render` - * reads it only when its turn in Mermaid's own queue comes. The stand-in keeps - * that shape: one global theme, read after the draw has yielded. - */ -function queuedLibraries(): RichContentLibraries { - let theme = 'unset'; - return { - hljs: { getLanguage: () => undefined, highlight: () => ({ value: '' }) }, - katex: { renderToString: (source: string) => source }, - renderMathInElement: () => {}, - mermaid: { - initialize: (config: { theme: string }) => { - theme = config.theme; - }, - render: async (id: string) => { - await new Promise((resolve) => setTimeout(resolve, 0)); - return { svg: `` }; - }, - }, - } as any; -} - -test('two passes in different themes each draw in their own', async () => { - resetDiagramCache(); - const mermaid = queuedLibraries(); - // A preview pass and a PDF export's pass over another copy, overlapping. - const preview = article(`${PRE}
graph LR;C-->D;
`); - const print = document.createElement('div'); - print.innerHTML = PRE; - - await Promise.all([ - renderRichContent({ roots: [preview], libraries: mermaid, mermaidTheme: 'dark', idFactory: (i) => `p${i}` }), - renderRichContent({ roots: [print], libraries: mermaid, mermaidTheme: 'neutral', idFactory: (i) => `x${i}` }), - ]); - - const themes = (root: ParentNode) => - [...root.querySelectorAll('.mermaid-diagram svg')].map((svg) => svg.getAttribute('data-drawn-for')); - expect(themes(preview)).toEqual(['dark', 'dark']); - expect(themes(print)).toEqual(['neutral']); -}); - -/** - * The wiring, which the tests above cannot reach: `renderRichContent` now knows - * how to re-draw a stale diagram, but the theme effect still has to call it, in - * every mode and once per theme change. - * - * A source assertion, because the caller is a Svelte effect that cannot be - * imported. It pins today's spelling of an internal call, so a rename breaks it - * without breaking anything real; what it is here for is the guard, which was - * `markdownBody && !isEditing`. That made the whole effect — the `dataset` - * writes, `monaco.editor.setTheme`, and for a `vscode:` theme an - * `invoke('read_vscode_theme')` and a re-parse — re-run on every mode toggle - * and on every tab switch that changed the mode, with nothing about the theme - * having moved. And it excluded from the re-colour every tab in edit mode, - * including a split tab entered from edit mode, whose preview is on screen. - */ -test('the theme effect re-colours in every mode, and does not re-run on a mode change', async () => { - const { readSource } = await import('./sourceTree.js'); - const viewer: string = readSource('src/lib/MarkdownViewer.svelte'); - - const effect = viewer.match( - /\t\$effect\(\(\) => \{\n\t\t\/\/ Persistence and cross-window sync[\s\S]*?\n\t\}\);/, - )?.[0]; - expect(effect, 'the theme effect must still be recognisable').toBeTruthy(); - - // The mode is not a reason to re-apply a theme, and this is the only read - // that ever made it one. - expect(effect).not.toMatch(/isEditing/); - expect(effect).toMatch( - /const recolourDiagrams = \(\) => untrack\(\(\) => \{ if \(markdownBody\) renderRichContent\(\); \}\);/, - ); - - // Once per branch: the `vscode:` branch does not know its own appearance - // until `parseAndApplyVscodeTheme` has published `dataset.themeType`, which - // is what `currentMermaidTheme` reads. - expect(effect!.match(/recolourDiagrams\(\);/g)).toHaveLength(2); - expect(effect).toMatch(/loadVscodeTheme\([\s\S]*?recolourDiagrams\(\);/); -}); diff --git a/scripts/documentEncodingRoundTrip.spec.ts b/scripts/documentEncodingRoundTrip.spec.ts deleted file mode 100644 index 1b7ff290..00000000 --- a/scripts/documentEncodingRoundTrip.spec.ts +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { functionSource, readDictionarySource, readRustBackend, readSource, sliceBetween } from './sourceTree.js'; - -/* - * #372: a document in a legacy encoding (GBK, Big5, Shift-JIS, CP-1252 …) is - * detected and decoded on open, and a save writes it back as the SAME - * encoding — the behaviour Notepad, VS Code and Typora all have, and the one - * an app that calls itself "the Notepad equivalent for Markdown" is measured - * against. - * - * The decoding and encoding themselves are Rust and are tested there - * (`saving_an_unedited_legacy_document_reproduces_its_canonical_bytes` is the - * one that matters). What has to hold on this side is the wiring, because it - * is the wiring that decides which bytes reach the file: - * - * read → tab.encoding → save - * - * A break anywhere in that chain does not fail loudly. It writes the user's - * GBK document back as UTF-8, silently, on an auto-save 1.5s after the first - * keystroke — no error, no toast, and every other tool that opens the file - * afterwards sees mojibake. So the chain is driven here end to end rather - * than read for. - */ - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin, so the store and the session run under real reactivity, and jsdom -// supplies `window` and `localStorage`. Only the Tauri backend is stubbed. - -/** What the backend reports for the next read. Set per test. */ -let fileEncoding = 'UTF-8'; -let fileLossy = false; -const BODY = '# 中文标题'; - -/** Every `save_file_content` the session issued, in order. */ -let writes: Array<{ path: string; content: string; encoding: string }> = []; - -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (command: string, args: Record) => { - const cmd = command.replace(/^plugin:[^|]*\|/, ''); - if (cmd === 'get_os_type') return Promise.resolve('macos'); - if (cmd === 'canonicalize_path') return Promise.resolve(args.path); - if (cmd === 'open_markdown_preview') { - return Promise.resolve([BODY, true, fileLossy, fileEncoding]); - } - if (cmd === 'read_file_content_checked') return Promise.resolve([BODY, fileLossy, fileEncoding]); - if (cmd === 'save_file_content') { - writes.push(args as unknown as { path: string; content: string; encoding: string }); - return Promise.resolve(null); - } - return Promise.resolve(null); - }, -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); -const { buildTransferredTab, snapshotTab, validateTransferPayload } = await import( - '../src/lib/utils/tabTransfer.js' -); - -function makeSession() { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: () => {}, - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: () => {}, - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - }); -} - -/** A tab opened from a file the backend reports as `encoding`. */ -async function openedAs(encoding: string, lossy = false) { - tabManager.closeAll(); - writes = []; - fileEncoding = encoding; - fileLossy = lossy; - const session = makeSession(); - await session.loadMarkdown('/notes/legacy.md'); - return { session, tab: tabManager.activeTab! }; -} - -test('opening a legacy document records the encoding it was read in', async () => { - const { tab } = await openedAs('GBK'); - assert.equal(tab.encoding, 'GBK'); - assert.equal(tab.hasReplacementChars, false, 'a detected encoding decodes; it does not substitute'); -}); - -test('saving that document writes it back as itself, not as UTF-8', async () => { - // The whole issue in one assertion. Under the old behaviour this write - // carried the buffer and nothing else, so the file became UTF-8 — or, - // before the buffer was decoded at all, U+FFFD. - const { session, tab } = await openedAs('GBK'); - - tabManager.updateTabRawContent(tab.id, `${BODY}\n\nedited`); - assert.equal(await session.saveContent(tab.id), true); - - assert.deepEqual(writes.map((write) => write.encoding), ['GBK']); - assert.equal(writes[0].path, '/notes/legacy.md'); -}); - -test('a UTF-8 document is still written as UTF-8', async () => { - // The fence. Everything above passes for a build that writes some - // arbitrary encoding to every file it touches. - const { session, tab } = await openedAs('UTF-8'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8']); -}); - -test('a byte order mark is a property of the file, so it is carried too', async () => { - // Windows-authored Markdown, and the case a plain "is it UTF-8?" test - // misses: the text decodes perfectly, and dropping the BOM on save still - // changes the file for every tool that reads it. - const { session, tab } = await openedAs('UTF-8-BOM'); - assert.equal(tab.encoding, 'UTF-8-BOM'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8-BOM']); -}); - -test('a re-read repoints the save at what the file is NOW', async () => { - // The user converted the file to UTF-8 in another program. The tab must - // stop writing GBK, and it learns that from the read rather than from - // remembering how it was first opened. - const { session, tab } = await openedAs('GBK'); - - fileEncoding = 'UTF-8'; - await session.loadMarkdown('/notes/legacy.md'); - assert.equal(tabManager.activeTab!.encoding, 'UTF-8'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8']); -}); - -test('an untitled buffer has an encoding before anything has been read', async () => { - // Required, not optional, on the Tab — so a buffer that never came from a - // file still answers, and answers the only thing it could be. - tabManager.closeAll(); - tabManager.addNewTab(); - assert.equal(tabManager.activeTab!.encoding, 'UTF-8'); -}); - -test('a moved tab keeps its encoding across the window boundary', async () => { - // The buffer is a decode of a file in this encoding; the destination - // window auto-saves it. A payload that dropped the field would rewrite the - // user's GBK document as UTF-8 the moment it landed. - const { tab } = await openedAs('Shift_JIS'); - - const payload = JSON.stringify(snapshotTab(tab)); - const validated = validateTransferPayload(payload); - assert.notEqual(validated, null, 'precondition: the payload validates'); - assert.equal(buildTransferredTab(validated!, [], 'Untitled').encoding, 'Shift_JIS'); -}); - -test('a payload without an encoding is rejected rather than defaulted', async () => { - // Same rule as `hasReplacementChars`: a missing field must not be read as - // "UTF-8, go ahead", because that is a write to the user's file. - const { tab } = await openedAs('Big5'); - const { encoding, ...withoutEncoding } = snapshotTab(tab); - void encoding; - - assert.equal(validateTransferPayload(JSON.stringify(withoutEncoding)), null); -}); - -// ---------------------------------------------------------- the status bar -// -// Detection without an indicator is worse than no detection: a GBK document -// now opens, reads correctly and saves back as GBK, and the slot that is -// supposed to name the encoding was still rendering the literal `UTF-8` it -// had rendered since before Markpad could open the file at all. The one place -// the user could have caught a misdetection was asserting the opposite. -// -// This is the same defect #540 fixed one slot to the left, where the line -// ending was hardcoded `CRLF`. - -test('the status bar names the encoding the document was decoded from', () => { - const editor = readSource('src/lib/components/Editor.svelte'); - const statusBar = sliceBetween(editor, '
', '
\n{/if}'); - - assert.match(statusBar, /
\{encoding\}<\/div>/, 'the slot shows the state'); - assert.doesNotMatch(editor, /editor\.status\.utf8/, 'the hardcoded UTF-8 string is gone'); - - // Off the tab, not off Monaco: the encoding belongs to the file the buffer - // was decoded from, and the store is what carries it through a save. - assert.match( - editor, - /let encoding = \$derived\(tabManager\.activeTab\?\.encoding \?\? 'UTF-8'\)/, - 'and it is read from the tab rather than kept as a synced copy', - ); -}); - -test('no locale still translates a hardcoded UTF-8 label', () => { - // A dead key reads like a supported feature to whoever finds it next. - assert.doesNotMatch(readDictionarySource(), /utf8:/); -}); - -// ------------------------------------------- saying why a save was refused -// -// Refusing is only half of it. The user pasted an emoji into a GBK document -// and got "auto-save failed, unsaved changes still in memory" — true, and no -// help at all: it names neither the character nor the encoding, and the way -// out (Save As, which writes UTF-8) is not in it. -// -// Rust sends a code and this side owns the wording, so the reason a save was -// refused reaches the user in the language the rest of the app is in. The -// generic English `Failed to save file: ` stays for genuine I/O -// failures, which the user cannot act on beyond reading the OS. - -test('the unmappable-character refusal crosses as a code, not as English prose', () => { - const rust = readRustBackend(); - assert.match(rust, /const UNMAPPABLE_CODE: &str = "ENCODING_UNMAPPABLE";/); - assert.match(rust, /return Err\(UNMAPPABLE_CODE\.to_owned\(\)\)/, 'a marker, carrying nothing'); - - // The sentence it replaced must not come back: a translated toast and an - // English one saying the same thing is worse than either alone. - assert.doesNotMatch(rust, /cannot represent every character it now contains/); -}); - -test('every locale can say which encoding refused the save', () => { - const i18n = readDictionarySource(); - const locales = i18n.match(/lossySaveBlocked:/g) ?? []; - const translated = i18n.match(/encodingUnmappable:/g) ?? []; - - assert.ok(locales.length >= 5, 'precondition: the sibling refusal is translated'); - assert.equal(translated.length, locales.length, 'both refusals reach the same languages'); - - // The encoding is named, not implied — "some characters cannot be saved" - // leaves the user with nothing to search for. - for (const line of i18n.split('\n').filter((l) => l.includes('encodingUnmappable:'))) { - assert.match(line, /\{\{encoding\}\}/, `no encoding placeholder: ${line.trim().slice(0, 60)}`); - } -}); - -test('the code is turned into the translation, and only that code is', () => { - const session = readSource('src/lib/sessions/documentSession.svelte.ts'); - const describe = functionSource(session, 'describeSaveFailure'); - - assert.match(describe, /toast\.encodingUnmappable/); - assert.match(describe, /replace\('\{\{encoding\}\}', tab\.encoding\)/, - 'the encoding comes off the tab, not back out of the error'); - assert.match(describe, /return \{ message: 'Failed to save file', detail: error \}/, - 'an I/O failure still reads as one, OS message and all'); - - // `onError` renders `${message}: ${detail}`, so returning the raw error as - // the detail printed the code the user was never meant to see: - // …请用"另存为"写入一份 UTF-8 副本: ENCODING_UNMAPPABLE:GBK - // The translated branch sends the document's path instead, which is what - // `toast.lossySaveBlocked` has always done. - assert.match(describe, /detail: tab\.path,/, 'the marker never reaches the toast'); -}); diff --git a/scripts/documentLoadFailure.spec.ts b/scripts/documentLoadFailure.spec.ts deleted file mode 100644 index 62157145..00000000 --- a/scripts/documentLoadFailure.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -// A read can fail on a file that is perfectly fine a moment later: a watcher -// fires while another process is replacing the file, a network volume blinks, -// a sync client swaps the inode. Closing the tab on that error throws away the -// buffer the user could still have saved somewhere else, and dropping the -// recent-file entry throws away the way back to it. -// -// This was asserted by matching documentSession for `/tabManager\.closeTab/` -// and for the exact `options.onError('Error loading file', error);` call. The -// first is satisfied by any file that happens not to contain those characters -// — including one that closes the tab through a differently spelled route — -// and the second pins a string literal. `createDocumentSession` takes its -// collaborators as plain callbacks, and eleven other files in this suite -// already build one, so the failure can be run instead of described. - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin, so the store and the session run under real reactivity. Only -// the Tauri backend, which jsdom cannot provide, is stubbed. -let handleInvoke: (cmd: string, args: any) => unknown = (cmd) => { - throw new Error(`unexpected invoke: ${cmd}`); -}; -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (cmd: string, args: any) => Promise.resolve(handleInvoke(cmd, args)), -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); - -const errors: { message: string; error: unknown }[] = []; -const deletedRecents: string[] = []; - -function makeSession(overrides: Record = {}) { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: (path: string) => void deletedRecents.push(path), - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: (message: string, error: unknown) => void errors.push({ message, error }), - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - ...overrides, - }); -} - -/** Every read of `path` fails; anything else the load path asks for is absent too. */ -function readsFail() { - handleInvoke = () => { - throw new Error('No such file or directory (os error 2)'); - }; -} - -test('a read that fails keeps the open tab, its buffer and its recent entry', async () => { - tabManager.closeAll(); - errors.length = 0; - deletedRecents.length = 0; - - const session = makeSession(); - tabManager.addTab('/notes/a.md', 'the text the reader can still rescue'); - const before = tabManager.activeTab!; - readsFail(); - - await session.loadMarkdown('/notes/a.md'); - - assert.equal(tabManager.tabs.length, 1, 'the tab is not closed'); - assert.equal(tabManager.tabs[0].id, before.id, 'and it is the same tab, not a replacement'); - assert.equal( - tabManager.tabs[0].rawContent, - 'the text the reader can still rescue', - 'the buffer is not replaced by the failed read', - ); - assert.deepEqual(deletedRecents, [], 'the recent-file entry survives, so the file can be reopened'); -}); - -test('the failure is reported rather than swallowed', async () => { - tabManager.closeAll(); - errors.length = 0; - - const session = makeSession(); - tabManager.addTab('/notes/a.md', 'buffer'); - readsFail(); - - await session.loadMarkdown('/notes/a.md'); - - assert.equal(errors.length, 1, 'exactly one error reaches the caller'); - assert.equal(errors[0].message, 'Error loading file'); - assert.ok(errors[0].error instanceof Error, 'the cause is passed on, not flattened to a string'); -}); diff --git a/scripts/documents.checks.spec.ts b/scripts/documents.checks.spec.ts new file mode 100644 index 00000000..804a3a50 --- /dev/null +++ b/scripts/documents.checks.spec.ts @@ -0,0 +1,1071 @@ +import assert from 'node:assert/strict'; + +import ts from 'typescript'; +import { afterAll, beforeAll, describe, test } from 'vitest'; + +import { reviewDirtyTabs } from '../src/lib/sessions/closeReview.js'; +import { sourceAtPoint } from '../src/lib/utils/previewCursor.js'; +import { callbackBodies, functionSource, offsetOf, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; + +// Each describe installs its own Tauri stub in `beforeAll`. This one only +// answers the settings store, which boots when the modules below are imported. +(window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), +}; + +const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); +const { settings } = await import('../src/lib/stores/settings.svelte.js'); +const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); + +// An unanswered external-change conflict is a question only the user can +// answer. The viewer's saves that nobody asked for — leaving the editor or +// split view, and the exit's auto-save — used to go through the same wrapper as +// Cmd+S, which authorises the overwrite whenever the bar is up. So Ctrl+E or +// quitting wrote the buffer over the other program's change and took the bar +// down without asking. +// +// The functions are lifted out of MarkdownViewer.svelte and RUN against the +// real document session, so what is asserted is what reaches the disk. +describe('silent saves keep conflicts', () => { + const disk = new Map(); + + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string, args: any) => { + if (cmd === 'canonicalize_path') return Promise.resolve(args.path); + if (cmd === 'read_file_content_checked') return Promise.resolve([disk.get(args.path) ?? '', false, 'UTF-8']); + if (cmd === 'open_markdown_preview') return Promise.resolve([disk.get(args.path) ?? '', true, false, 'UTF-8']); + if (cmd === 'save_file_content') { + disk.set(args.path, args.content); + return Promise.resolve(null); + } + if (cmd === 'get_os_type') return Promise.resolve('macos'); + return Promise.resolve(null); + }, + }; + }); + + const LIFTED = [ + 'hasExternalChangeConflict', + 'noteExternalChangeConflict', + 'clearExternalChangeConflict', + 'resolveExternalChangeByKeepingBuffer', + 'saveContent', + 'saveSilently', + 'flushBeforeLeavingEditableMode', + 'settleForExit', + ]; + + function makeViewer() { + const closeQuestions: boolean[] = []; + const toasts: string[] = []; + const scope: Record = { + tabManager, + externalChangeConflicts: {}, + comparison: null, + settings: { autoSave: true, language: 'en', restoreStateOnReopen: false }, + t: (key: string) => key, + addToast: (message: string) => void toasts.push(message), + invoke: async () => null, + tick: async () => {}, + reviewDirtyTabs, + isCloseWalkActive: false, + pinFilesAtClose: null, + openFilePaths: () => [], + savePinnedTagIfNeeded: async () => {}, + persistWindowState: async () => {}, + }; + const session = createDocumentSession({ + setShowHome: () => {}, + currentFile: () => tabManager.activeTab?.path ?? '', + resetScrollHistory: () => {}, + renderMarkdown: async (raw: string) => raw, + afterLoad: async () => {}, + saveRecentFile: () => {}, + deleteRecentFile: () => {}, + setLoadingTabs: () => {}, + measureInitialViewport: () => {}, + isScrolling: () => false, + onError: () => {}, + onDiskChangedUnderSave: (id: string) => (fns.noteExternalChangeConflict as (id: string) => void)(id), + cancelPendingAutoSave: () => {}, + askClose: async (_title: string, diskMoved: boolean) => { + closeQuestions.push(diskMoved); + return 'cancel' as const; + }, + onCloseSaveNewerEdits: () => {}, + onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, + }); + scope.documentSession = session; + scope.canCloseTab = (id: string) => session.canCloseTab(id); + + const source = readSource('src/lib/MarkdownViewer.svelte'); + const declarations = LIFTED.map((name) => `const ${name} = ${functionSource(source, name)};`).join('\n'); + const js = ts.transpileModule(declarations, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + const fns = new Function('scope', `with (scope) { ${js}\nreturn { ${LIFTED.join(', ')} }; }`)( + new Proxy(scope, { has: (target, key) => key in target }), + ) as Record Promise>; + return { fns, scope, closeQuestions, toasts }; + } + + /** a.md was saved, another program then changed it, and the user typed once more. */ + function conflicted(viewer: ReturnType) { + tabManager.closeAll(); + disk.clear(); + disk.set('/notes/a.md', 'saved'); + tabManager.addTab('/notes/a.md', 'saved'); + const tab = tabManager.activeTab!; + tab.originalContent = 'saved'; + tab.isEditing = true; + disk.set('/notes/a.md', 'theirs'); + tabManager.updateTabRawContent(tab.id, 'saved!'); + (viewer.fns.noteExternalChangeConflict as unknown as (id: string) => void)(tab.id); + return tab; + } + + test('leaving the editor keeps the edit in the buffer and the bar up', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + await viewer.fns.flushBeforeLeavingEditableMode(tab); + + assert.equal(disk.get('/notes/a.md'), 'theirs', 'the other program\'s change was overwritten'); + assert.equal(tab.rawContent, 'saved!'); + assert.equal(viewer.fns.hasExternalChangeConflict(tab.id), true, 'the bar came down'); + assert.deepEqual(viewer.toasts, [], 'the bar already says why nothing was written'); + }); + + test('closing the window asks about a conflicted tab instead of saving it', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + assert.equal(await viewer.fns.settleForExit(), false); + + assert.equal(disk.get('/notes/a.md'), 'theirs', 'the other program\'s change was overwritten'); + assert.equal(tab.rawContent, 'saved!'); + assert.deepEqual(viewer.closeQuestions, [true], 'the walk asked the changed-on-disk question'); + }); + + test('the exit still auto-saves the other dirty tabs', async () => { + const viewer = makeViewer(); + conflicted(viewer); + disk.set('/notes/b.md', 'b'); + tabManager.addTab('/notes/b.md', 'b'); + const b = tabManager.activeTab!; + b.originalContent = 'b'; + tabManager.updateTabRawContent(b.id, 'b edited'); + + await viewer.fns.settleForExit(); + + assert.equal(disk.get('/notes/b.md'), 'b edited'); + assert.equal(disk.get('/notes/a.md'), 'theirs'); + }); + + test('Cmd+S on a conflicted tab is still the answer "keep mine"', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + assert.equal(await viewer.fns.saveContent(tab.id), true); + + assert.equal(disk.get('/notes/a.md'), 'saved!'); + assert.equal(viewer.fns.hasExternalChangeConflict(tab.id), false); + }); + + test('"Keep mine" from the comparison answers for the tab it was opened on', async () => { + const viewer = makeViewer(); + const a = conflicted(viewer); + // The user switched tabs with the comparison open: b.md is in front, edited, + // and another program has just written it too. + disk.set('/notes/b.md', 'b'); + tabManager.addTab('/notes/b.md', 'b'); + const b = tabManager.activeTab!; + b.originalContent = 'b'; + tabManager.updateTabRawContent(b.id, 'b edited'); + disk.set('/notes/b.md', 'b theirs'); + + // What the overlay's button passes: the tab the comparison was opened on. + await viewer.fns.resolveExternalChangeByKeepingBuffer(a.id); + await viewer.fns.saveSilently(b.id); + + assert.equal(disk.get('/notes/b.md'), 'b theirs', 'b.md was overwritten on an answer given about a.md'); + assert.equal(await viewer.fns.saveSilently(a.id), true, 'a.md did not get the answer'); + }); + + test('a conflict does not follow its tab onto another file', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + // A link in a.md: the close dialog is answered Don't Save, and b.md loads + // into the same tab. + disk.set('/notes/b.md', 'b'); + tab.rawContent = tab.originalContent; + await (viewer.scope.documentSession as { loadMarkdown: (path: string, options: object) => Promise }) + .loadMarkdown('/notes/b.md', { navigate: true }); + assert.equal(tab.path, '/notes/b.md'); + + // b.md is edited, and changed by another program before the watcher says so. + tabManager.updateTabRawContent(tab.id, 'b edited'); + disk.set('/notes/b.md', 'b theirs'); + + assert.equal(await viewer.fns.saveContent(tab.id), false); + assert.equal(disk.get('/notes/b.md'), 'b theirs', 'Cmd+S used the answer about a.md to overwrite b.md'); + }); +}); + +/* + * A keystroke reaches the document by ONE route, and the dirty flag is not a + * flag. + * + * THE DEFECT. Typing used to write the buffer twice. `MarkdownViewer` passed + * the editor `bind:value={tabManager.activeTab.rawContent}`, so Svelte's + * assignment put the new text on the tab; the same listener then called + * `tabManager.updateTabRawContent`, which put it there again and recomputed + * `isDirty` from it. Both writes carried the same text, so the app worked — and + * it worked *because* Svelte's assignment happened to run first. Delete the + * store call and the editor still edited, the preview still re-rendered, and + * nothing maintained `isDirty`: no dirty dot, no auto-save, no unsaved-changes + * prompt on close, and no type error anywhere to say so. + * + * WHAT IT IS NOW. `value` is a plain prop — a getter over the parent's + * `tabManager.activeTab.rawContent` — and the listener's call to the store is + * the only write. Remove it and the editor stops editing the document, which is + * a failure you can see; these tests are the ones that see it first. + * + * `isDirty` went the same way: a getter on the tab, exactly + * `rawContent !== originalContent`, so nothing can hold an opinion about + * "changed?" that its two buffers do not support. + * + * WHAT RUNS HERE. The component's own `onDidChangeModelContent` listener, + * lifted out of Editor.svelte and evaluated against the REAL `TabManager`. Only + * Monaco is a fake, and only the two methods the listener touches. + */ +describe('editor writes through the store', () => { + // ---------------------------------------------------------------- environment + // Svelte runes and the Tauri bridge, faked as the other lifting tests do, so + // `tabs.svelte.ts` imports under plain node. + + // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the + // Svelte plugin, so the store and the session run under real reactivity, and jsdom + // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), + }; + }); + + // ------------------------------------------------- the component, as written + + const EDITOR = 'src/lib/components/Editor.svelte'; + const source = readSource(EDITOR); + + const contentChangeBody = (() => { + const bodies = callbackBodies(source, 'editor.onDidChangeModelContent'); + assert.equal(bodies.length, 1, `expected exactly one model-content listener in ${EDITOR}, found ${bodies.length}`); + return bodies[0]; + })(); + + /** + * The lifted body is TypeScript and `new Function` only parses JavaScript, so + * it goes through `tsc` first, as tabs.checks.spec.ts and + * editor.imageUndo.checks.spec.ts already do. Types are erased, nothing else: the + * statements that run are the component's. + */ + const factorySource = ts.transpileModule( + `const __component = (tabManager, editor) => { + // The status bar readings. Stubbed rather than lifted because they are + // the listener's other errand and have their own tests; what is under + // test here is where the text goes. + const syncStatusFromModel = () => {}; + let emittedText = null; + return { contentChanged: () => ${contentChangeBody} }; + };`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText; + + /** + * The `value` prop, modelled as what Svelte compiles a dynamic prop to: a + * getter over the parent's expression, `value={tabManager.activeTab.rawContent}`. + * Modelling it as a local copy would be re-introducing the second buffer this + * whole change removed. + * + * A null-prototype object so `with` captures this one name and not + * `toString`, `constructor` and the rest of Object.prototype. + */ + const propsScope = Object.defineProperty(Object.create(null), 'value', { + get: () => tabManager.activeTab?.rawContent ?? '', + }); + + function editorShowing(text: string) { + let onScreen = text; + const editor = { getValue: () => onScreen }; + const factory = new Function( + '__props', + `with (__props) { ${factorySource}\nreturn __component; }`, + )(propsScope) as (tabManager: unknown, editor: unknown) => { contentChanged: () => void }; + const component = factory(tabManager, editor); + + return { + /** A keystroke: Monaco's buffer changes, then it fires the listener. */ + type: (next: string) => { + onScreen = next; + component.contentChanged(); + }, + }; + } + + function activeTab() { + const tab = tabManager.activeTab; + assert.ok(tab, 'precondition: a tab is active'); + return tab!; + } + + // ------------------------------------------------------------------ the tests + + test('a keystroke reaches the document, and the document knows it is unsaved', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/a.md', 'saved text'); + const editor = editorShowing('saved text'); + + assert.equal(activeTab().isDirty, false, 'precondition: a freshly opened tab is clean'); + + editor.type('saved text and more'); + + assert.equal( + activeTab().rawContent, + 'saved text and more', + 'the keystroke never reached the document: the editor is writing to something else, or to nothing', + ); + assert.equal( + activeTab().isDirty, + true, + 'the document has unsaved edits and does not say so — no dirty dot, no auto-save, no prompt on close', + ); + }); + + test('typing the saved text back makes the tab clean again, with nobody clearing a flag', () => { + // The point of deriving it. Undo, or retyping what was deleted, leaves a + // buffer that IS the saved text — and the only thing that has to happen for + // the tab to say so is that the two buffers agree. + tabManager.closeAll(); + tabManager.addTab('/notes/b.md', 'saved text'); + const editor = editorShowing('saved text'); + + editor.type('saved text!'); + assert.equal(activeTab().isDirty, true); + + editor.type('saved text'); + assert.equal(activeTab().isDirty, false, 'the buffer is the saved text and the tab still reads dirty'); + }); + + test('following a link does not leave the tab dirty against the file it just left', () => { + // `navigate` repoints the tab at another file before the load lands, so for + // that window `path` is the new file and `rawContent` is the old document. + // A tab left dirty there arms the auto-save effect, which writes the + // document the reader left into the file they opened. + tabManager.closeAll(); + tabManager.addTab('/notes/c.md', 'saved text'); + const editor = editorShowing('saved text'); + editor.type('unsaved edits'); + + tabManager.navigate(activeTab().id, '/notes/linked.md'); + + assert.equal(activeTab().path, '/notes/linked.md'); + assert.equal(activeTab().isDirty, false, 'the tab claims unsaved edits to a document it no longer holds'); + }); +}); + +// Issue #168, second report by @dayeggpi: "allow user to switch to rendered +// view without saving/creating file ... no way to see rendered view until file +// is saved". +// +// The cause was that reading mode was drawn from DISK: leaving the editor +// called `loadMarkdown(tab.path)`, so a dirty tab had to be flushed first — +// silently with auto-save on, through a modal otherwise — or the reader would +// have shown the pre-edit file. The modal was never protecting the buffer; the +// buffer survives a view toggle. It was protecting the screen from lying. +// +// These tests run the REAL `toggleEdit` / `toggleSplitView` / the real +// `renderTabPreviewFromRaw` out of MarkdownViewer.svelte against the real +// TabManager, with the disk, the renderer and the modal faked. `loadMarkdown` +// is faked to behave like the real one (dirty short-circuit included) and to +// serve a DIFFERENT text than the buffer, so any route that goes back to the +// file is visible in the rendered output rather than merely in the call log. +// +// The boundary tests at the bottom are the other half of the argument: closing +// a tab and closing the window still ask, because there the buffer really is +// about to disappear. +describe('view mode without saving', () => { + // ---------------------------------------------------------------- environment + + // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the + // Svelte plugin, so the store and the session run under real reactivity, and jsdom + // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: () => Promise.reject(new Error('no invoke expected in these tests')), + }; + }); + + // `settings` is one store for the whole file: put back what this block changes. + let settingsBefore: { autoSave: typeof settings.autoSave }; + beforeAll(() => { + settingsBefore = { autoSave: settings.autoSave }; + }); + afterAll(() => { + Object.assign(settings, settingsBefore); + }); + + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + // ------------------------------------------------------------ source plucking + + /** + * Slice one `async function (...) { ... }` out of the component. + * + * Brace counting, but string/template/comment aware — a naive count trips over + * the first `{` inside a comment or a template literal and silently returns a + * body that stops in the middle, which would make every assertion below + * meaningless in exactly the direction that hides bugs. + */ + function pluck(name: string, required = true): string { + const asyncStart = viewer.indexOf(`async function ${name}(`); + const syncStart = viewer.indexOf(`\tfunction ${name}(`); + const start = asyncStart !== -1 ? asyncStart : syncStart === -1 ? -1 : syncStart + 1; + if (start === -1) { + assert.ok(!required, `expected MarkdownViewer.svelte to define ${name}`); + return ''; + } + let i = offsetOf(viewer, '{', offsetOf(viewer, ')', start)); + let depth = 0; + for (; i < viewer.length; i++) { + const c = viewer[i]; + const next = viewer[i + 1]; + if (c === '/' && next === '/') { + i = viewer.indexOf('\n', i); + continue; + } + if (c === '/' && next === '*') { + i = viewer.indexOf('*/', i) + 1; + continue; + } + if (c === "'" || c === '"' || c === '`') { + const quote = c; + for (i++; i < viewer.length; i++) { + if (viewer[i] === '\\') i++; + else if (viewer[i] === quote) break; + } + continue; + } + if (c === '{') depth++; + else if (c === '}' && --depth === 0) return viewer.slice(start, i + 1); + } + assert.fail(`unbalanced braces while slicing ${name}`); + } + + type Harness = { + toggleEdit: () => Promise; + toggleSplitView: (tabId: string) => Promise; + }; + + type Fakes = { + disk: Map; + askCustomCalls: string[]; + saveCalls: string[]; + loadCalls: string[]; + renderedFrom: string[]; + toasts: string[]; + saveFails: boolean; + /** Text the "user" appends while `saveContent` is awaiting (TOCTOU). */ + typeDuringSave: string; + }; + + /** `renderMarkdownPreview`'s stand-in: the output names what it was given. */ + const rendered = (raw: string) => `${raw}`; + + function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record = {}): Harness { + const source = [ + pluck('renderTabPreviewFromRaw'), + // Absent on the pre-fix baseline, where both toggles inline their own + // save/modal flow. Optional so this file runs — and fails on behaviour + // rather than on a missing symbol — against either version. + pluck('flushBeforeLeavingEditableMode', false), + pluck('renderPreviewLeavingEditableMode', false), + pluck('toggleEdit'), + pluck('toggleSplitView'), + ].join('\n\n'); + + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, settings, t, addToast, askCustom, saveSilently, hasExternalChangeConflict, + cancelPendingAutoSave, renderMarkdownPreview, loadMarkdown, + documentSession, invoke, isEditing, liveMode, toggleLiveMode, + tick, renderRichContent, editorPane, markdownBody, + getPreviewScrollSyncPosition, restoreAfterLeavingEditor, + } = deps; + let previewPlacing = false; + ${js} + return { toggleEdit, toggleSplitView };`, + ); + + return factory({ + tabManager, + settings, + isEditing, + hasExternalChangeConflict: () => false, + t: (key: string) => key, + addToast: (message: string) => fakes.toasts.push(message), + askCustom: async (message: string) => { + fakes.askCustomCalls.push(message); + return 'save' as const; + }, + // Mirrors documentSession.saveContent for a tab that has a path. + saveSilently: async (tabId: string) => { + fakes.saveCalls.push(tabId); + const tab = tabManager.tabs.find((item) => item.id === tabId)!; + if (fakes.saveFails) return false; + const snapshot = tab.rawContent; + await Promise.resolve(); + if (fakes.typeDuringSave) tab.rawContent = snapshot + fakes.typeDuringSave; + fakes.disk.set(tab.path, snapshot); + tab.originalContent = snapshot; + return true; + }, + cancelPendingAutoSave: () => {}, + renderMarkdownPreview: async (raw: string) => { + fakes.renderedFrom.push(raw); + return rendered(raw); + }, + // The disk route, with the real function's dirty short-circuit. + loadMarkdown: async (path: string, options: any = {}) => { + fakes.loadCalls.push(path); + const activeId = tabManager.activeTabId!; + const receiving = tabManager.tabs.find((item) => item.id === activeId)!; + if (receiving.isDirty && receiving.path === path && !options.discardUnsavedBuffer) return; + const content = fakes.disk.get(path) ?? ''; + fakes.renderedFrom.push(content); + tabManager.updateTabContent(activeId, rendered(content)); + tabManager.setTabRawContent(activeId, content); + }, + documentSession: { ensureFullContent: async () => true, isLossySaveRefused: () => false }, + invoke: async () => { + throw new Error('no invoke expected while leaving an editable pane'); + }, + liveMode: false, + toggleLiveMode: () => {}, + tick: async () => {}, + // No panes unless a test asks: where the reader lands (#799) is not + // what the rest of this file is about. + editorPane: undefined, + markdownBody: undefined, + ...panes, + }); + } + + function freshFakes(): Fakes { + return { + disk: new Map(), + askCustomCalls: [], + saveCalls: [], + loadCalls: [], + renderedFrom: [], + toasts: [], + saveFails: false, + typeDuringSave: '', + }; + } + + const ON_DISK = '# saved heading\n'; + const IN_BUFFER = '# saved heading\n\nan edit that was never written to disk\n'; + + /** + * A tab holding unsaved edits to a real file, in whichever editable pane the + * caller names, with the file on disk still carrying the pre-edit text. + */ + function dirtyTab(mode: 'edit' | 'split', path = '/notes/note.md') { + tabManager.closeAll(); + const fakes = freshFakes(); + fakes.disk.set(path, ON_DISK); + tabManager.addTab(path); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, ON_DISK); + tab.isEditing = mode === 'edit'; + tabManager.setSplitEnabled(tab.id, mode === 'split'); + tabManager.updateTabRawContent(tab.id, IN_BUFFER); + assert.equal(tab.isDirty, true, 'precondition: the tab is dirty'); + return { tab, fakes, harness: buildHarness(fakes, mode === 'edit') }; + } + + /** + * `autoSave` used to be half of a pair — the other, `confirmBeforeSave`, could + * veto it, and every decision in the app read `autoSave && !confirmBeforeSave`. + * The pair is now the one switch that expression always described, so the two + * old combinations that meant "do not write silently" are the single `false` + * here. + */ + function setSettings(autoSave: boolean) { + settings.autoSave = autoSave; + } + + // ------------------------------------------------------- leaving edit mode + + test('a dirty file switches to reading mode without a modal and without writing', async () => { + setSettings(false); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.askCustomCalls, [], 'no unsaved-changes modal on a view toggle'); + assert.deepEqual(fakes.saveCalls, [], 'nothing is written to disk'); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isEditing, false, 'the user actually reaches reading mode'); + assert.equal(tab.isDirty, true, 'the edits are still unsaved, and still flagged as such'); + assert.equal(tab.rawContent, IN_BUFFER, 'the buffer is intact'); + }); + + test('reading mode shows the buffer, not the file', async () => { + setSettings(false); + const { tab, harness, fakes } = dirtyTab('edit'); + + await harness.toggleEdit(); + + // The whole point of the bug: with a disk read here, this is `ON_DISK`. + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.deepEqual( + fakes.renderedFrom, + [IN_BUFFER], + 'the preview is rendered once, from the buffer', + ); + assert.deepEqual(fakes.loadCalls, [], 'the file is not re-read to leave the editor'); + }); + + test('an untitled buffer never reaches the Save dialog on a view toggle', async () => { + setSettings(true); + tabManager.closeAll(); + const fakes = freshFakes(); + tabManager.addTab(''); + const tab = tabManager.activeTab!; + tab.isEditing = true; + tabManager.updateTabRawContent(tab.id, '# untitled\n'); + const harness = buildHarness(fakes, true); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered('# untitled\n')); + }); + + test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { + // The auto-save effect requires `isEditing || isSplit`, so this is the last + // chance to honour "save automatically" for these edits. + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.equal(fakes.disk.get(tab.path), IN_BUFFER, 'the flush actually wrote the buffer'); + assert.deepEqual(fakes.askCustomCalls, [], 'the flush is silent — it is the user\'s own setting'); + assert.equal(tab.isDirty, false); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('a file that cannot be written no longer traps the user in the editor', async () => { + // Read-only path, or a buffer the lossy-decode guard refuses: saveContent + // returns false forever, and the old code returned early on that, so + // reading mode was unreachable for the life of the tab. + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.saveFails = true; + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); + assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); + assert.equal(tab.isEditing, false, 'but the view still switches'); + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); + }); + + test('edits typed during the flush are shown, and reported as not yet on disk', async () => { + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.typeDuringSave = 'typed while saving\n'; + + await harness.toggleEdit(); + + assert.equal(tab.isDirty, true); + assert.ok(fakes.toasts.includes('toast.savedNewerEdits'), 'the disk is one revision behind'); + assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); + assert.equal( + tab.content, + rendered(IN_BUFFER + 'typed while saving\n'), + 'the preview shows the newest text, which is exactly what is NOT on disk', + ); + }); + + // --------------------------------------------------- closing the split view + + test('closing split view on a dirty file neither asks nor writes', async () => { + setSettings(false); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.askCustomCalls, []); + assert.deepEqual(fakes.saveCalls, []); + assert.deepEqual(fakes.loadCalls, []); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isSplit, false); + assert.equal(tab.isDirty, true); + assert.equal(tab.content, rendered(IN_BUFFER), 'the surviving pane keeps showing the buffer'); + }); + + test('closing split view honours auto-save the same way leaving edit mode does', async () => { + setSettings(true); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.deepEqual(fakes.askCustomCalls, []); + assert.equal(tab.isSplit, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('closing the window still reviews unsaved tabs', () => { + // The close handler is where the buffer is really at stake: it dies with + // the window. Source-level, because it is wired to a Tauri window event. + // Quit goes through the same handler now — `appExit` closes windows and + // decides nothing about their buffers (#390). + const exit = pluck('appExit'); + assert.doesNotMatch(exit, /askCustom/, 'quit asks nothing of its own; the close handler does the asking'); + + const closeHandler = sliceBetween(viewer, 'appWindow.onCloseRequested', 'onDragDropEvent'); + assert.match(closeHandler, /await settleForExit\(/, 'the close still runs the review'); + assert.match(pluck('settleForExit'), /canCloseTab,\n/, 'the walk still runs the per-tab dialog'); + }); + + test('the view toggles no longer re-read the file to leave an editable pane', () => { + // Belt and braces for the behaviour above: neither toggle may reach for + // the disk again. `renderTabPreviewFromRaw` is the shared "render THIS + // tab's buffer under its own path" helper (it also serves the PDF export). + const toggleEdit = pluck('toggleEdit'); + const leaveEditMode = toggleEdit.slice(0, offsetOf(toggleEdit, '// Switch to edit')); + assert.doesNotMatch(leaveEditMode, /loadMarkdown/); + assert.doesNotMatch(leaveEditMode, /askCustom/); + + const toggleSplit = pluck('toggleSplitView'); + const closeSplit = sliceFrom(toggleSplit, 'setSplitEnabled(tab.id, false)'); + assert.doesNotMatch(closeSplit, /loadMarkdown/); + assert.doesNotMatch(toggleSplit, /askCustom/); + }); + + // ------------------------------------------------ where the reader lands (#799) + + test('Ctrl+E hands each pane the line the other one was showing', async () => { + setSettings(false); + const { tab, fakes } = dirtyTab('edit'); + const fromEditor = { section: 'body', ratio: 0.4, line: 120 }; + const fromPreview = { section: 'body', ratio: 0.7, line: 300 }; + const synced: unknown[] = []; + const restored: unknown[] = []; + const panes = { + editorPane: { + scrollSyncPosition: () => fromEditor, + syncScrollToPosition: (position: unknown) => synced.push(position), + }, + markdownBody: {}, + getPreviewScrollSyncPosition: () => fromPreview, + restoreAfterLeavingEditor: (_id: string, position: unknown) => restored.push(position), + }; + + await buildHarness(fakes, true, panes).toggleEdit(); + assert.equal(tab.isEditing, false); + assert.deepEqual(restored, [fromEditor], 'the preview was not sent to the editor\'s line'); + + await buildHarness(fakes, false, panes).toggleEdit(); + assert.equal(tab.isEditing, true); + assert.deepEqual(synced, [fromPreview], 'the editor was not sent to the preview\'s line'); + }); + + test('Ctrl+E brings the cursor on screen only when the outline follows it', async () => { + // An outline marking the cursor's heading while the editor shows another + // one reads as wrong, so with 'cursor' the handoff moves a cursor left off + // screen. With 'scroll' the outline never looks at the cursor, and moving + // it would only lose the reader's place. + const asked: unknown[] = []; + for (const follows of ['cursor', 'scroll'] as const) { + settings.tocFollows = follows; + const { tab, fakes } = dirtyTab('edit'); + tab.isEditing = false; + await buildHarness(fakes, false, { + editorPane: { syncScrollToPosition: (_position: unknown, options: unknown) => asked.push(options) }, + markdownBody: {}, + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), + }).toggleEdit(); + } + settings.tocFollows = 'scroll'; + assert.deepEqual(asked, [{ cursorIntoView: true }, { cursorIntoView: false }]); + }); + + // Scroll sync is split view's. The tab keeps `isScrollSynced` after the split + // closes, and out of split the other pane is still mounted at zero width, so + // syncing through it dragged the visible pane off its line on every Ctrl+E. + function buildSyncHarness(panes: Record) { + const source = [ + pluck('handleEditorScrollSync'), + pluck('splitScrollSyncOn', false), + pluck('syncEditorToPreviewScroll'), + pluck('restoreAfterLeavingEditor'), + pluck('followToc'), + pluck('handleEditorCursor'), + pluck('placePreviewCursor'), + ].join('\n\n'); + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, tick, viewerPaneEl, markdownBody, editorPane, lineCoords, + tabAnchorForEditorTopLine, asBufferLine, scrollPreviewToSyncPosition, + getPreviewScrollSyncPosition, getPreviewScrollAnchor, settings, hasEditorPane, hasPreviewPane, isEditing, isSplit, + previewBlocks, sourceAtPoint, readRendererLine, + } = deps; + let tocActiveLine = null; + let previewPlacing = false; + const cursorByTab = {}; + const activeCursor = deps.activeCursor ?? null; + ${js} + return { + handleEditorScrollSync, syncEditorToPreviewScroll, restoreAfterLeavingEditor, handleEditorCursor, followToc, placePreviewCursor, + toc: () => tocActiveLine, + cursors: () => cursorByTab, + startPlacing: () => { previewPlacing = true; }, + };`, + ); + return factory({ + tabManager, + tick: async () => {}, + viewerPaneEl: undefined, + markdownBody: {}, + lineCoords: { toRendererLine: (line: number) => line - 3, toBufferLine: (line: number) => line + 3 }, + settings, + hasEditorPane: false, + hasPreviewPane: true, + isEditing: false, + isSplit: false, + tabAnchorForEditorTopLine: (_coords: unknown, line: number) => line, + asBufferLine: (line: number) => line, + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), + ...panes, + }); + } + + test('scroll sync left on in split view stays off once the split closes', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tabManager.setSplitEnabled(tab.id, true); + if (!tab.isScrollSynced) tabManager.toggleScrollSync(tab.id); + const toPreview: unknown[] = []; + const toEditor: unknown[] = []; + const harness = buildSyncHarness({ + scrollPreviewToSyncPosition: (position: unknown) => toPreview.push(position), + editorPane: { syncScrollToPosition: (position: unknown) => toEditor.push(position) }, + }); + const position = { section: 'body', ratio: 0.5, line: 40 }; + + harness.handleEditorScrollSync(position); + harness.syncEditorToPreviewScroll({}); + assert.equal(toPreview.length, 1, 'precondition: split view syncs the preview'); + assert.equal(toEditor.length, 1, 'precondition: split view syncs the editor'); + + tabManager.setSplitEnabled(tab.id, false); + tab.isEditing = true; + assert.equal(tab.isScrollSynced, true, 'the tab still remembers the choice'); + + harness.handleEditorScrollSync(position); + harness.syncEditorToPreviewScroll({}); + assert.equal(toPreview.length, 1, 'the hidden preview was scrolled from the editor'); + assert.equal(toEditor.length, 1, 'the editor was scrolled from the hidden preview'); + assert.equal(harness.toc(), 40, 'the outline still follows the editor'); + }); + + test('leaving the editor moves the outline to the line the preview lands on', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const harness = buildSyncHarness({ + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => 57, + }); + + await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); + + assert.equal(tab.anchorLine, 57); + assert.equal(harness.toc(), 57, 'the outline kept the entry from before the switch'); + }); + + // #799: "the table of contents should follow the cursor". An editor setting, + // because only the editor has one: the preview alone always follows the scroll. + describeOutlineSource('cursor', true, (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), 17, 'the cursor line, in the outline\'s numbering'); + h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); + assert.equal(h.toc(), 17, 'scrolling the editor left it on the cursor'); + h.handleEditorCursor(30, 1); + assert.equal(h.toc(), 27); + }); + + describeOutlineSource('cursor', false, async (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), null, 'no editor on screen, so no cursor to follow'); + await h.restoreAfterLeavingEditor(tabManager.activeTab!.id, { section: 'body', ratio: 0.5, line: 58 }); + assert.equal(h.toc(), 57, 'the preview alone follows its scroll'); + }); + + describeOutlineSource('scroll', true, (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), null, 'the default ignores the cursor'); + h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); + assert.equal(h.toc(), 60); + // The editor alone keeps the preview mounted at zero width, and its + // scroll events put the outline on a heading from that layout. + h.followToc('preview', 90); + assert.equal(h.toc(), 60, 'the hidden preview moved the outline'); + }); + + function describeOutlineSource( + follows: 'cursor' | 'scroll', + editorOnScreen: boolean, + run: (harness: ReturnType) => void | Promise, + ) { + test(`the outline follows the ${follows} setting with the editor ${editorOnScreen ? 'on' : 'off'} screen`, async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = editorOnScreen; + settings.tocFollows = follows; + try { + await run(buildSyncHarness({ + hasEditorPane: editorOnScreen, + hasPreviewPane: !editorOnScreen, + isEditing: editorOnScreen, + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => 57, + })); + } finally { + settings.tocFollows = 'scroll'; + } + }); + } + + test('the preview does not move the outline after Ctrl+E until it is placed', async () => { + // Its scroll events before that report the old position, and the outline + // flashed the first headings until the preview was placed. + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const harness = buildSyncHarness({ scrollPreviewToSyncPosition: () => {}, getPreviewScrollAnchor: () => 57 }); + + harness.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 40 }); + harness.startPlacing(); + harness.followToc('preview', 2); + assert.equal(harness.toc(), 40, 'an unplaced preview put the outline on its own line'); + + await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); + assert.equal(harness.toc(), 57, 'the placed preview decides'); + harness.followToc('preview', 60); + assert.equal(harness.toc(), 60, 'and keeps deciding once placed'); + }); + + test('reading with a cursor placed, the outline stays on it only while the preview draws it', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = false; + settings.tocFollows = 'cursor'; + try { + settings.previewCursor = true; + const drawn = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); + drawn.followToc('cursor', 17); + drawn.followToc('preview', 90); + assert.equal(drawn.toc(), 17, 'the preview scroll moved the outline off the drawn cursor'); + + // Off, the editor's last cursor is still there for Ctrl+E, but the + // preview shows nothing, so an outline pinned to it would look stuck. + settings.previewCursor = false; + const hidden = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); + hidden.followToc('cursor', 17); + hidden.followToc('preview', 90); + assert.equal(hidden.toc(), 90, 'the outline stayed on a cursor the preview does not show'); + + const without = buildSyncHarness({ hasEditorPane: false }); + without.followToc('preview', 90); + assert.equal(without.toc(), 90, 'with no cursor yet, the outline follows the scroll'); + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + } + }); + + test('a click in the preview places the cursor only with its setting on, whatever the outline follows', () => { + // #799: the shared cursor is an editing aid in its own right, so Ctrl+E + // lands on the clicked character in either outline mode. The drawn caret + // is visual noise to some readers, so the whole aid is off by default. + const root = document.createElement('div'); + root.innerHTML = '

hello world

'; + const text = root.querySelector('p')!.firstChild!; + (document as any).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(text, 6); + return range; + }; + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const click = () => { + const harness = buildSyncHarness({ + previewBlocks: root, + sourceAtPoint, + readRendererLine: (line: number) => (line === 2 ? 'hello world' : ''), + activeCursor: { line: 5, column: 7 }, + }); + harness.placePreviewCursor({ detail: 1, clientX: 0, clientY: 0 } as MouseEvent); + return harness; + }; + try { + settings.tocFollows = 'cursor'; + const off = click(); + assert.deepEqual(off.cursors(), {}, 'off by default: the click leaves the cursor alone'); + assert.equal(off.toc(), null); + + settings.previewCursor = true; + for (const follows of ['scroll', 'cursor'] as const) { + settings.tocFollows = follows; + const harness = click(); + assert.deepEqual(harness.cursors()[tab.id], { line: 5, column: 7 }, `${follows}: the click placed the cursor on the "w"`); + assert.equal(harness.toc(), follows === 'cursor' ? 2 : null, `${follows}: the outline`); + } + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + delete (document as any).caretRangeFromPoint; + } + }); +}); diff --git a/scripts/documents.checks.test.ts b/scripts/documents.checks.test.ts new file mode 100644 index 00000000..be56917e --- /dev/null +++ b/scripts/documents.checks.test.ts @@ -0,0 +1,1014 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { describe, test } from 'node:test'; + +import { LIST_MARKER, TASK_BOX } from '../src/lib/utils/listSyntax.js'; +import { MARKDOWN_LINK_EXTENSIONS } from '../src/lib/utils/markdownLinks.js'; +import { DEFAULT_OPEN_FILE_MODE } from '../src/lib/utils/openFileMode.js'; +import { viewerCommandTable } from './keymapHarness.js'; +import { + callbackBodies, + filesMatching, + functionSource, + offsetOf, + readDictionarySource, + readRustBackend, + readSource, + readSourceFiles, + sliceBetween, + sliceFrom, +} from './sourceTree.js'; + +/* + * #379 made every read that fills a WRITABLE buffer report whether the decode + * was lossy, so the tab can refuse to write U+FFFD back over a file that was + * merely in another encoding. Three reads kept the bare command: + * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and + * the split pane. They were safe — but only because each re-read a file whose + * tab `loadMarkdown` had already flagged. That is an invariant held by call + * sites agreeing with each other, and the failure mode when one stops agreeing + * is a destroyed document. It is now held by the code: every one of them reads + * the fidelity itself. + * + * WHAT IS LEFT IN THIS FILE, AND WHY IT IS HERE RATHER THAN UNDER VITEST + * + * Every assertion below reads source text, and each one is of a kind no run can + * reach: + * + * - that the unchecked command exists NOWHERE. An absence is not observable + * at run time: executing the checked read cannot prove there is no other + * caller of the unchecked one. The same claim then crosses into the Rust + * crate, where nothing but the spelling connects the two sides. + * - the two MarkdownViewer call sites, and the two auto-save ones. They are + * in a `.svelte` component, and mounting one here would put jsdom in for + * Monaco, mermaid and KaTeX to earn a weaker claim than the text does — + * what these pin is the ORDER of statements inside a handler (flag the tab + * before the buffer is published; recognise the refusal before raising the + * generic toast), which is a property of the code as written. + * + * `ensureFullContent` is not here: it is a runes module driven for real, and + * running it under a hand-written `$state` was standing exactly where the + * assertions look. It is `documents.load.spec.ts` now. `documents.checks.test.ts` + * and `documents.save.spec.ts` cover the store behaviour these source + * assertions deliberately stop short of. + * + * The second half of this file is the toast the guard's refusal used to + * trigger every 1.5 seconds. + */ +describe('checked read migration', () => { + // --- the two component call sites ------------------------------------------- + + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + test('the unchecked read command is gone, and nothing calls it', () => { + // This was a three-file allowlist — the files #379 migrated. That is the + // wrong shape for a rule that means "nobody, anywhere": a fourth file + // added the call and the test still passed. Two changes fix it. The scan + // below covers the whole tree, so a new file cannot slip under it. And the + // Rust command itself is deleted, which is what turns the rule from a + // convention into a fact: `read_file_content` is not registered any more, + // so invoking it fails loudly instead of quietly filling a buffer with + // mojibake nothing flagged. + const offenders = readSourceFiles('src') + .filter(({ text }) => /invoke\('read_file_content'/.test(text)) + .map(({ path }) => path); + assert.deepEqual(offenders, [], 'read_file_content no longer exists; read_file_content_checked is the read'); + + const rust = readRustBackend(); + assert.doesNotMatch(rust, /\basync fn read_file_content\(/, 'the unchecked command must stay deleted'); + assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); + }); + + test('entering the editor reads the fidelity and stores it', () => { + const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function saveContent'); + assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); + const read = offsetOf(toggle, 'read_file_content_checked'); + const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); + const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); + assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); + }); + + test('entering split view reads the fidelity and stores it', () => { + const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); + assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); + const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); + const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); + assert.ok(flag < store, 'flag the tab before the buffer is published'); + }); + + // --- the repeating toast ------------------------------------------------------ + + test('the session can tell a refusal from a failure', () => { + // `saveContent` returns false for both, which is why the auto-save timer + // could not tell them apart. The predicate is no longer set membership on + // its own: the set records what was SAID, and whether the refusal still + // stands is asked of the tab. `documents.save.spec.ts` exercises + // both halves for real; this only pins that the tab is consulted at all, + // since a predicate that answers from memory alone is the defect. + const body = sliceBetween( + readSource('src/lib/sessions/documentSession.svelte.ts'), + 'function isLossySaveRefused(', + 'function updateLoading', + ); + assert.match(body, /lossySaveWarnedTabs\.has\(tabId\)/); + assert.match(body, /hasReplacementChars/, 'the live tab decides whether anything is still being refused'); + }); + + test('a refused save does not add a generic toast to its own explanation', () => { + const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); + const check = offsetOf(body, 'if (documentSession.isLossySaveRefused(s.id)) return;'); + const toast = offsetOf(body, "t('toast.autoSaveFailed'"); + assert.ok(check < toast, 'the refusal must be recognised before the generic toast is raised'); + }); + + test('a tab that can only be refused stops re-arming the timer', () => { + // Auto-save re-arms on every keystroke. Without this the guard was reached + // again every 1.5s for as long as the user kept typing — each time + // producing the console warning, the wasted round trip, and (before the + // test above) the toast. + const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); + assert.match(body, /decodedLossily: tab\.hasReplacementChars/); + assert.match(body, /const eligible = [^;]*!\(s\.decodedLossily && documentSession\.isLossySaveRefused\(s\.id\)\)/); + // The FIRST attempt must still happen: it is what produces the explanation. + // `isLossySaveRefused` is false until the guard has spoken, so eligibility + // only drops afterwards — and "Save As" to a new file clears + // `hasReplacementChars`, which restores it. + assert.match( + readSource('src/lib/sessions/documentSession.svelte.ts'), + /lossySaveWarnedTabs\.delete\(tab\.id\)/, + ); + }); +}); + +// Every read path decodes leniently (#371): a file it cannot read opens with +// U+FFFD substituted for the bytes it could not, instead of failing. U+FFFD is +// not reversible — the original bytes are gone from the buffer. Writing that +// buffer back over the source file (auto-save does it 1.5s after the first +// keystroke) destroys the document permanently. +// +// Detection (#372) removed the common cause rather than the guard: a legacy +// encoding (GBK, Big5, Shift-JIS, CP-1252 ...) is now read properly and +// written back as itself — `documents.save.spec.ts` drives that. +// What is left here is the file NO encoding can read, which is still a buffer +// that must never reach its own file. +// +// The guard: Rust reports the fidelity of every decode, the tab carries it — +// through window restore and cross-window moves — and `documentSession` +// refuses to write such a buffer over the file it came from. That is the +// single choke point every writer shares, including MarkdownViewer's +// auto-save timer and the close dialogs. "Save As" to a *different* path +// stays open: the new file is genuinely UTF-8, so writing it is correct, and +// it is the user's only way out. +describe('lossy decode save guard', () => { + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + const windowSession = readSource('src/lib/sessions/windowSession.svelte.ts'); + const tabs = readSource('src/lib/stores/tabs.svelte.ts'); + const rust = readRustBackend(); + + const loadMarkdown = () => sliceBetween(session, 'async function loadMarkdown', 'async function saveContent'); + const saveContent = () => sliceBetween(session, 'async function saveContent(', 'async function saveContentAs'); + const saveContentAs = () => sliceBetween(session, 'async function saveContentAs', 'async function toggleTaskCheckbox'); + const guard = () => sliceBetween(session, 'function refuseIfLossilyDecoded', 'function updateLoading'); + + test('one decoder, and it reports what it destroyed', () => { + // The fact is known exactly once — when the bytes are decoded — and was + // thrown away. Detection (#372) made the decode able to succeed where it + // used to substitute, but not able to always succeed, so the decoder still + // carries the verdict rather than growing a second path. + assert.match(rust, /struct DecodedText \{[^}]*\bcontent: String,[^}]*\blossy: bool/s); + assert.match(rust, /fn decode_text\(bytes: &\[u8\]\) -> DecodedText/); + assert.match(rust, /fn read_to_string_lossy\(path: &str\) -> std::io::Result/); + assert.equal(rust.match(/fn decode_text\(/g)?.length, 1, 'exactly one decoder'); + }); + + test('both read commands can report fidelity', () => { + assert.match(rust, /async fn open_markdown_preview\([^)]*\)\s*-> Result<\(String, bool, bool, String\), String>/s); + assert.match(rust, /async fn read_file_content_checked\(path: String\) -> Result<\(String, bool, String\), String>/); + assert.match(rust, /read_file_content_checked,/, 'the command must be registered'); + }); + + test('a preview cut inside a multi-byte character is not called lossy', () => { + // The preview stops at a fixed byte count, so a perfectly valid UTF-8 file + // is routinely cut mid-character. Reporting that as lossy would lock every + // large CJK/emoji document out of saving — a guard worse than the bug. + const preview = sliceBetween(rust, 'fn build_markdown_preview', '#[tauri::command]'); + const trim = offsetOf(preview, 'utf8_truncation_boundary'); + assert.ok(trim < offsetOf(preview, 'decode_text('), 'the split tail is dropped before fidelity is judged'); + }); + + test('the tab carries the fidelity of its buffer', () => { + // Required, not optional: a construction site that forgot it would default + // to "safe to overwrite", and the failure mode is a destroyed document. + assert.match(tabs, /hasReplacementChars: boolean;/); + assert.doesNotMatch(tabs, /hasReplacementChars\?: boolean;/); + assert.match(tabs, /setTabDecodedLossy\(id: string, lossy: boolean\)/); + assert.equal(tabs.match(/hasReplacementChars: false/g)?.length, 4); + }); + + test('every load decides the flag instead of leaving it stale', () => { + // A reload of a file the user has since converted to UTF-8 must clear it. + const body = loadMarkdown(); + assert.match(body, /as \[string, boolean, boolean, string\]/); + assert.match(body, /setTabDecodedLossy\(activeId, lossy\)/); + // The full load REPLACES the preview's buffer, so it brings its own + // verdict: a file can be valid UTF-8 for the first 50KB and not after. + assert.match(body, /read_file_content_checked/); + assert.match(body, /setTabDecodedLossy\(activeId, fullLossy\)/); + }); + + test('the editable-pane shortcut reports fidelity too', () => { + // #374 added a second way into loadMarkdown: a pane that can WRITE skips + // the preview and reads the whole file up front. That branch fills an + // editable buffer, so it is exactly the kind of caller that must not use + // the bare command — it would hand the editor unflagged mojibake. + const body = loadMarkdown(); + assert.match( + body, + /if \(initialIsEditing \|\| initialIsSplit\) \{\s*\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked'/, + ); + // `ensureFullContent` was the one place the bare command survived, and only + // because it re-read a file whose tab loadMarkdown had already flagged — + // an invariant enforced by two call sites agreeing rather than by the code. + // It now reads the fidelity itself, so no writable buffer in this file is + // filled by a command that cannot report one. + const bare = session.match(/invoke\('read_file_content'/g)?.length ?? 0; + assert.equal(bare, 0, 'no writable buffer may be filled by the unchecked command'); + assert.match( + sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'), + /invoke\('read_file_content_checked'/, + ); + assert.match( + sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'), + /setTabDecodedLossy\(tabId, lossy\)/, + ); + }); + + test('the flag is set before the buffer can reach a writer', () => { + const body = loadMarkdown(); + const set = offsetOf(body, 'setTabDecodedLossy(activeId, lossy)'); + const buffer = offsetOf(body, 'setTabRawContent(activeId, content'); + assert.ok(set < buffer, 'the flag must be set before the buffer it describes'); + assert.doesNotMatch(body.slice(set, buffer), /await /, 'and in the same synchronous block'); + }); + + test('window restore cannot launder the flag away', () => { + // Restore reads each file back into an editable buffer. With the bare + // command it produced mojibake that looked safe to save, so reopening the + // app was a way around the guard. It now reads through `loadMarkdown`, + // which sets the flag on every load, and reads nothing itself. + assert.doesNotMatch(windowSession, /invoke\('read_file/); + assert.match(sliceBetween(session, 'function readRestoredTab', 'function readActiveIfUnread'), /return loadMarkdown\(tab\.path,/); + assert.match(loadMarkdown(), /tabManager\.setTabDecodedLossy\(activeId, lossy\)/); + }); + + test('saveContent refuses to write a lossy buffer over its own file', () => { + const body = saveContent(); + const refusal = offsetOf(body, 'refuseIfLossilyDecoded'); + assert.match(body.slice(refusal, refusal + 120), /return false;/); + assert.ok( + refusal < offsetOf(body, "invoke('save_file_content'"), + 'the guard must run before save_file_content is invoked', + ); + }); + + test('the guard protects the source file only', () => { + const body = guard(); + // An untitled buffer has no source file to destroy, and any other path is + // a new UTF-8 file — neither is blocked. + assert.match(body, /targetPath === ''/); + assert.match(body, /targetPath !== tab\.path/); + assert.match(body, /return true;/); + }); + + test('saveContent is the choke point auto-save and the close dialogs share', () => { + // Auto-save lives in MarkdownViewer.svelte's debounce effect and the close + // flow in canCloseTab; both reach saveContent, so guarding it covers them + // without touching either. + assert.match(session, /const success = await saveContent\(tabId\);/); + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + assert.match(viewer, /saveSilently\(s\.id\)\.then\(/); + assert.match(functionSource(viewer, 'saveSilently'), /documentSession\.saveContent\(tabId\)/); + }); + + test('Save As to a new file stays open as the escape hatch', () => { + const body = saveContentAs(); + // Explicitly UTF-8, never the tab's own encoding: the copy exists to be + // readable when the original could not be read, or could not represent + // what the user typed into it. + assert.match(body, /invoke\('save_file_content', \{ path: selected, content: snapshot, encoding: 'UTF-8' \}\)/); + // Once written, the buffer matches a UTF-8 file of its own. + assert.match(body, /setTabDecodedLossy\(tab\.id, false\)/); + assert.match(body, /setTabEncoding\(tab\.id, 'UTF-8'\)/); + }); + + test('Save As onto the same file is still a destructive overwrite', () => { + const body = saveContentAs(); + // "The same file", not "the same string": the dialog can come back with + // another spelling of the source file — a different case, or accents + // composed differently — and on macOS and Windows that still lands on the + // same bytes. The guard is therefore handed the target's resolved identity + // as well as its path. See tabs.spec.ts, which drives + // that refusal for real instead of reading for it. + const refusal = offsetOf(body, 'refuseIfLossilyDecoded(tab, selected'); + assert.ok( + refusal < offsetOf(body, "invoke('save_file_content'"), + 'the guard must run before save_file_content is invoked', + ); + }); + + test('the refusal tells the user what to do and is not repeated per keystroke', () => { + // Auto-save re-arms on every edit; an undeduplicated toast would fire + // every 1.5s. The message names "Save As" because that is the only exit. + const body = guard(); + assert.match(body, /toast\.lossySaveBlocked/); + assert.match(body, /lossySaveWarnedTabs\.has\(tab\.id\)/); + const i18n = readDictionarySource(); + assert.match(i18n, /lossySaveBlocked: 'Not saved: parts of this file could not be read in any encoding/); + assert.match(i18n, /use "Save As" to write a copy/); + }); +}); + +describe('save from reading mode', () => { + test('the browser save dialog is always suppressed', () => { + // preventDefault has to run for every mode, including the no-op case, or + // reading mode would surface the webview's own Save Page dialog. It is one + // unconditional call on the command rather than one per branch now, so this + // reads the six-line handler rather than the branch it used to sit in — and + // covers all twenty-two commands instead of Save alone. + const handler = functionSource(readSource('src/lib/MarkdownViewer.svelte'), 'handleKeyDown'); + assert.match( + handler, + /if \(!command\) return;\s*\n\s*e\.preventDefault\(\);/, + 'a command that runs must have had the keystroke prevented first', + ); + }); + + /* + * The three below are the residue of this seam, and they say so. + * + * What Ctrl+S DOES is a closure over `tabManager.activeTab` inside the + * component, which is the one thing that could not move: see the note on + * `viewerCommandTable` in `keymapHarness.ts`. They are still text assertions — + * but text anchored on a `case 'save':` label rather than on a chord condition, + * so the rename that reddened five of these in #647 no longer reaches them. + */ + const saveCommand = () => viewerCommandTable()['save']; + + test('an untitled buffer can be saved from reading mode', () => { + // `path === ''` is the untitled case; documentSession.saveContent opens + // the Save dialog for it, so no extra plumbing is needed here. + assert.match(saveCommand(), /saveTarget\.path === ''/); + }); + + test('a saved, unmodified document does not write on Ctrl+S', () => { + // Writing unconditionally would touch mtime and wake the file watcher, + // which in live mode reloads the tab -- a keystroke that reads as a no-op + // to the user should not move the document underneath them. + assert.match(saveCommand(), /saveTarget\.isDirty \|\| saveTarget\.path === ''/); + }); + + test('the HOME tab is not savable', () => { + // HOME carries the sentinel path 'HOME' and is never dirty, so the guard + // excludes it without naming it. This pins that reasoning: if the guard + // ever becomes unconditional, HOME would start opening a Save dialog. + assert.doesNotMatch(saveCommand(), /^\s*saveContent\(\);\s*$/m); + }); +}); + +describe('unsaved recovery', () => { + test('the close decision commands are spelled the same in the viewer and in Rust', () => { + // Cross-language contract: only the string connects invoke() to the command. + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + const rust = readSource('src-tauri/src/window_runtime.rs'); + for (const name of ['keeps_unsaved_on_close', 'set_quitting']) { + assert.ok(viewer.includes(`invoke('${name}'`) || viewer.includes(`invoke('${name}'`), `viewer does not invoke ${name}`); + assert.match(rust, new RegExp(`pub fn ${name}\\(`), `Rust does not declare ${name}`); + } + }); +}); + +describe('live mode watched path', () => { + const runtime = readSource('src-tauri/src/window_runtime.rs'); + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + test('a watcher notification is routed to its watched path', () => { + assert.match(runtime, /emit_to\(event_label\.as_str\(\), "file-changed", watched_path\.clone\(\)\)/); + // `async` because deciding what an event means now reads the file: the + // answer to "did somebody else write this" is the file itself, not a clock. + assert.match(viewer, /await appWindow\.listen\('file-changed', async \(event\) => \{/); + assert.match(viewer, /const changedPath = event\.payload as string;/); + // The listener no longer compares the payload with the active file itself: + // resolveExternalChange looks up the tab that OWNS the changed path (and + // refuses to reload it when it has unsaved edits). See + // documents.load.spec.ts for the routing behaviour. + assert.match(viewer, /await documentSession\.resolveExternalChange\(changedPath\)/); + // A conflict is reported with Live Mode off too; Live Mode gates only the reload. + assert.match(viewer, /if \(liveMode\) loadMarkdown\(outcome\.path\);/); + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + assert.match(session, /tabManager\.tabs\.find\(\(tab\) => tab\.path === changedPath\)/); + }); + + test('the watcher follows the active file instead of retaining a previous tab watcher', () => { + assert.match(viewer, /if \(currentFile\) \{\n\t\t\tinvoke\('watch_file', \{ path: currentFile \}\)/); + + // The defect #296 fixed was `documentSession` re-issuing `watch_file` on + // every load, so the watcher followed whichever document loaded last rather + // than the active one. It used to be pinned as + // `/isLiveMode\(\)\) invoke\('watch_file'/` — the exact one-line spelling + // the defect happened to be written in, against a file where neither token + // occurs any more. Rewriting the same call as an ordinary `if (…) { … }` + // evaded it. What has to hold is who may call it: the effect above, alone. + const watchers = filesMatching(readSourceFiles('src'), /invoke\(\s*'watch_file'/); + assert.deepEqual(watchers, ['src/lib/MarkdownViewer.svelte'], 'one owner for the file watcher'); + + const watchingEffects = callbackBodies(viewer, '$effect').filter((body) => /'watch_file'/.test(body)); + assert.equal(watchingEffects.length, 1, 'and one effect inside it'); + assert.match(watchingEffects[0], /invoke\('unwatch_file'\)/, 'the same effect gives the watcher up'); + + const toggleLiveMode = sliceBetween(viewer, 'function toggleLiveMode', 'async function saveImageAs'); + assert.doesNotMatch(toggleLiveMode, /loadMarkdown\(/); + }); +}); + +describe('editor line ending', () => { + test('the status bar renders the measured label and no longer a translation key', () => { + const editor = readSource('src/lib/components/Editor.svelte'); + const statusBar = sliceBetween(editor, '
', '
\n{/if}'); + + assert.match(statusBar, /
\{lineEnding\}<\/div>/, 'the line-ending item shows the state'); + assert.doesNotMatch(editor, /editor\.status\.crlf/, 'the hardcoded CRLF string is gone'); + assert.match( + editor, + /lineEnding = lineEndingLabel\(model\)/, + 'and it is filled from the model, in the function that refreshes document-wide readings', + ); + + // One line-ending decision reaches this component. A second one — a `\r\n` + // test of its own over `getValue()` — is the defect this fix replaces, in a + // new place. + assert.doesNotMatch(editor, /includes\('\\r\\n'\)/, 'no hand-rolled line-ending detection in the editor'); + }); +}); + +describe('file associations', () => { + const config = JSON.parse(readSource('src-tauri/tauri.conf.json')) as { + bundle: { + fileAssociations: Array<{ ext: string[]; contentTypes?: string[]; mimeType?: string; name: string }>; + }; + }; + + // Every extension Markpad treats as a Markdown document is associated, except + // `.txt`. An extension added to either list fails here until the other follows. + test('installer associates every Markdown extension except txt', () => { + const extensions = config.bundle.fileAssociations.flatMap((association) => association.ext); + assert.deepEqual( + extensions, + MARKDOWN_LINK_EXTENSIONS.filter((ext) => ext !== 'txt'), + ); + }); + + // macOS ignores `ext` once an association has a content type, and its + // Markdown type (net.daringfireball.markdown, owned by the system) only tags + // .md and .markdown. Any other extension must sit in an association with no + // content type or MIME type, or it gets a dynamic UTI that no association + // claims and Markpad drops out of Finder's Open With. One name keeps a single + // Windows ProgID, which hooks.nsi registers under. + test('macOS claims extensions outside the system Markdown type by extension', () => { + for (const association of config.bundle.fileAssociations) { + if (association.contentTypes) { + assert.deepEqual(association.ext, ['md', 'markdown']); + } else { + assert.equal(association.mimeType, undefined); + } + assert.equal(association.name, 'Markdown File'); + } + }); + + // The other half of the same policy, kept in this file so neither side moves + // without the reader seeing both. Claiming `.txt` in the installer takes the + // Text Document association away from Notepad (#179), which is why the test + // above exists — but Markpad does open, render and save `.txt` like any other + // note, so the Open dialog has to offer them. + test('the Open dialog offers every extension Markpad treats as a document', () => { + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + const links = readSource('src/lib/utils/markdownLinks.ts'); + + assert.match(links, /MARKDOWN_LINK_EXTENSIONS = \[[^\]]*'txt'/); + assert.match(viewer, /\{ name: 'Markdown', extensions: MARKDOWN_LINK_EXTENSIONS \}/); + // A literal list is how the two drifted apart in the first place. + assert.doesNotMatch(viewer, /extensions: \['md/); + }); + + test('the Save dialogs offer the same document extensions', () => { + // Both hard-coded `['md']`, so a `.txt` note saved under a new name had to + // go through "All Files". + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + + assert.equal(session.match(/\{ name: 'Markdown', extensions: MARKDOWN_LINK_EXTENSIONS \}/g)?.length, 2); + assert.doesNotMatch(session, /extensions: \['md/); + }); +}); + +/** + * #183: "I'd like it to remember that I had the split screen option turned on + * when opening a new session." + * + * Half of it already worked — a session restore replays each tab's saved + * `isSplit` — but every newly opened document was created `isSplit: false`, so + * the reporter pressed the split button again for each file. There was a + * preference for the neighbouring case and none for this one. + * + * It is ONE preference with three answers rather than a second checkbox beside + * the first, because `isEditing` and `isSplit` are independent flags on the tab: + * two booleans can say "editor AND split", which is not a state the reader can + * be in. That made the checkbox that already existed part of this change, and + * an existing install must not notice — hence the migration below. + * + * The two implementation details pinned here are the ones that are quietly + * expensive to get wrong: + * + * 1. Split must go through `setSplitEnabled`, not a bare `tab.isSplit = true`. + * A fresh tab is created `isScrollSynced: false`, and `setSplitEnabled` is + * what applies the remembered scroll-sync preference — assigning the flag + * directly opens split view with sync silently off. + * + * 2. Both must be applied BEFORE `initialIsSplit` is read. That variable is + * what sends the load down the full-read branch: `open_markdown_preview` + * returns only the first 50KB, and a split pane is an editor, so a document + * that opened split on a partial buffer would be one keystroke away from + * auto-saving the truncation back over the file. + */ +describe('open file mode', () => { + const sessionSource = readSource(new URL('../src/lib/sessions/documentSession.svelte.ts', import.meta.url)); + const settingsSource = readSource(new URL('../src/lib/stores/settings.svelte.ts', import.meta.url)); + const settingsComponentSource = readSource(new URL('../src/lib/components/Settings.svelte', import.meta.url)); + const tabsSource = readSource(new URL('../src/lib/stores/tabs.svelte.ts', import.meta.url)); + + test('the old key is read, never written — a rollback still finds its setting', () => { + assert.match(settingsSource, /const LEGACY_START_IN_EDITOR_KEY = 'editor\.startInEditor'/); + assert.match(settingsSource, /read: \(s\) => s\.openFileMode/); + assert.match( + settingsSource, + /resolveOpenFileMode\(raw, readStoredKey\(LEGACY_START_IN_EDITOR_KEY\)\)/, + ); + // One `key:` per persisted entry, and this entry's is the new one: the + // legacy name must not appear as a key this store writes back. + assert.doesNotMatch(settingsSource, /key: 'editor\.startInEditor'/); + }); + + /* ------------------------------------------------------------- the wiring */ + + test('opening a document applies the choice, split through the same door the toggle uses', () => { + assert.match(sessionSource, /tab\.isEditing = settings\.openFileMode === 'editor'/); + assert.match( + sessionSource, + /if \(settings\.openFileMode === 'split'\) tabManager\.setSplitEnabled\(tab\.id, true\)/, + ); + // The premise of point 1 above: a fresh tab has sync off, and + // `setSplitEnabled` is the only place the preference is applied. + assert.match(tabsSource, /isScrollSynced: false/); + assert.match( + tabsSource, + /setSplitEnabled\(id: string, enabled: boolean\)[\s\S]{0,200}?tab\.isScrollSynced = this\.splitScrollSyncPreference/, + ); + }); + + test('the choice is applied before the load decides how much of the file to read', () => { + const applied = sessionSource.indexOf("if (settings.openFileMode === 'split')"); + const read = sessionSource.indexOf('const initialIsSplit = tab?.isSplit ?? false'); + const fullRead = sessionSource.indexOf('if (initialIsEditing || initialIsSplit)'); + + assert.ok(applied > 0 && read > 0 && fullRead > 0); + assert.ok( + applied < read && read < fullRead, + 'a document opening into an editable mode must take the full-read branch, ' + + 'or the editor is bound to the 50KB preview slice', + ); + }); + + test('it applies to a newly opened document only', () => { + // Not to a reload that is preserving the mode, and not to a file already + // open in a tab — reopening a document must not flip the mode the reader + // chose for it. The same guard the checkbox always had. + assert.match(sessionSource, /if \(tab && !loadOptions\.preserveEditState && !existing\) \{/); + }); + + test('a new empty document keeps its own preference', () => { + // `Ctrl+N` asks a different question — what an empty buffer opens as — and + // answers it with `newFileDefaultMode`. This change must not annex it. + assert.match(tabsSource, /isEditing: settings\.newFileDefaultMode/); + }); + + test('the setting is one control, defaulting to preview', () => { + assert.match(settingsSource, /openFileMode = \$state\(DEFAULT_OPEN_FILE_MODE\)/); + assert.equal(DEFAULT_OPEN_FILE_MODE, 'preview'); + assert.match(settingsComponentSource, / \u00a0\u00a0indented\n\n', + ); + + assert.equal(root.querySelector('.task-text')?.textContent, '\u00a0\u00a0indented'); + }); +}); + +describe('preview annotations', () => { + // A reader's session-only highlight: the selection goes to source points, + // and comes back as the same text however the preview was re-rendered since. + + const SOURCE = ['A paragraph with **bold text** here.', '', '中文段落里有**粗体**和测试。']; + + const HTML = '

A paragraph with bold text here.

\n

中文段落里有粗体和测试。

'; + + const readLine = (line: number) => SOURCE[line - 1]; + + function render() { + const root = document.createElement('div'); + root.innerHTML = HTML; + return root; + } + + function select(root: Element, from: string, to: string) { + const text = root.textContent!; + const start = text.indexOf(from); + const end = text.indexOf(to) + to.length; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + let seen = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + if (start >= seen && start < seen + node.data.length) range.setStart(node, start - seen); + if (end > seen && end <= seen + node.data.length) range.setEnd(node, end - seen); + seen += node.data.length; + } + return range; + } + + test('a selection comes back as the same text in a fresh render', () => { + const cases: Array<[string, string, string]> = [ + ['bold', 'text', 'bold text'], + ['with', 'here', 'with bold text here'], + ['text', '粗体', 'text here.\n中文段落里有粗体'], + ['粗', '测', '粗体和测'], + ]; + for (const [from, to, expected] of cases) { + const shown = render(); + const mark = annotationOf(shown, select(shown, from, to), readLine); + assert.ok(mark, `${from}…${to} mapped`); + assert.equal(rangeOf(render(), mark, readLine)?.toString(), expected); + } + }); + + const at = (line: number, column: number) => ({ line: asRendererLine(line), column }); + + const span = (a: number, b: number): Annotation => ({ start: at(1, a), end: at(1, b) }); + + test('overlap is half-open, and a point hits the mark around it', () => { + const marks = [span(3, 8), span(12, 20)]; + assert.deepEqual(overlapping(marks, span(7, 13)), marks); + assert.deepEqual(overlapping(marks, span(8, 12)), [], 'touching ends do not overlap'); + assert.deepEqual(hitAt(marks, at(1, 3)), [marks[0]]); + assert.deepEqual(hitAt(marks, at(1, 8)), [], 'the end is outside'); + assert.deepEqual(hitAt(marks, at(2, 1)), []); + }); + + test('occurrences are the exact copies of the selected text, up to the limit', () => { + const root = document.createElement('div'); + root.innerHTML = '

cat, Cat, cat

concatenate cat

'; + const found = occurrenceRanges(root, 'cat', 100); + assert.deepEqual(found.map((range) => range.toString()), ['cat', 'cat', 'cat', 'cat'], 'case-sensitive, inside words too'); + assert.equal(occurrenceRanges(root, 'cat', 2).length, 2); + }); +}); + +describe('preview copy flavours', () => { + // #674: what a preview selection looks like once it leaves the app. The + // clipboard carries no stylesheet, so anything the preview only renders + // correctly *because* of one has to be resolved here or it arrives wrong. + + const DOC = '/Users/me/notes/report.md'; + + /** The fragment a `Range.cloneContents()` would hand over, spelled as HTML. */ + function fragmentOf(html: string): DocumentFragment { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content; + } + + test('structure survives untouched', () => { + const html = copyableHtml( + fragmentOf('

Heading

bold and italic and a link

  • one
'), + DOC, + ); + assert.match(html, /

Heading<\/h2>/); + assert.match(html, /bold<\/strong>/); + assert.match(html, /italic<\/em>/); + assert.match(html, /a link<\/a>/); + assert.match(html, /
  • one<\/li>/); + }); + + test('a formula is carried once, not twice', () => { + // KaTeX renders both halves and hides the MathML in CSS. Pasted into a + // document with no KaTeX stylesheet, both are visible — the formula reads + // twice, once as MathML text and once as the glyphs. + const katex = + '' + + 'EE=mc^2' + + '' + + ''; + const html = copyableHtml(fragmentOf(katex), DOC); + assert.doesNotMatch(html, /katex-mathml/); + assert.doesNotMatch(html, /annotation/); + // The half that stays is the one the user was looking at, and the one + // `selection.toString()` already puts in the plain-text flavour. + assert.match(html, /katex-html/); + assert.match(html, /E=mc2/); + }); + + test('the fold chevrons do not travel', () => { + const html = copyableHtml( + fragmentOf('

    Section▾

    ▾Note
    '), + DOC, + ); + assert.doesNotMatch(html, /fold-icon/); + assert.match(html, /Section/); + assert.match(html, /Note/); + }); + + test('a local image becomes a URL the receiving app can open', () => { + // `asset://` (and `http://asset.localhost` on Windows) is this app's own + // scheme; anything else pasting it shows a broken image. + const posix = copyableHtml(fragmentOf(''), DOC); + assert.match(posix, /src="file:\/\/\/Users\/me\/notes\/img\/shot\.png"/); + + const windows = copyableHtml(fragmentOf(''), DOC); + assert.match(windows, /src="file:\/\/\/C:\/notes\/img\/shot\.png"/); + }); + + test('a document-relative image is resolved against the document', () => { + const html = copyableHtml(fragmentOf(''), DOC); + assert.match(html, /src="file:\/\/\/Users\/me\/notes\/img\/diagram\.png"/); + }); + + test('a path with a space or a hash survives as a URL', () => { + // Both end a URL where they sit, so `notes #2.png` would otherwise resolve + // to `notes ` and the image would be missing rather than broken. + const html = copyableHtml(fragmentOf(''), DOC); + assert.match(html, /src="file:\/\/\/Users\/me\/my%20notes\/shot%20%232\.png"/); + }); + + test('a remote image and a data URI are left alone', () => { + const remote = copyableHtml(fragmentOf(''), DOC); + assert.match(remote, /src="https:\/\/example\.com\/a\.png"/); + + const inline = copyableHtml(fragmentOf(''), DOC); + assert.match(inline, /src="data:image\/png;base64,AAAA"/); + }); + + // ---------------------------------------------------------------- both flavours + + /** A selection over `html`, as the preview would hand one to the handler. */ + function selectionOver(html: string): Selection { + document.body.innerHTML = `
    ${html}
    `; + const range = document.createRange(); + range.selectNodeContents(document.getElementById('preview') as HTMLElement); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + selection.addRange(range); + return selection; + } + + const KATEX_INLINE = + '' + + 'EE=mc^2' + + '' + + ''; + + test('a formula copies as its source, not as the glyphs', () => { + // What `katex/contrib/copy-tex` did before this module took the copy over. + // The rendered spans read `E=mc2`, which is not the formula and cannot be + // pasted back into a Markdown document. + const { text } = copyableFlavours(selectionOver(`

    Einstein: ${KATEX_INLINE}

    `), ''); + assert.match(text, /\$E=mc\^2\$/); + assert.doesNotMatch(text, /E=mc2/); + }); + + test('a display formula keeps its display delimiters', () => { + const { text } = copyableFlavours(selectionOver(`

    ${KATEX_INLINE}

    `), ''); + assert.match(text, /\$\$E=mc\^2\$\$/); + }); + + test('a selection with no math takes its plain text from the selection itself', () => { + // `Selection.toString()` is the only source that knows what is rendered — + // `textContent` of the fragment would pick up hidden nodes and lose the line + // breaks between blocks. + const { text } = copyableFlavours(selectionOver('

    one

    two

    '), ''); + assert.match(text, /one/); + assert.match(text, /two/); + }); + + test('a soft break the preview hides is not pasted as a line break (#967)', () => { + const copy = (article: string) => { + document.body.innerHTML = `

    one
    \ntwo
    \nthree

    `; + const paragraph = document.querySelector('p') as HTMLElement; + const range = document.createRange(); + range.setStart(paragraph.firstChild as Text, 0); + range.setEnd(paragraph.lastChild as Text, 6); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + selection.addRange(range); + return copyableFlavours(selection, '').html; + }; + assert.match(copy('markdown-body'), /one
    \ntwo
    /); + assert.match(copy('markdown-body soft-breaks'), /one\ntwo
    \nthree/); + }); + + test('both flavours come back from one call', () => { + const { text, html } = copyableFlavours(selectionOver('

    bold

    '), ''); + assert.match(html, /bold<\/strong>/); + assert.match(text, /bold/); + }); +}); + +describe('preview cursor mapping', () => { + // #799's shared cursor: a click in the preview lands on a source character, + // and the editor's cursor is drawn back onto the rendered character. The HTML + // is `convert_markdown`'s real output for SOURCE, so the columns are comrak's + // UTF-8 byte offsets, which the Chinese lines exercise. + + const SOURCE = "# Title here\n\nA paragraph with **bold text** and a [link](https://example.com) here.\nSecond line with `code` and an escaped \\* star & more.\n\n中文段落里有**粗体**和测试。\n\n- item one\n- item **two**\n\n> quoted text\n\n```js\nlet x = 1;\n```\n\n| name | 值 |\n| --- | --- |\n| 甲 | beta |\n"; + + const HTML = "

    Title here

    \n

    A paragraph with bold text and a link here.
    \nSecond line with code and an escaped * star & more.

    \n

    中文段落里有粗体和测试。

    \n
      \n
    • item one
    • \n
    • item two
    • \n
    \n
    \n

    quoted text

    \n
    \n
    let x = 1;\n
    \n\n\n\n\n\n\n\n\n\n\n\n\n\n
    name值
    甲beta
    \n"; + + const sourceLines = SOURCE.split('\n'); + + const readLine = (line: number) => sourceLines[line - 1]; + + function render() { + const root = document.createElement('div'); + root.innerHTML = HTML; + return root; + } + + /** The text node and offset of the `nth` occurrence of `char` in rendered text under `selector`. */ + function charIn(root: Element, selector: string, char: string, nth = 0) { + const walker = document.createTreeWalker(root.querySelector(selector)!, NodeFilter.SHOW_TEXT); + let seen = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let offset = 0; offset < node.data.length; offset++) { + if (node.data[offset] === char && seen++ === nth) return { node, offset }; + } + } + throw new Error(`no ${char} under ${selector}`); + } + + const at = (line: number, column: number): SourcePoint => ({ line: asRendererLine(line), column }); + + test('a click lands on the source character it rendered', () => { + const root = render(); + const cases: Array<[string, string, SourcePoint, number?]> = [ + ['h1', 'T', at(1, 3)], + ['p strong', 'b', at(3, 20)], + ['p a', 'l', at(3, 39)], + ['p code', 'c', at(4, 19)], + ['p', '*', at(4, 41)], + ['p', '&', at(4, 48)], + ['p:nth-of-type(2)', '粗', at(6, 9)], + ['p:nth-of-type(2)', '测', at(6, 14)], + ['li', 'o', at(8, 8)], + ['li:nth-of-type(2) strong', 't', at(9, 10)], + ['blockquote p', 'q', at(11, 3)], + ['pre', 'x', at(14, 5)], + ['td', '甲', at(19, 3)], + ['td:nth-of-type(2)', 'b', at(19, 7)], + ]; + for (const [selector, char, expected] of cases) { + assert.deepEqual(sourceAtPoint(root, charIn(root, selector, char), readLine), expected, `${char} in ${selector}`); + assert.equal(sourceLines[expected.line - 1][expected.column - 1], char, `${char} is the source character there`); + } + }); + + test('every rendered character goes to the source and back to itself', () => { + const root = render(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let checked = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let offset = 0; offset < node.data.length; offset++) { + if (/\s/.test(node.data[offset]) || !node.parentElement?.closest('[data-sourcepos]')) continue; + const source: SourcePoint = sourceAtPoint(root, { node, offset }, readLine)!; + const back: DomPoint = pointAtSource(root, source, readLine)!; + assert.equal(back.node, node, `"${node.data[offset]}" came back in another node`); + assert.equal(back.offset, offset, `"${node.data[offset]}" came back at another offset`); + checked++; + } + } + assert.ok(checked > 130, `the fixture was walked (${checked})`); + }); + + test('a fenced block pairs its code, not its info string', () => { + // `js` on the fence would take the first two characters of `js = 1`. + const root = document.createElement('div'); + root.innerHTML = '
    js = 1;\n
    '; + const lines = ['```js', 'js = 1;', '```']; + const point = sourceAtPoint(root, charIn(root, 'pre', 's'), (line) => lines[line - 1]); + assert.deepEqual(point, at(2, 2)); + }); + + test('a point inside KaTeX lands just after the formula', () => { + const root = document.createElement('div'); + root.innerHTML = '

    sum x2x^2 done

    '; + const katex = root.querySelector('.katex')!; + const point = sourceAtPoint(root, { node: katex.firstChild!, offset: 1 }, () => 'sum $x^2$ done'); + assert.deepEqual(point, at(1, 10), 'on the space after `$x^2$`'); + }); +}); + +describe('preview patch', () => { + /** + * The block patch, from the renderer's split output to the live article. + * `preview.spec.ts` holds it to the whole-document pipeline over a + * real document; these pin which nodes it keeps, which it replaces, and what + * it hands on, one edit at a time. + */ + + let host: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); + }); + + const patch = (html: string, folds = new Set()) => patchPreview(host, html, '/docs/a.md', folds); + + const split = (...parts: string[]) => parts.join('\0\n'); + + const p = (line: number, text: string) => `

    ${text}

    `; + + const h = (level: number, line: number, text: string, id = text.toLowerCase()) => + `${text}`; + + const bodies = () => Array.from(host.querySelectorAll('p')) as HTMLElement[]; + + describe('the first render', () => { + test('inserts every block, folds each section under its heading, and hands all of it on', () => { + const result = patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.className)).toEqual(['foldable-header', 'foldable-content-wrapper']); + expect(host.querySelectorAll('.foldable-content-wrapper > .content-inner > p').length).toBe(2); + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1', 'P', 'P']); + expect(result.wrappers.length).toBe(1); + }); + + test('carries no whitespace between blocks, so children and childNodes stay one list', () => { + patch(split(p(1, 'a'), p(3, 'b')) + '\n'); + expect(host.childNodes.length).toBe(host.children.length); + }); + }); + + describe('raw HTML that breaks out of the batch a first render parses blocks in', () => { + test('is rendered block by block instead, with nothing attributed to the wrong block', () => { + const forged = '
    a

    b

    '; + const result = patch(split(p(1, 'alpha'), forged, p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alpha', 'a', 'b', 'beta']); + expect(result.inserted.length).toBe(4); + + patch(split(p(1, 'alphaX'), forged, p(5, 'beta'))); + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alphaX', 'a', 'b', 'beta']); + }); + }); + + describe('typing one character in the middle of a document', () => { + const before = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'), p(7, 'gamma')); + const after = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'betaX'), p(7, 'gamma')); + + test('replaces exactly that block, in place, and keeps every other node', () => { + patch(before); + const [alpha, beta, gamma] = bodies(); + const heading = host.querySelector('h1'); + const wrapper = host.querySelector('.foldable-content-wrapper'); + + const result = patch(after); + + const [alphaAfter, betaAfter, gammaAfter] = bodies(); + expect(alphaAfter).toBe(alpha); + expect(gammaAfter).toBe(gamma); + expect(betaAfter).not.toBe(beta); + expect(betaAfter.textContent).toBe('betaX'); + expect(host.querySelector('h1')).toBe(heading); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(result.inserted).toEqual([betaAfter]); + expect(result.wrappers).toEqual([]); + }); + }); + + describe('inserting a line above everything', () => { + test('keeps the blocks that only moved and gives them their new source ranges', () => { + patch(split(p(3, 'alpha'), `
      \n
    • item
    • \n
    `)); + const [alpha] = bodies(); + const item = host.querySelector('li')!; + + const result = patch(split(p(4, 'alpha'), `
      \n
    • item
    • \n
    `)); + + expect(result.inserted).toEqual([]); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('li')).toBe(item); + expect(alpha.getAttribute('data-sourcepos')).toBe('4:1-4:5'); + // The task checkbox toggle writes to the line it reads here. + expect(item.getAttribute('data-sourcepos')).toBe('6:1-6:9'); + }); + + test('moves the line anchors this side added by the same amount', () => { + const long = (line: number) => + `

    a
    \nb
    \nc

    `; + patch(long(1)); + const anchors = () => Array.from(host.querySelectorAll('.source-line-anchor')).map((a) => a.getAttribute('data-sourcepos')); + expect(anchors()).toEqual(['2:1-2:1', '3:1-3:1']); + + patch(long(4)); + expect(anchors()).toEqual(['5:1-5:1', '6:1-6:1']); + }); + }); + + describe('headings', () => { + test('typing in one replaces the heading and keeps its wrapper and everything in it', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'))); + const [alpha] = bodies(); + const wrapper = host.querySelector('.foldable-content-wrapper')!; + + const result = patch(split(h(1, 1, 'Titles'), p(3, 'alpha'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1']); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(host.querySelector('h1')!.getAttribute('data-fold-target')).toBe(wrapper.id); + }); + + test('renaming one of many identical ones replaces the renumbered headings and nothing else', () => { + const sections = (names: string[]) => { + const seen = new Map(); + return split( + ...names.flatMap((name, index) => { + const slug = name.toLowerCase(); + const n = seen.get(slug) ?? 0; + seen.set(slug, n + 1); + return [h(2, index * 4 + 1, name, n ? `${slug}-${n}` : slug), p(index * 4 + 3, `body ${index}`)]; + }), + ); + }; + patch(sections(['Objectives', 'Objectives', 'Objectives'])); + const kept = bodies(); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + + const result = patch(sections(['Goals', 'Objectives', 'Objectives'])); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2', 'H2', 'H2']); + expect(bodies()).toEqual(kept); + bodies().forEach((body, index) => expect(body).toBe(kept[index])); + expect(Array.from(host.querySelectorAll('.foldable-content-wrapper'))).toEqual(wrappers); + expect(Array.from(host.querySelectorAll('h2')).map((e) => e.id)).toEqual(['goals', 'objectives', 'objectives-1']); + }); + + test('a new section regroups the blocks after it under the new heading, without rebuilding them', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), p(5, 'beta'))); + const [alpha, beta] = bodies(); + + const result = patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2']); + expect(result.wrappers.length).toBe(2); + const sections = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(sections[0].contains(alpha)).toBe(true); + expect(sections[1].contains(beta)).toBe(true); + }); + + test('a new subsection leaves the sections around it where they were', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Three'), p(11, 'gamma'))); + const [one, twoBody, three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + two.nextElementSibling!.classList.add('is-collapsed'); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(3, 9, 'Sub'), p(11, 'x'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[1]).toBe(twoBody); + expect(wrappers[wrappers.length - 1]).toBe(three); + // Only the new subsection is wrapped: the section around it ends where it did. + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['sub']); + // The section the reader closed is still closed, and now holds the new one. + expect(two.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + expect(two.nextElementSibling!.querySelector('#sub')).not.toBeNull(); + }); + + test('a new section of the same level re-wraps only the section it splits', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), p(9, 'b2'), h(2, 11, 'Three'), p(13, 'gamma'))); + const [one, , three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const kept = bodies(); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Mid'), p(11, 'b2'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['two', 'mid']); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[3]).toBe(three); + expect(bodies().every((body, index) => body === kept[index])).toBe(true); + expect(wrappers[2].textContent).toBe('b2'); + }); + + test('a new paragraph right under a heading goes into that heading\'s section, first', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(h(2, 1, 'One'), p(3, 'new'), p(5, 'alpha'))); + + const inner = host.querySelector('.content-inner')!; + expect(Array.from(inner.children).map((e) => e.textContent)).toEqual(['new', 'alpha']); + }); + + test('a new paragraph above the first heading stays outside every section', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(p(1, 'lead'), h(2, 3, 'One'), p(5, 'alpha'))); + + expect(host.firstElementChild!.textContent).toBe('lead'); + }); + }); + + describe('folds', () => { + const doc = split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta')); + + test('a fold the document is told to keep closed is closed on the first render', () => { + patch(doc, new Set(['two'])); + expect(host.querySelector('#two')!.classList.contains('is-collapsed')).toBe(true); + expect(host.querySelector('#two')!.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + }); + + test('a fold the reader closed stays closed through an edit somewhere else', () => { + patch(doc); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + + patch(doc.replace('>alpha<', '>alphaX<')); + + expect(host.querySelector('#two')).toBe(two); + expect(two.classList.contains('is-collapsed')).toBe(true); + }); + + test('callouts with the same title get keys of their own', () => { + const callout = (line: number) => + `
    \n

    [!note]- Same
    \nx

    \n
    `; + patch(split(callout(1), callout(4))); + expect(Array.from(host.querySelectorAll('.callout-foldable')).map((e) => e.getAttribute('data-fold-key'))).toEqual([ + 'callout:Same', + 'callout:Same~1', + ]); + }); + }); + + describe('blocks that renderRichContent replaced', () => { + const pre = (line: number) => `
    x\n
    `; + + /** What the code-block pass does: a new parent, recorded for the patch. */ + function shell(element: Element): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'code-block-shell'; + element.replaceWith(wrapper); + wrapper.appendChild(element); + replacedBy.set(element, wrapper); + return wrapper; + } + + test('are kept through an edit elsewhere, and removed whole when their block goes', () => { + patch(split(pre(1), p(5, 'alpha'))); + const wrapper = shell(host.querySelector('pre')!); + + patch(split(pre(1), p(5, 'alphaX'))); + expect(host.firstElementChild).toBe(wrapper); + + patch(split(p(1, 'alphaX'))); + expect(host.querySelector('.code-block-shell')).toBeNull(); + expect(host.children.length).toBe(1); + }); + + test('get their new source range through the node that replaced them', () => { + patch(split(pre(1))); + const original = host.querySelector('pre')!; + const diagram = document.createElement('div'); + diagram.className = 'mermaid-diagram'; + diagram.setAttribute('data-sourcepos', original.getAttribute('data-sourcepos')!); + original.replaceWith(diagram); + replacedBy.set(original, diagram); + + patch(split(pre(2))); + + expect(host.firstElementChild).toBe(diagram); + expect(diagram.getAttribute('data-sourcepos')).toBe('2:1-4:3'); + }); + }); + + /** + * The memos in `previewAnchor.ts` assume a node whose source lines changed is a + * new node. A kept node with rewritten ranges is not, so the patch has to drop + * them — shown by asking `previewAnchor` its own questions across a patch. + * jsdom has no layout, so the boxes are supplied. + */ + describe('the source ranges previewAnchor has already read', () => { + test('follow a range rewritten on a node that was kept', () => { + patch(split(p(1, 'alpha'), p(3, 'beta'))); + const [alpha, beta] = bodies(); + const boxes = new Map([ + [alpha, { top: 0, height: 100 }], + [beta, { top: 100, height: 100 }], + ]); + const measure = (node: AnchorNode): AnchorBox => boxes.get(node) ?? { top: NaN, height: NaN }; + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(100); + + patch(split(p(3, 'alpha'), p(5, 'beta'))); + + expect(bodies()).toEqual([alpha, beta]); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(0); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(5), measure)).toBe(100); + }); + + test('follow kept blocks inside a kept section', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + expect(findAnchorElement(host, 5)?.element).toBe(bodies()[1]); + + patch(split(h(1, 1, 'Title'), p(8, 'alpha'), p(10, 'beta'))); + + expect(findAnchorElement(host, 10)?.element).toBe(bodies()[1]); + expect(findAnchorElement(host, 8)?.element).toBe(bodies()[0]); + }); + }); + + describe('an article somebody else rewrote', () => { + test('is rebuilt rather than diffed against a stale record', () => { + patch(split(p(1, 'a'), p(3, 'b'))); + host.appendChild(document.createElement('p')); + + const result = patch(split(p(1, 'a'), p(3, 'b'))); + + expect(result.inserted.length).toBe(2); + expect(host.children.length).toBe(2); + }); + }); +}); + +describe('preview patch parity', () => { + /** + * The block patch against the whole-document pipeline it replaced, over the + * renderer's real output for samples/markdown-syntax.md (every construct the + * preview has: callouts, folds, footnotes, math, Mermaid, task lists, raw + * `
    ` around Markdown). `preview_block_fixture_is_current` in + * markdown.rs keeps the fixture in step with the renderer. + * + * Three properties: + * - a first render is the document the old pipeline produced; + * - after an edit, the patched article is what a first render of the edited + * document would be — the incremental path loses nothing; + * - undoing the edit puts the original document back. + */ + + const SPLIT = blocks.join('\0'); + + const PATH = '/docs/markdown-syntax.md'; + + /** `wrap-N` ids only have to be unique; the two paths hand them out in a different order. */ + const normalized = (host: Element) => host.innerHTML.replace(/wrap-\d+/g, 'wrap-N'); + + function oldPipeline(split: string): string { + const host = document.createElement('div'); + host.append(sanitizeMarkdownFragment(processMarkdownHtml(split.replaceAll('\0\n', ''), PATH, new Set()))); + // The old patch carried elements only, never the whitespace between them. + for (const node of Array.from(host.childNodes)) if (node.nodeType !== 1) node.remove(); + return normalized(host); + } + + function cold(split: string): string { + const host = document.createElement('div'); + patchPreview(host, split, PATH, new Set()); + return normalized(host); + } + + /** Every `data-sourcepos` line moved down by one: a line inserted at the top. */ + const enterAtTop = (split: string) => + split.replace(/data-sourcepos="(\d+):(\d+)-(\d+):(\d+)"/g, (_, a, b, c, d) => `data-sourcepos="${+a + 1}:${b}-${+c + 1}:${d}"`); + + const at = (split: string, index: number, edit: (block: string) => string) => { + const parts = split.split('\0'); + parts[index] = edit(parts[index]); + return parts.join('\0'); + }; + + const firstIndex = (pattern: RegExp) => blocks.findIndex((block) => pattern.test(block)); + + const middleParagraph = blocks.findIndex((block, index) => index > blocks.length / 2 && /^\n?

    /); + + const edits: Record string> = { + 'a keystroke in a paragraph': (split) => at(split, middleParagraph, (b) => b.replace('

    ', 'x

    ')), + 'a line inserted at the top': enterAtTop, + 'a renamed heading': (split) => at(split, firstH2, (b) => b.replace(/(

    ]*>)/, '$1Renamed ')), + 'a new paragraph': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    new

    \n`), + 'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    Added

    \n`), + 'a deleted heading': (split) => at(split, firstH2, () => ''), + 'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    Sub

    \n`), + 'a new top-level heading': (split) => at(split, firstH2, (b) => `

    Top

    \n\0${b}`), + 'a heading changed level': (split) => at(split, firstH2, (b) => b.replace(/^(\n?)

    /, '

    ')), + 'an edited diagram': (split) => at(split, mermaid, (b) => b.replace('', 'C --> D\n
    ')), + 'an edited raw HTML block': (split) => at(split, details, (b) => b.replace('', ' edited')), + }; + + let host: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); + }); + + test('the fixture has the constructs the edits below aim at', () => { + expect([middleParagraph, firstH2, mermaid, details].every((index) => index > 0)).toBe(true); + }); + + test('a first render is what the whole-document pipeline produced', () => { + expect(cold(SPLIT)).toBe(oldPipeline(SPLIT)); + }); + + describe.each(Object.entries(edits))('%s', (_, edit) => { + test('patches to what a first render of the edited document is', () => { + const edited = edit(SPLIT); + expect(edited).not.toBe(SPLIT); + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edited, PATH, new Set()); + expect(normalized(host)).toBe(cold(edited)); + }); + + test('and back to the original when it is undone', () => { + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edit(SPLIT), PATH, new Set()); + patchPreview(host, SPLIT, PATH, new Set()); + expect(normalized(host)).toBe(cold(SPLIT)); + }); + }); +}); + +/** + * What the shared sanitizer actually does to an author stylesheet — run, not + * described. + * + * `preview.checks.test.ts` pins the WIRING: that the preview reaches the + * shared policy, that the policy forbids ` + * + * and the observable is the same one: the preview injects `tab.content` into + * the APPLICATION'S OWN document, so a stylesheet that survives the filter is + * not scoped to the article — it restyles Markpad. `.titlebar` is a real + * element of the app's chrome, and `getComputedStyle` is what answers whether + * the document author moved it. + * + * The `background-image` half is the beacon: in a browser the computed value + * being a URL is followed by a request for it (the comment recorded the entry + * in `performance.getEntriesByType('resource')`, and the shipped CSP allows it + * — `img-src … https:`). jsdom does not fetch it. The computed value is the + * part that is a property of the sanitizer rather than of the network stack, + * and it is the part asserted here. + * + * WHY THE "BEFORE" ROW IS ASSERTED TOO: without it "the `'; + + /** + * A rendered document carrying it. + * + * The leading paragraph is not decoration. DOMPurify serializes its parse + * tree's ``, and the HTML parser puts a `'; + document.body.innerHTML = '
    '; + return { + titlebar: document.querySelector('.titlebar')!, + preview: document.querySelector('.markdown-body')!, + }; + } + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + test('the config the preview used to build lets a document restyle the application', () => { + const { titlebar, preview } = mountApp(); + assert.equal(getComputedStyle(titlebar).display, 'flex', 'precondition: the app decides where its chrome is'); + + const sanitized = DOMPurify.sanitize(RENDERED, VIEWER_LOCAL_CONFIG); + assert.match(sanitized, / - * - * and the observable is the same one: the preview injects `tab.content` into - * the APPLICATION'S OWN document, so a stylesheet that survives the filter is - * not scoped to the article — it restyles Markpad. `.titlebar` is a real - * element of the app's chrome, and `getComputedStyle` is what answers whether - * the document author moved it. - * - * The `background-image` half is the beacon: in a browser the computed value - * being a URL is followed by a request for it (the comment recorded the entry - * in `performance.getEntriesByType('resource')`, and the shipped CSP allows it - * — `img-src … https:`). jsdom does not fetch it. The computed value is the - * part that is a property of the sanitizer rather than of the network stack, - * and it is the part asserted here. - * - * WHY THE "BEFORE" ROW IS ASSERTED TOO: without it "the `'; - -/** - * A rendered document carrying it. - * - * The leading paragraph is not decoration. DOMPurify serializes its parse - * tree's ``, and the HTML parser puts a `'; - document.body.innerHTML = '
    '; - return { - titlebar: document.querySelector('.titlebar')!, - preview: document.querySelector('.markdown-body')!, - }; -} - -afterEach(() => { - document.head.innerHTML = ''; - document.body.innerHTML = ''; -}); - -test('the config the preview used to build lets a document restyle the application', () => { - const { titlebar, preview } = mountApp(); - assert.equal(getComputedStyle(titlebar).display, 'flex', 'precondition: the app decides where its chrome is'); - - const sanitized = DOMPurify.sanitize(RENDERED, VIEWER_LOCAL_CONFIG); - assert.match(sanitized, /'), + ); + + /** Every class the component's own stylesheet writes a rule for. */ + const styledClasses = new Set( + [...settingsStyle.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((match) => match[1]), + ); + + /** + * The control classes `.setting-item`'s row layout sizes. Each is sized on the + * assumption that it is a flex item of the row, so each must be a direct child + * of one. + */ + const ROW_SIZED_CONTROLS = ['select-wrapper', 'slider-container', 'toggle', 'custom-dropdown-wrapper']; + + type Row = { children: { tag: string; classes: string[] }[]; nested: string[]; label: string }; + + /** Every `.setting-item` in Settings.svelte, with its direct children. */ + function settingRows(): Row[] { + const ast = parseComponent(componentSource, 'src/lib/components/Settings.svelte'); + const rows: Row[] = []; + + const classesOf = (node: Record): string[] => { + const out: string[] = []; + for (const attribute of node.attributes ?? []) { + if (attribute.type === 'ClassDirective') out.push(attribute.name); + if (attribute.type !== 'Attribute' || attribute.name !== 'class') continue; + const parts = Array.isArray(attribute.value) ? attribute.value : [attribute.value]; + for (const part of parts) if (part.type === 'Text') out.push(...String(part.data).split(/\s+/).filter(Boolean)); + } + return out; + }; + + /** Elements below `node`, skipping `{#if}`/`{#each}` wrappers, to `depth`. */ + const elementsUnder = (node: unknown, depth: number, out: { node: Record; depth: number }[] = []) => { + if (Array.isArray(node)) { + for (const child of node) elementsUnder(child, depth, out); + return out; + } + if (!node || typeof node !== 'object') return out; + const current = node as Record; + if (current.type === 'RegularElement') { + out.push({ node: current, depth }); + elementsUnder(current.fragment, depth + 1, out); + return out; + } + // Control-flow blocks do not add a DOM level. + for (const key of ['fragment', 'nodes', 'consequent', 'alternate', 'body']) { + if (current[key]) elementsUnder(current[key], depth, out); + } + return out; + }; + + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (!node || typeof node !== 'object') return; + const current = node as Record; + + if (current.type === 'RegularElement' && classesOf(current).includes('setting-item')) { + const descendants = elementsUnder(current.fragment, 1); + const direct = descendants.filter((entry) => entry.depth === 1); + rows.push({ + label: direct[0] ? textOf(direct[0].node) : '(empty row)', + children: direct.map((entry) => ({ tag: entry.node.name, classes: classesOf(entry.node) })), + nested: descendants + .filter( + (entry) => + entry.depth > 1 && + classesOf(entry.node).some((name) => ROW_SIZED_CONTROLS.includes(name)) && + // `.number-input-wrapper` legitimately lives inside + // `.slider-container`, which is itself a direct child. + !parentIsSizedControl(descendants, entry), + ) + .map((entry) => `${entry.node.name}.${classesOf(entry.node).join('.')}`), + }); + } + + for (const [key, value] of Object.entries(current)) { + if (key === 'parent' || key === 'metadata') continue; + walk(value); + } + }; + + walk((ast as Record).fragment); + return rows; + } + + function parseComponent(text: string, filename: string): unknown { + return parse(text, { modern: true, filename }); + } + + function textOf(node: Record): string { + const first = (node.fragment?.nodes ?? []).find((child: any) => child.type === 'ExpressionTag' || child.type === 'Text'); + if (!first) return node.name; + return componentSource.slice(first.start, first.end).trim().slice(0, 48); + } + + /** True when `entry`'s nearest element ancestor is itself a sized control. */ + function parentIsSizedControl( + all: { node: Record; depth: number }[], + entry: { node: Record; depth: number }, + ): boolean { + const index = all.indexOf(entry); + for (let i = index - 1; i >= 0; i--) { + if (all[i].depth !== entry.depth - 1) continue; + const classes = (all[i].node.attributes ?? []) + .filter((a: any) => a.type === 'Attribute' && a.name === 'class' && Array.isArray(a.value)) + .flatMap((a: any) => a.value.filter((v: any) => v.type === 'Text').flatMap((v: any) => String(v.data).split(/\s+/))); + return classes.some((name: string) => ROW_SIZED_CONTROLS.includes(name)); + } + return false; + } + + const rows = settingRows(); + + test('every settings row was found', () => { + // Vacuous-pass guard: the two assertions below say nothing if the walk + // stopped finding rows. + assert.ok(rows.length > 25, `found ${rows.length} .setting-item rows`); + assert.ok(styledClasses.has('setting-item') && styledClasses.has('select-wrapper')); + }); + + test('a settings row leads with the label that names its control', () => { + const wrong = rows + .filter((row) => row.children[0]?.tag !== 'label') + .map((row) => `${row.label} leads with <${row.children[0]?.tag}>`); + assert.deepEqual(wrong, [], 'a row whose first child is not the