diff --git a/AGENTS.md b/AGENTS.md index bce678f9..0266ba44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,11 +161,16 @@ All file operations go through Rust commands - never use Node.js fs APIs. - **CI**: Runs `npm audit`, `npm run check`, `npm test`, `npm run test:vitest` and `cargo test` on PRs -Two kinds of assertion live in this suite: behavior tests, which import and run the real -modules, and source-shape assertions, which match the source text for a contract the -compiler cannot check — a Tauri command name, an i18n key, a second copy of a fixed -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. +Tests run the real code and assert what it does. A test does not read source as text — +no regex over a file, no slicing a function body, no cutting code out of a component and +evaluating it: such a test guards a spelling, goes red on a rename and stays green on a +regression. When the logic sits in a component where a test cannot call it, move it into a +`.ts` or `.svelte.ts` module the component calls, and test the module. + +The `*.checks.*` files are the one exception, and are meant to shrink: they hold contract +checks (a Tauri command or event name, a config key, an i18n key — the spelling that joins +Rust and TypeScript) and convention checks ("one implementation of this behavior"), until +those move to generated bindings and lint rules. ### Where a test goes @@ -198,22 +203,11 @@ what an effect writes cannot fail. Under vitest the module is compiled for real **Leave it as `*.test.ts` (`node --test`) otherwise.** Plain TypeScript modules run fine there, the runner is already configured, and moving a passing file buys nothing. -**Do not migrate `.svelte` component tests.** Mounting a component here means jsdom -standing in for Monaco, mermaid and KaTeX, and the assertion it earns — "the handler is -wired up" — is weaker than the source-shape assertion it replaced, not stronger. - -### What must never be migrated - -Two categories are correct *because* they are text assertions, and a runtime test cannot -express them at all: - -- **Cross-language contracts.** A `#[tauri::command]` name, an event name, a config key: one - side is Rust and the other is TypeScript, and nothing but the spelling connects them. - Running the TypeScript proves the TypeScript. -- **Single-implementation conventions.** "There is exactly one copy of this behavior", "no - other caller passes this flag", "this constant is not hard-coded a second time". These are - claims about the *absence* of a second implementation. Executing the one implementation - that exists cannot observe a second one; only reading the source can. +**Mount a component only for its markup wiring, and only one that does not need Monaco, +mermaid or KaTeX.** The suite mounts `Toc`, `HomePage`, `Settings`, `TitleBar`, `Tab`, +`UpdateDialog` and `ContextMenu` this way (vitest, jsdom; stub `Element.prototype.animate` +for transitions). `MarkdownViewer.svelte` and `Editor.svelte` are not mounted: their logic is +tested through the modules they call. ### Migrating a file diff --git a/samples/stress-test.md b/samples/stress-test.md index 0fa6a689..39db8f0a 100644 --- a/samples/stress-test.md +++ b/samples/stress-test.md @@ -505,8 +505,8 @@ the preprocessing passes look for. The one dimension this file cannot carry is line endings — it is LF throughout, and the original was CRLF. That half is covered by -`scripts/taskToggleLineEndings.test.ts`, which drives both endings through the -real write-back. +`scripts/documents.save.spec.ts` ("task toggle line endings"), which drives +both endings through the real write-back. ## Task-Like Text That Is Not A Task diff --git a/scripts/documents.checks.spec.ts b/scripts/documents.checks.spec.ts deleted file mode 100644 index 804a3a50..00000000 --- a/scripts/documents.checks.spec.ts +++ /dev/null @@ -1,1071 +0,0 @@ -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 index be56917e..af7aedd7 100644 --- a/scripts/documents.checks.test.ts +++ b/scripts/documents.checks.test.ts @@ -1,16 +1,9 @@ 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, @@ -79,60 +72,12 @@ describe('checked read migration', () => { 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. + // The eligibility rule runs in documents.test.ts "auto-save debounce". What + // is left: "Save As" to a new file forgets that the refusal was said, so a + // later lossy load into the same tab is explained again. assert.match( readSource('src/lib/sessions/documentSession.svelte.ts'), /lossySaveWarnedTabs\.delete\(tab\.id\)/, @@ -161,224 +106,27 @@ describe('checked read migration', () => { // 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\)/); + test('the refusal names Save As, the only exit', () => { + // i18n wording. That the session raises this message once per tab is + // driven in documents.session.spec.ts. 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 viewer = readSource('src/lib/sessions/windowExit.ts'); 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}`); @@ -392,64 +140,20 @@ describe('live mode watched path', () => { const viewer = readSource('src/lib/MarkdownViewer.svelte'); test('a watcher notification is routed to its watched path', () => { + // The event name across Rust and TypeScript. What the viewer does with the + // event runs in documents.conflicts.spec.ts (handleFileChanged). 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\)/); + assert.match(viewer, /await appWindow\.listen\('file-changed', \(event\) => externalChange\.handleFileChanged\(event\.payload as string, liveMode\)\)/); }); - 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'); - + test('toggling Live Mode does not reload the document', () => { + // The watcher half of this case is driven in documents.session.spec.ts + // (fileWatch.svelte.ts). The toggle is one assignment inside the viewer. 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: { @@ -491,22 +195,12 @@ describe('file associations', () => { // 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. + // The list itself runs in documents.test.ts. What stays is that the viewer's + // Open dialog uses it: a literal list is how the two drifted apart. + assert.match(viewer, /filters: MARKDOWN_OPEN_FILTERS,/); 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/); - }); }); /** @@ -539,10 +233,7 @@ describe('file associations', () => { * 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'/); @@ -556,74 +247,12 @@ describe('open file mode', () => { 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, /
'; + for (const target of root.querySelectorAll('span, textarea')) { + assert.equal(contextMenuFor(target, { modalOpen: false, inApp: true }), 'editor', target.tagName); + } + }); + + /** A pane that records which of its functions ran. */ + function recordingPane(): { pane: EditorMenuPane; calls: string[] } { + const calls: string[] = []; + const record = (name: string) => () => void calls.push(name); + return { + calls, + pane: { + undo: record('undo'), + redo: record('redo'), + cutToClipboard: record('cutToClipboard'), + copyToClipboard: record('copyToClipboard'), + pasteFromClipboard: record('pasteFromClipboard'), + selectAll: record('selectAll'), + runEditorAction: (id) => void calls.push(`runEditorAction:${id}`), + }, + }; + } + + const click = (items: ReturnType, label: string) => { + const item = items.find((entry) => entry.label === label); + assert.ok(item?.onClick, `no "${label}" item`); + item.onClick(); + }; + + test('the editor menu runs the same three functions the keyboard runs', () => { + // One implementation per operation is the whole point: six entry points + // (three keys, three menu items) and three functions. A menu item wired to + // its own inline implementation would drift from the shortcut silently. + const { pane, calls } = recordingPane(); + const items = editorContextMenuItems(() => pane, 'en', 'macos'); + + click(items, 'Cut'); + click(items, 'Copy'); + click(items, 'Paste'); + + assert.deepEqual(calls, ['cutToClipboard', 'copyToClipboard', 'pasteFromClipboard']); + }); + + test('the two Monaco entries this app can use survive drawing our own menu', async () => { + // Drawing the menu ourselves means every item Monaco used to contribute is + // gone unless it is put back, and two of them work without a language + // provider — so they were there, and vanished, and were noticed only + // because someone went looking for them. + // + // The rest (Go to Symbol, Quick Fix, Refactor, Format, Rename) are gated on + // providers Markdown has none of and never appeared in this app. + // + // Through the app's own translations, which is a gain rather than parity: + // Monaco's menu is English whatever language the app is in. + await loadLocale('zh-TW'); + const { pane, calls } = recordingPane(); + const items = editorContextMenuItems(() => pane, 'zh-TW', 'windows'); + + click(items, t('menu.commandPalette', 'zh-TW')); + click(items, t('menu.changeAllOccurrences', 'zh-TW')); + + assert.notEqual(t('menu.commandPalette', 'zh-TW'), 'Command Palette'); + assert.deepEqual(calls, ['runEditorAction:editor.action.quickCommand', 'runEditorAction:editor.action.changeAll']); + }); +}); diff --git a/scripts/editor.test.ts b/scripts/editor.test.ts index 74390bbb..c0f72ca5 100644 --- a/scripts/editor.test.ts +++ b/scripts/editor.test.ts @@ -17,14 +17,17 @@ import { getSupportedLanguages, type LanguageCode, t, type Translation } from '. import { translations } from './allTranslations.js'; import { editorLanguageFor } from '../src/lib/utils/editorLanguage.js'; import { MARKDOWN_LINK_EXTENSIONS } from '../src/lib/utils/markdownLinks.js'; -import { editorOptionsFromSettings } from '../src/lib/utils/editorOptions.js'; +import { createdEditorOptions, editorOptionsFromSettings, editorReadOnly, updatedEditorOptions } from '../src/lib/utils/editorOptions.js'; import { markdownTokenRules, monacoThemeName, semanticTokenRules } from '../src/lib/utils/editorTheme.js'; import { importedThemeRules, MARKDOWN_SCOPE_ALIASES, markdownKindColors, monacoTokenRules } from '../src/lib/utils/theme.js'; import { encodeSemanticTokens, TOKEN_MODIFIERS, TOKEN_TYPES } from '../src/lib/utils/semanticTokens.js'; import { resolveExportImagePath } from '../src/lib/utils/exportHtml.js'; import { DEFAULT_IMAGE_DIRECTORY, documentParentDir, encodeImageDestination, imageEmbed, resolveImageDirectory } from '../src/lib/utils/imageEmbed.js'; import { resolveDocumentRelativePath } from '../src/lib/utils/markdown.js'; -import { buildPasteProbe, findTokenAtOffset, getFrontMatterLineCount, isCodeToken, isInFrontMatter, type PasteContextToken, shouldLinkifyPastedUrl } from '../src/lib/utils/pasteContext.js'; +import { buildPasteProbe, findTokenAtOffset, getFrontMatterLineCount, isCodeToken, isInFrontMatter, isLinkifyPasteTarget, type PasteContextToken, shouldLinkifyPastedUrl } from '../src/lib/utils/pasteContext.js'; +import { clipboardTextForSelection, type EditingEditor, insertLink, insertTextAtCursor, wrapAsCodeBlock } from '../src/lib/utils/editorEdits.js'; +import { mountAfterLoad } from '../src/lib/utils/editorMount.js'; +import { pasteFromClipboard } from '../src/lib/utils/editorClipboard.js'; import { paragraphAround } from '../src/lib/utils/focusParagraph.js'; // WHAT THIS FILE COVERS, AND WHAT IT DOES NOT @@ -190,7 +193,9 @@ describe('editor options', () => { */ function effectiveUnicodeHighlight(passed: unknown): UnicodeHighlightOptions { const option = EditorOptions.unicodeHighlight; - return option.applyUpdate(option.defaultValue, passed ?? {}).newValue; + // A copy: Monaco's `applyUpdate` merges in place unless the update carries + // `allowedCharacters`, and the shipped defaults are one shared object. + return option.applyUpdate(structuredClone(option.defaultValue), passed ?? {}).newValue; } /** @@ -424,6 +429,166 @@ describe('editor options', () => { assert.ok(on.cursorSurroundingLines! > 1000, 'more lines than any screen, which Monaco caps at half the viewport'); assert.equal(on.cursorSurroundingLinesStyle, 'all'); }); + + /** What `monaco.editor.create` gets for a settings store, on a whole buffer. */ + function created(settings: Partial = {}) { + return createdEditorOptions({ ...SETTINGS, ...settings }, 100, false); + } + + test('an option a setting can change is one `updateOptions` re-applies', () => { + // `smoothScrolling` used to be a bare `true` in the creation literal, where + // options that are set once and never re-applied live. Left there, the + // toggle would have taken effect only on a newly created editor — the same + // shape as the `fontSize` divergence #369 removed. So: change each setting, + // and every option that moves at creation has to move the same way in what + // the update effect re-applies to the open editor. + const OTHER: Record = { wordWrap: 'off', lineNumbers: 'off', renderLineHighlight: 'none', editorFont: 'Georgia' }; + for (const [field, value] of Object.entries(SETTINGS)) { + const changed = { ...SETTINGS, [field]: typeof value === 'boolean' ? !value : typeof value === 'number' ? value + 6 : OTHER[field] }; + const before = created(); + const after = created(changed); + const reapplied = updatedEditorOptions(changed, 100, true, false) as Record; + const moved = Object.keys(after).filter((key) => JSON.stringify(Reflect.get(after, key)) !== JSON.stringify(Reflect.get(before, key))); + assert.ok(moved.length > 0, `${field} changes some option`); + for (const key of moved) { + assert.deepEqual(reapplied[key], Reflect.get(after, key), `changing ${field} re-applies ${key} to an open editor`); + } + } + }); + + test('renderLineHighlight is a Monaco string enum, not a boolean flag', () => { + // Any non-empty string is truthy, so a ternary on the stored 'line' / 'none' + // can only ever produce "line" — which defeats both the line-highlight + // toggle and Zen mode. Both stored values have to survive the trip, at + // creation and on update; a ternary passes for 'line' and fails for 'none'. + for (const stored of ['line', 'none'] as const) { + assert.equal(created({ renderLineHighlight: stored }).renderLineHighlight, stored); + assert.equal(updatedEditorOptions({ ...SETTINGS, renderLineHighlight: stored }, 100, true, false).renderLineHighlight, stored); + } + }); + + test('editor options are applied by a single updateOptions effect', () => { + // Two competing effects wrote the same Monaco options with different + // values — notably fontSize with and without the zoom factor. What the one + // update applies: every option the two effects used to fight over, and the + // zoom-aware font size that was the value they disagreed on. + const applied = updatedEditorOptions({ ...SETTINGS, editorFontSize: 20, editorMaxWidth: 90 }, 150, true, false); + assert.equal(applied.fontSize, 30, 'the surviving font size carries the zoom factor'); + assert.equal(applied.wordWrapColumn, 90, 'wordWrapColumn survived the merge'); + assert.equal(created({ editorFontSize: 20 }).fontSize, 20, 'and creation derives it the same way'); + }); + + test('a proportional font wraps on measured widths, a monospace font keeps the fast path', () => { + // #758: 'simple' wrapped Times New Roman and Roboto lines well short of the + // window edge. Both values are asserted because 'advanced' everywhere would + // also fix the report, and would make every large file pay for it. + assert.equal(updatedEditorOptions(SETTINGS, 100, true, false).wrappingStrategy, 'simple'); + assert.equal(updatedEditorOptions(SETTINGS, 100, false, false).wrappingStrategy, 'advanced'); + }); + + test('Show Whitespace renders every whitespace run, not just trailing', () => { + // The setting is labelled without qualification ("Show Whitespace" / + // "显示空白字符"), so "trailing" left interior spaces unmarked. + assert.equal(created({ showWhitespace: true }).renderWhitespace, 'all'); + assert.equal(created({ showWhitespace: false }).renderWhitespace, 'none'); + assert.equal(updatedEditorOptions({ ...SETTINGS, showWhitespace: true }, 100, true, false).renderWhitespace, 'all'); + }); + + test('the editor narrows the Unicode highlighter without switching it off', () => { + // Reports #186 and #94 are both Monaco outlining fullwidth punctuation. + // Narrowness is the assertion: `unicodeHighlight: false`, or turning + // `invisibleCharacters` off, would also make the boxes go away and would + // take the NBSP warning with it. Two overrides, and only two: the + // confusable rule off, and U+3000 exempted from the invisible rule (#393). + const passed = created().unicodeHighlight as object; + assert.deepEqual(Object.keys(passed).sort(), ['allowedCharacters', 'ambiguousCharacters'], 'no third sub-option'); + const effective = effectiveUnicodeHighlight(passed); + assert.equal(effective.ambiguousCharacters, false); + assert.deepEqual(effective.allowedCharacters, { ' ': true }, 'exactly one character is exempted'); + }); + + test('no box is drawn on fullwidth CJK punctuation', () => { + const found = outlined(CJK_PROSE, effectiveUnicodeHighlight(created().unicodeHighlight)); + assert.equal( + found.ranges.length, + 0, + `nothing outlined in CJK prose, got ${JSON.stringify( + found.ranges.map((r) => CJK_PROSE[r.startLineNumber - 1].slice(r.startColumn - 1, r.endColumn - 1)), + )}`, + ); + assert.equal(found.ambiguousCharacterCount, 0); + }); + + test('the invisible-character warning survives the fix', () => { + // A stray NBSP or zero-width space is a real hazard in Markdown — an NBSP + // after `-` stops a list from parsing. + const effective = effectiveUnicodeHighlight(created().unicodeHighlight); + assert.equal(effective.invisibleCharacters, true, 'left at the Monaco default'); + const found = outlined(['a b', 'x​z'], effective); + assert.equal(found.invisibleCharacterCount, 2, 'NBSP and zero-width space are still flagged'); + }); + + test('the space bar under a CJK IME is not outlined', () => { + const found = outlined(CJK_IDEOGRAPHIC_SPACE, effectiveUnicodeHighlight(created().unicodeHighlight)); + assert.equal( + found.ranges.length, + 0, + `nothing outlined around U+3000, got ${JSON.stringify(found.ranges.map((r) => CJK_IDEOGRAPHIC_SPACE[r.startLineNumber - 1]))}`, + ); + }); + + test('exempting U+3000 does not exempt the NBSP that breaks a list', () => { + const found = outlined(['- item', '今天 天气'], effectiveUnicodeHighlight(created().unicodeHighlight)); + assert.equal(found.invisibleCharacterCount, 1, 'the NBSP is flagged, the U+3000 is not'); + assert.equal(found.ranges[0].startLineNumber, 1, 'and it is the NBSP line'); + }); + + test('"Highlight Occurrences" off leaves nothing highlighting occurrences', () => { + // Monaco splits the feature in two: `SelectionHighlighter` gates itself on + // `selectionHighlight` and consults `occurrencesHighlight` only to choose a + // decoration style. So at the setting's own default, selecting a word still + // highlighted every other copy of it. Read through each option's own + // `validate`, at creation and on update, for both values of the setting. + for (const occurrencesHighlight of [false, true]) { + for (const options of [created({ occurrencesHighlight }), updatedEditorOptions({ ...SETTINGS, occurrencesHighlight }, 100, true, false)]) { + assert.deepEqual( + { + occurrencesHighlight: EditorOptions.occurrencesHighlight.validate(options.occurrencesHighlight), + selectionHighlight: EditorOptions.selectionHighlight.validate(options.selectionHighlight), + }, + occurrencesHighlight + ? { occurrencesHighlight: 'singleFile', selectionHighlight: true } + : { occurrencesHighlight: 'off', selectionHighlight: false }, + ); + } + } + }); + + test('the editor does not offer to rewrite a document containing U+2028', () => { + // `unusualLineTerminators` defaults to 'prompt', which in standalone Monaco + // is a bare `mainWindow.confirm()` offering to delete characters out of the + // user's file. 'auto' makes the same edit with no dialog. Read through + // `validate`, because anything it does not recognise resolves back to the + // dialog. + assert.equal(EditorOptions.unusualLineTerminators.validate(created().unusualLineTerminators), 'off'); + }); + + test('the editor turns the semantic layer on, because the default is off', () => { + // `StandaloneTheme.semanticHighlighting` is hard-coded false, so + // `'configuredByTheme'` cannot reach it and the provider is never asked. + assert.equal(Reflect.get(created(), 'semanticHighlighting.enabled'), true); + }); + + test('an editor on a buffer that is not the whole file refuses typing', () => { + // An unread or unreadable tab in edit mode shows an empty buffer that no + // save will ever accept. Read-only at the keyboard, not a toast at the save. + assert.equal(editorReadOnly({ isTruncated: true }), true); + assert.equal(editorReadOnly({ isTruncated: false }), false); + assert.equal(editorReadOnly({}), false); + assert.equal(createdEditorOptions(SETTINGS, 100, true).readOnly, true, 'at creation'); + assert.equal(updatedEditorOptions(SETTINGS, 100, true, true).readOnly, true, 'and when a tab is read later'); + assert.equal(updatedEditorOptions(SETTINGS, 100, true, false).readOnly, false, 'and typing is back once it is whole'); + }); }); // The editor's Markdown colours. Two things can make a rule here do nothing at @@ -478,6 +643,30 @@ describe('editor theme', () => { } }); + test('both themes get rules, and every rule is a real token', () => { + // Every construct the palette colours, run through Monaco's own grammar. A + // rule naming a token this never emits would be inert. + const sample = [ + '# Heading', '', '> quoted', '', '* * *', '', + '- item **bold** and *it* `code` \\* [a](http://x "t") ', '', + '| H1 | H2 |', '|----|----|', '| a | b |', '', + '```', 'fenced', '```', '', + '
html & text
', '', + ].join('\n'); + const emitted = new Set(createMarkdownTokenizer()(sample).flat().map((token) => token.type)); + + for (const appearance of ['light', 'dark'] as const) { + const rules = markdownTokenRules(appearance); + assert.ok(rules.length > 0, `${appearance} must not fall back to Monaco's source-code colours`); + for (const rule of rules) { + assert.ok(emitted.has(rule.token), `no Markdown token is called \`${rule.token}\` — the rule would be inert`); + // Monaco's own parser reads these as `#rrggbb` once it prepends the + // hash; a `#` here, or a short form, resolves to no colour. + assert.match(rule.foreground, /^[0-9a-f]{6}$/, `${rule.token} must be a bare six-digit hex`); + } + } + }); + test('light and dark are actually different themes', () => { const light = markdownTokenRules('light'); const dark = markdownTokenRules('dark'); @@ -971,59 +1160,57 @@ describe('image embed', () => { // types, the fence state machine, and the caret lookup are genuinely exercised, // not mocked. // -// Not covered: that Editor.svelte calls this code on the Ctrl+V path with the -// right arguments. Editor.svelte is a Svelte component and cannot be imported -// here, so that wiring is pinned by a source-text assertion at the bottom of -// the file, which proves only that the code reads a certain way. +// The paste handler itself (utils/editorClipboard.ts) runs too: "the paste path +// consults the context guard before building a link" below. // // Also not covered: Monaco's async tokenizer bootstrap. In the app, // `monaco.editor.tokenize()` falls back to null tokens if the language has not // finished loading; that degrades to "no code token found", i.e. to the old // linkify-everything behaviour, and is not reproduced here. -describe('paste URL context', () => { - // `monaco.editor.tokenize()` needs a browser-backed editor to exist, so the - // tokenizer is driven directly. Only the handful of services Monarch actually - // touches during plain (non-encoded) tokenization are stubbed. - function createMarkdownTokenizer() { - const lexer = compile('markdown', markdownLanguage); - const languageService = { - languageIdCodec: { encodeLanguageId: () => 1, decodeLanguageId: () => 'markdown' }, - isRegisteredLanguageId: () => true, - // ```lang fences: Monarch asks for the embedded language and stamps its - // tokens with the resulting id, which is the signal pasteContext keys on. - getLanguageIdByLanguageName: (name: string) => name, - requestBasicLanguageFeatures: () => {}, - requestRichLanguageFeatures: () => {}, - onDidRequestBasicLanguageFeatures: () => ({ dispose() {} }), - onDidRequestRichLanguageFeatures: () => ({ dispose() {} }), - }; - const themeService = { - getColorTheme: () => ({ tokenTheme: { match: () => 0 } }), - onDidColorThemeChange: () => ({ dispose() {} }), - }; - const configurationService = { - getValue: () => undefined, - onDidChangeConfiguration: () => ({ dispose() {} }), - }; - - const tokenizer = new MonarchTokenizer( - languageService, - themeService, - 'markdown', - lexer, - configurationService, - ); +// `monaco.editor.tokenize()` needs a browser-backed editor to exist, so the +// tokenizer is driven directly. Only the handful of services Monarch actually +// touches during plain (non-encoded) tokenization are stubbed. +function createMarkdownTokenizer() { + const lexer = compile('markdown', markdownLanguage); + const languageService = { + languageIdCodec: { encodeLanguageId: () => 1, decodeLanguageId: () => 'markdown' }, + isRegisteredLanguageId: () => true, + // ```lang fences: Monarch asks for the embedded language and stamps its + // tokens with the resulting id, which is the signal pasteContext keys on. + getLanguageIdByLanguageName: (name: string) => name, + requestBasicLanguageFeatures: () => {}, + requestRichLanguageFeatures: () => {}, + onDidRequestBasicLanguageFeatures: () => ({ dispose() {} }), + onDidRequestRichLanguageFeatures: () => ({ dispose() {} }), + }; + const themeService = { + getColorTheme: () => ({ tokenTheme: { match: () => 0 } }), + onDidColorThemeChange: () => ({ dispose() {} }), + }; + const configurationService = { + getValue: () => undefined, + onDidChangeConfiguration: () => ({ dispose() {} }), + }; - return (text: string): PasteContextToken[][] => { - let state = tokenizer.getInitialState(); - return text.split('\n').map((line: string) => { - const result = tokenizer.tokenize(line, true, state); - state = result.endState; - return result.tokens as PasteContextToken[]; - }); - }; - } + const tokenizer = new MonarchTokenizer( + languageService, + themeService, + 'markdown', + lexer, + configurationService, + ); + + return (text: string): PasteContextToken[][] => { + let state = tokenizer.getInitialState(); + return text.split('\n').map((line: string) => { + const result = tokenizer.tokenize(line, true, state); + state = result.endState; + return result.tokens as PasteContextToken[]; + }); + }; +} +describe('paste URL context', () => { const tokenize = createMarkdownTokenizer(); /** @@ -1048,6 +1235,79 @@ describe('paste URL context', () => { }); } + test('the paste path consults the context guard before building a link', () => { + // The guard the paste path calls, over a model and the primary selection: + // the lines above the caret go through Monaco's tokenizer for markdown. + const languages: string[] = []; + const model = (lines: string[]) => ({ + getLanguageId: () => 'markdown', + getValue: () => lines.join('\n'), + getLinesContent: () => lines, + }); + const monacoEditor = { + tokenize: (text: string, languageId: string) => { + languages.push(languageId); + return tokenize(text); + }, + }; + const fenced = ['```', 'const a = ', '```']; + assert.equal(isLinkifyPasteTarget(model(fenced), { startLineNumber: 2, startColumn: 11 }, monacoEditor), false, 'a URL pasted in code stays bare'); + assert.equal(isLinkifyPasteTarget(model(['```', 'code', '```', 'prose ']), { startLineNumber: 4, startColumn: 7 }, monacoEditor), true); + // Only the lines up to the caret count: a fence opened below it is not one the caret is in. + assert.equal(isLinkifyPasteTarget(model(['prose ', '```', 'code']), { startLineNumber: 1, startColumn: 7 }, monacoEditor), true); + assert.ok(languages.length > 0 && languages.every((id) => id === 'markdown'), `tokenized as ${languages}`); + }); + + test('the paste path consults the context guard before building a link', async () => { + // The editor's own paste handler, with the clipboard holding a URL and the + // caret in a fence, then in prose: the guard decides between the bare URL + // and `[label](url)`. + const url = 'https://example.com/a'; + const pasteInto = async (lines: string[], line: number, column: number) => { + const edits: { source: string; text: string }[] = []; + const selection = { + startLineNumber: line, + startColumn: column, + endLineNumber: line, + endColumn: column, + isEmpty: () => true, + }; + const editor = { + focus: () => {}, + getModel: () => ({ + getLanguageId: () => 'markdown', + getValue: () => lines.join('\n'), + getLinesContent: () => lines, + }), + getSelections: () => [selection], + pushUndoStop: () => true, + executeEdits: (source: string, ops: { text: string }[]) => { + for (const op of ops) edits.push({ source, text: op.text }); + return true; + }, + setSelections: () => {}, + }; + await pasteFromClipboard({ + editor: () => editor as never, + monaco: () => + ({ + editor: { tokenize: (text: string) => tokenize(text) }, + Selection: class {}, + }) as never, + invoke: async (cmd) => (cmd === 'clipboard_read_text' ? url : null), + // No document path: the image branch is skipped, as for an untitled tab. + tabs: { activeTab: { path: undefined } }, + settings: { imageDirectory: 'img', macosImageScaling: false }, + }); + return edits; + }; + + assert.deepEqual(await pasteInto(['```', 'const a = ', '```'], 2, 11), [{ source: 'paste-text', text: url }], 'a URL pasted in code stays bare'); + assert.deepEqual(await pasteInto(['```', 'code', '```', 'prose '], 4, 7), [ + { source: 'paste-link', text: `[example.com/a](${url})` }, + ]); + }); + test('a URL pasted into prose still becomes a markdown link', () => { assert.equal(linkifiesAt('some prose |here'), true); assert.equal(linkifiesAt('# A heading |'), true); @@ -1171,3 +1431,127 @@ describe('focus paragraph', () => { assert.deepEqual(paragraphAround(read, LINES.length, 2), { start: 2, end: 2 }, 'a blank line is its own'); }); }); + +describe('editor clipboard and format tools', () => { + class FakeRange { + constructor( + readonly startLineNumber: number, + readonly startColumn: number, + readonly endLineNumber: number, + readonly endColumn: number, + ) {} + isEmpty() { + return this.startLineNumber === this.endLineNumber && this.startColumn === this.endColumn; + } + } + + /** An editor over `lines` with one selection, recording the calls the tools make. */ + function fakeEditor(lines: string[], selection: number[], eol = '\n') { + const calls: string[] = []; + const edits: Array<{ text: string }> = []; + const model = { + getLineContent: (line: number) => lines[line - 1], + getLineCount: () => lines.length, + getLineMaxColumn: (line: number) => lines[line - 1].length + 1, + getEOL: () => eol, + getValueInRange: (r: FakeRange) => { + const span = lines.slice(r.startLineNumber - 1, r.endLineNumber); + span[span.length - 1] = span[span.length - 1].slice(0, r.endColumn - 1); + span[0] = span[0].slice(r.startColumn - 1); + return span.join(eol); + }, + }; + const editor = { + getModel: () => model, + getSelection: () => new FakeRange(selection[0], selection[1], selection[2], selection[3]), + pushUndoStop: () => { + calls.push('pushUndoStop'); + return true; + }, + executeEdits: (_source: string, ops: Array<{ text: string }>) => { + calls.push('executeEdits'); + edits.push(...ops); + return true; + }, + }; + return { editor: editor as unknown as EditingEditor, calls, edits }; + } + + const monaco = { Range: FakeRange } as unknown as Parameters[1]; + const numbers = (r: unknown) => { + const range = r as FakeRange; + return [range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn]; + }; + + test("custom copy keeps Monaco's whole-line copy on an empty selection", () => { + // Monaco's `emptySelectionClipboard` default, VS Code's and Sublime Text's: + // nothing selected copies the caret's line with its line ending. + const { editor } = fakeEditor(['first', 'second', 'last'], [2, 3, 2, 3], '\r\n'); + const line = clipboardTextForSelection(editor, monaco); + assert.equal(line.text, 'second\r\n'); + assert.deepEqual(numbers(line.range), [2, 1, 3, 1], 'a cut takes the line ending with the line'); + + const last = clipboardTextForSelection(fakeEditor(['first', 'last'], [2, 1, 2, 1]).editor, monaco); + assert.equal(last.text, 'last\n'); + assert.deepEqual(numbers(last.range), [2, 1, 2, 5], 'the last line has no next line to run to'); + + const selected = clipboardTextForSelection(fakeEditor(['hello world'], [1, 1, 1, 6]).editor, monaco); + assert.equal(selected.text, 'hello', 'a real selection takes just the selection'); + assert.deepEqual(numbers(selected.range), [1, 1, 1, 6]); + + assert.deepEqual(clipboardTextForSelection(undefined, monaco), { text: '', range: null }, 'no editor yet, nothing to copy'); + }); + + test('the format tools each push an undo stop before they edit', () => { + const tools: Record void> = { + insertTextAtCursor: (editor) => insertTextAtCursor(editor, 'x'), + wrapAsCodeBlock, + insertLink, + }; + for (const [name, run] of Object.entries(tools)) { + const { editor, calls, edits } = fakeEditor(['hello'], [1, 1, 1, 6]); + run(editor); + assert.equal(edits.length, 1, name); + assert.deepEqual(calls, ['pushUndoStop', 'executeEdits'], `${name} edits inside the undo step typing left open`); + } + }); +}); + +describe('editor mount', () => { + /** A load the test resolves by hand. */ + function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; + } + const settle = () => new Promise((r) => setImmediate(r)); + + test('unmounting while Monaco is still loading creates no editor', async () => { + const load = deferred(); + const events: string[] = []; + const cleanup = mountAfterLoad(() => load.promise, () => { + events.push('create'); + return () => events.push('teardown'); + }); + assert.equal(typeof cleanup, 'function', 'the cleanup is returned synchronously, not as a promise'); + cleanup(); + load.resolve(); + await settle(); + assert.deepEqual(events, []); + }); + + test('the editor is created once Monaco lands, and torn down on unmount', async () => { + const load = deferred(); + const events: string[] = []; + const cleanup = mountAfterLoad(() => load.promise, () => { + events.push('create'); + return () => events.push('teardown'); + }); + assert.deepEqual(events, [], 'nothing is created before the module arrives'); + load.resolve(); + await settle(); + assert.deepEqual(events, ['create']); + cleanup(); + assert.deepEqual(events, ['create', 'teardown']); + }); +}); diff --git a/scripts/editor.theme.spec.ts b/scripts/editor.theme.spec.ts new file mode 100644 index 00000000..ac4af888 --- /dev/null +++ b/scripts/editor.theme.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { defineAppThemes, monacoThemeName } from '../src/lib/utils/editorTheme.js'; +import { loadVscodeTheme } from '../src/lib/utils/theme.js'; + +// Module-level on purpose: vitest hoists `vi.mock` and `vi.hoisted` to the top of the file. +const defined = vi.hoisted(() => new Set()); + +vi.mock('monaco-editor', () => ({ + editor: { setTheme: () => {}, defineTheme: (name: string) => void defined.add(name) }, +})); + +describe('editor theme names', () => { + test('every name the seam returns is a theme somebody defines', async () => { + // `setTheme` on a name Monaco has never been given silently falls back to + // `vs`. The app defines two themes when the editor mounts, and an imported + // VS Code theme is defined when it loads. + defineAppThemes({ editor: { defineTheme: (name: string) => void defined.add(name) } } as never); + const theme = JSON.stringify({ type: 'dark', colors: { 'editor.background': '#101010' } }); + expect(await loadVscodeTheme('Dark Thing', async () => theme)).toBe(true); + + for (const setting of ['system', 'light', 'dark', 'vscode:whatever']) { + for (const prefersDark of [false, true]) { + const name = monacoThemeName(setting, prefersDark); + expect(defined, `nothing defines ${name}; Monaco would fall back to vs`).toContain(name); + } + } + }); +}); diff --git a/scripts/editor.vim.test.ts b/scripts/editor.vim.test.ts index e096ffaa..ca10923d 100644 --- a/scripts/editor.vim.test.ts +++ b/scripts/editor.vim.test.ts @@ -121,6 +121,45 @@ describe('vim clipboard registers', () => { assert.equal(installVimClipboardRegisters({ Vim: {} }, () => {}), false); assert.equal(installVimClipboardRegisters({ Vim: { defineRegister() {}, getRegisterController: () => undefined } }, () => {}), false); }); + + test('Editor.svelte installs the commands on the module it just imported', async () => { + // `attachVimMode` is what the editor's `import("monaco-vim").then` runs. It + // has to install before `initVimMode` attaches the adapter, and it has to + // install even on the disposed path, because the tables it writes to are + // the package's and outlive this editor. + const { attachVimMode } = await import('../src/lib/utils/vimAttach.js'); + for (const disposed of [false, true]) { + const { VimMode, controller } = await freshVim(); + const events: string[] = []; + const realMapCommand = VimMode.Vim.mapCommand; + VimMode.Vim.mapCommand = function (this: unknown, ...args: unknown[]) { + events.push(`map ${String(args[0])}`); + return realMapCommand.apply(this, args); + }; + const written: unknown[] = []; + const invoke = (command: string, args: unknown) => { + written.push([command, args]); + return Promise.resolve(); + }; + const adapter = { dispose() {} }; + const initVimMode = () => { + events.push(controller.isValidRegister('+') ? 'attach with "+' : 'attach without "+'); + return adapter; + }; + + const vim = attachVimMode({ initVimMode, VimMode }, {}, {}, invoke, () => disposed); + if (disposed) { + assert.equal(vim, null, 'a disposed effect attaches nothing'); + assert.deepEqual(events, ['map zb', 'map z-'], 'a disposed effect still patches the package'); + assert.equal(controller.isValidRegister('+'), true); + } else { + assert.equal(vim, adapter, 'the adapter is handed back for disposal'); + assert.deepEqual(events, ['map zb', 'map z-', 'attach with "+'], 'the commands must be in place before the adapter attaches'); + } + controller.pushText('+', 'yank', 'yanked', false); + assert.deepEqual(written, [['clipboard_write_text', { text: 'yanked' }]], '"+ writes through the Rust clipboard'); + } + }); }); /* diff --git a/scripts/export.checks.test.ts b/scripts/export.checks.test.ts index f3fdf673..cc64200d 100644 --- a/scripts/export.checks.test.ts +++ b/scripts/export.checks.test.ts @@ -3,9 +3,8 @@ import assert from 'node:assert/strict'; import { buildExportDocument, exportThemeAttribute } from '../src/lib/utils/export.ts'; import { DEFAULT_PREVIEW_MAX_WIDTH, MIN_PREVIEW_MAX_WIDTH } from '../src/lib/utils/previewWidth.js'; import { plainAppearance } from './exportFixtures.ts'; -import { functionSource, offsetOf, readRustBackend, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; +import { offsetOf, readRustBackend, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; import { collectUsedKatexFamilies, katexFontUrlsToEmbed } from '../src/lib/utils/exportFonts.ts'; -import { ALLOWED_MARKDOWN_URI_REGEXP, MARKDOWN_SANITIZE_CONFIG } from '../src/lib/utils/sanitize.js'; import { useShimDom } from './preview.domShim.ts'; /** @@ -31,8 +30,6 @@ describe('exported content width', () => { const appStyles = readSource(new URL('../src/styles.css', import.meta.url)); - const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - type Rule = { selectors: string[]; declarations: string; media: 'screen' | 'print' }; type Declaration = { value: string; important: boolean }; @@ -162,30 +159,11 @@ describe('exported content width', () => { appearance: plainAppearance, }); } - - test('the exporter is handed the preview\'s own derived width', () => { - // The one link in the chain that cannot be executed here: `exportAsHtml` - // lives in a `.svelte` component, which the Node test runner cannot import - // (see sourceTree.ts). So the field is checked in the source of that one - // function — `functionSource` rather than a whole-file match, so a - // `contentWidth:` appearing anywhere else in a 3700-line component cannot - // satisfy it. - const exportCall = functionSource(viewerSource, 'exportAsHtml'); - assert.match(exportCall, /contentWidth: previewContentWidth,/); - - // And `previewContentWidth` is the same derived value the live preview - // renders with, which is what makes "one source of truth" true rather than - // merely intended. (preview.test.ts holds the other end of this.) - assert.match(viewerSource, /previewContentWidth = \$derived\(getPreviewContentWidth\(settings\.previewMaxWidth, settings\.previewFullWidth\)\)/); - assert.match(viewerSource, /--preview-max-width: \{previewContentWidth === null \? '100%' : `\$\{previewContentWidth\}px`\}/); - }); }); describe('folds in print and export', () => { const styles = readSource('src/styles.css'); - const exportSource = readSource('src/lib/utils/export.ts'); - // Both export routes have to agree on what is in the file. The HTML route // decides in TypeScript (it renders every fold open); the print/PDF route // decides in CSS, because it prints the live DOM with its folds still @@ -526,14 +504,6 @@ describe('folds in print and export', () => { assert.equal(screenValue(nestedCallout, 'transform'), undefined); }); - test('the HTML export renders every heading fold open', () => { - // The HTML route never sees the live DOM, it re-renders from source, and - // the Set it passes is the fold state that render is given. An empty one is - // what makes every section expanded. - assert.match(exportSource, /processMarkdownHtml\(safeHtml, ctx\.tabPath, new Set\(\)\)/); - assert.doesNotMatch(exportSource, /processMarkdownHtml\([^)]*foldOverrides/); - }); - test('a callout collapsed in the source is still readable in an exported file', () => { // The empty Set only governs heading folds. `> [!note]-` puts `is-collapsed` // in the markup itself, so it survives the re-render — and an exported file @@ -625,104 +595,9 @@ describe('rich content in the HTML export', () => { }); }); -describe('export sanitizing', () => { - const exportSource = readSource('src/lib/utils/export.ts'); - - const sanitizeSource = readSource('src/lib/utils/sanitize.ts'); - - // DOMPurify itself needs a real DOM, which the Node test runner does not have, - // so these tests exercise the two halves of the policy that are checkable here: - // the URI decision (a pure regexp, applied exactly the way DOMPurify applies it, - // see purify.es.mjs line ~1041) and the configuration that decides what the - // filter may keep. The behavioural half was verified against the real DOMPurify - // build in Chrome: `sanitizeMarkdownHtml(POC)` returns `` — the - // event handler, and with it the whole payload, is gone. - const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g; - - function uriIsAllowed(value: string): boolean { - return ALLOWED_MARKDOWN_URI_REGEXP.test(value.replace(ATTR_WHITESPACE, '')); - } - - test('the export sanitizes the rendered document before it touches it', () => { - // The regression this pins: `render_markdown` output going into - // `processMarkdownHtml` — and from there into the file — without passing - // through the shared filter first. Order matters as much as presence: the - // sanitizer has to see the untrusted document, not our own generated markup. - const sanitizeCall = exportSource.indexOf('const safeHtml = sanitizeMarkdownHtml(rendered);'); - const processCall = exportSource.indexOf('processMarkdownHtml(safeHtml, ctx.tabPath, new Set())'); - - assert.ok(sanitizeCall !== -1, 'export must sanitize the raw render_markdown output'); - assert.ok(processCall !== -1, 'export must feed the sanitized HTML into processMarkdownHtml'); - assert.ok(sanitizeCall < processCall, 'sanitization must happen before any Markpad-owned processing'); - - // The raw renderer output must not reach any other consumer unfiltered. - assert.doesNotMatch(exportSource, /processMarkdownHtml\(\s*rendered\b/); - assert.match(exportSource, /import \{ sanitizeMarkdownHtml \} from '\.\/sanitize\.js'/); - }); - - test('the proof-of-concept payload cannot reach an exported file', () => { - // The payload's danger is the handler, not the URI: `x` is an ordinary - // relative path and is meant to stay allowed. So nothing about the URI - // policy stops this one — two other things do, and both are asserted here, - // because on the unfixed export path neither was in the way. - // - // (`assert.match(POC, /onerror=/)` used to sit here. POC is a template - // literal three lines above, so the assertion restated the fixture back to - // itself and no change to `src/` could reach it.) - assert.equal(uriIsAllowed('x'), true); - - // 1. The renderer output carrying the payload goes through the shared - // filter before anything else touches it, and DOMPurify drops `on*` - // handlers unless the config hands them back. Nothing in the shared - // config does: a future `ADD_ATTR: ['onerror']`, `ALLOWED_ATTR`, - // `ADD_TAGS`, `ALLOW_UNKNOWN_PROTOCOLS` or `SANITIZE_DOM: false` would - // fail the key assertion below. - assert.match(exportSource, /const safeHtml = sanitizeMarkdownHtml\(rendered\);/); - assert.deepEqual(Object.keys(MARKDOWN_SANITIZE_CONFIG).sort(), ['ALLOWED_URI_REGEXP', 'FORBID_TAGS']); - assert.deepEqual(MARKDOWN_SANITIZE_CONFIG.FORBID_TAGS, ['style']); - assert.equal(MARKDOWN_SANITIZE_CONFIG.ALLOWED_URI_REGEXP, ALLOWED_MARKDOWN_URI_REGEXP); - - // 2. Even if the filter ever lets something through, the exported document - // itself refuses to run it. - assert.match(exportSource, / { - // Second line of defence: verified in Chrome, an exported page with this - // meta tag runs neither an inline `')); - const file = ts.createSourceFile('editor.ts', script, ts.ScriptTarget.ES2022, true); - - const constants: Record = {}; - const calls: BareCommand[] = []; - - const evaluate = (expression: string): unknown => - new Function('monaco', 'constants', `with (constants) { return (${expression}); }`)( - { KeyMod, KeyCode }, - constants, - ); - - const visit = (node: ts.Node) => { - if ( - ts.isVariableDeclaration(node) && - ts.isIdentifier(node.name) && - node.initializer && - (ts.isStringLiteral(node.initializer) || ts.isTemplateExpression(node.initializer)) - ) { - // Best effort: the component is full of template literals built from - // component state (`${label}px`), and none of them is a `when` clause. - // One that fails to evaluate here is simply not a constant the calls - // below can be reading, and an argument that turns out to need it fails - // loudly at its own `evaluate`. - try { - constants[node.name.text] = String(evaluate(node.initializer.getText())); - } catch { - /* not a constant */ - } - } - - if ( - ts.isCallExpression(node) && - node.expression.getText() === 'editor.addCommand' && - node.arguments.length === 3 - ) { - calls.push({ - binding: evaluate(node.arguments[0].getText()) as number, - handler: node.arguments[1].getText(), - when: String(evaluate(node.arguments[2].getText())), - }); - } - - ts.forEachChild(node, visit); - }; - visit(file); - - assert.ok(calls.length >= 5, `found ${calls.length} addCommand calls; the extraction is not running`); - return calls; -} - -/** What a handler did to the stub editor it was handed. */ -export type RecordedEdits = { - triggers: string[]; - edits: Array<{ range: number[]; text: string; cursor: number[] }>; - /** `setSelection` calls: a caret moved without an edit. */ - selections: number[][]; - undoStops: number; -}; - -class FakeRange { - constructor( - readonly startLineNumber: number, - readonly startColumn: number, - readonly endLineNumber: number, - readonly endColumn: number, - ) {} - isEmpty() { - return this.startLineNumber === this.endLineNumber && this.startColumn === this.endColumn; - } - numbers() { - return [this.startLineNumber, this.startColumn, this.endLineNumber, this.endColumn]; - } -} - -/** - * Component handlers, extracted by name and evaluated against a stub editor. - * - * `names` is every function the run needs, entry point LAST — a handler that - * dispatches through helpers needs the helpers in scope, and naming them makes - * the dependency visible instead of resolving it with a stub that would happily - * pass. - * - * The list and table modules in scope are the REAL ones, so a handler that - * stopped calling them — or called them with the wrong column — fails here - * rather than passing against a second, more agreeable copy. - */ -export function runEditorHandler( - names: string[], - options: { lines: string[]; selections: number[][]; eol?: string }, -): RecordedEdits { - const recorded: RecordedEdits = { triggers: [], edits: [], selections: [], undoStops: 0 }; - const eol = options.eol ?? '\n'; - const selections = options.selections.map((s) => new FakeRange(s[0], s[1], s[2], s[3])); - - const model = { - getLineContent: (line: number) => options.lines[line - 1], - getLineCount: () => options.lines.length, - getLineMaxColumn: (line: number) => options.lines[line - 1].length + 1, - getEOL: () => eol, - getValueInRange: (range: FakeRange) => { - const span = options.lines.slice(range.startLineNumber - 1, range.endLineNumber); - span[span.length - 1] = span[span.length - 1].slice(0, range.endColumn - 1); - span[0] = span[0].slice(range.startColumn - 1); - return span.join(eol); - }, - }; - - const scope: Record = { - blockEnter, - isInFencedCode, - parseListItem, - shiftListItem, - tableStep, - tableOperation, - monaco: { Range: FakeRange, Selection: FakeRange }, - editor: { - getModel: () => model, - getSelection: () => selections[0], - getSelections: () => selections, - pushUndoStop: () => { - recorded.undoStops += 1; - }, - setSelection: (selection: FakeRange) => { - recorded.selections.push(selection.numbers()); - }, - trigger: (_source: string, handlerId: string, payload: { text?: string } | null) => { - recorded.triggers.push(payload?.text ? `${handlerId}:${JSON.stringify(payload.text)}` : handlerId); - }, - executeEdits: ( - _source: string, - edits: Array<{ range: FakeRange; text: string }>, - cursor?: FakeRange[], - ) => { - for (const [index, edit] of edits.entries()) { - recorded.edits.push({ - range: edit.range.numbers(), - text: edit.text, - cursor: cursor?.[index]?.numbers() ?? [], - }); - } - }, - }, - }; - - const source = readSource(EDITOR); - const declarations = names.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 build = new Function('scope', `with (scope) { ${js}\nreturn ${names[names.length - 1]}; }`) as ( - s: unknown, - ) => () => void; - build(new Proxy(scope, { has: () => true }))(); - - return recorded; + return editorKeyCommands({ KeyMod, KeyCode } as never); } diff --git a/scripts/layout.checks.spec.ts b/scripts/layout.checks.spec.ts index 54e925c4..e5122482 100644 --- a/scripts/layout.checks.spec.ts +++ b/scripts/layout.checks.spec.ts @@ -1,33 +1,14 @@ -import assert from 'node:assert/strict'; - import { describe, expect, test } from 'vitest'; import Toc from '../src/lib/components/Toc.svelte'; import { readSource } from './sourceTree.js'; -describe('split scroll-sync preference', () => { - test('the tab store keeps no localStorage of its own', () => { - // Both halves: the bare `setItem` the convention rule now forbids outright, - // and the constructor's `getItem`, which is the load path that has to move - // with it or the two would drift. - const tabsSource = readSource('src/lib/stores/tabs.svelte.ts'); - assert.doesNotMatch(tabsSource, /localStorage\.(?:get|set)Item\s*\(/); - }); -}); - describe('outline render signal', () => { - test('the revision is published by the patch, not by the render', () => { - // The guard on the fix: a signal derived from the rendered string rather - // than from the write to the DOM is the bug, spelled differently. - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - const patchEffect = viewer.slice(viewer.indexOf('patchPreview(host, ')); - const bump = patchEffect.indexOf('previewRevision = ++previewPatches;'); - expect(bump).toBeGreaterThan(-1); - expect(bump).toBeLessThan(patchEffect.indexOf('\n\t});')); - - // And the outline no longer receives the document string it never read. + test('the outline takes no document string', () => { + // The revision itself is layout.spec.ts "the revision is published by the + // patch, not by the render". This is the absence of the old signal. const toc = readSource('src/lib/components/Toc.svelte'); expect(/htmlContent/.test(toc.split('It used to be')[0])).toBe(false); }); + }); diff --git a/scripts/layout.checks.test.ts b/scripts/layout.checks.test.ts index d51b424a..741473d5 100644 --- a/scripts/layout.checks.test.ts +++ b/scripts/layout.checks.test.ts @@ -1,67 +1,29 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { asBufferLine, tabAnchorForEditorTopLine } from '../src/lib/utils/lineCoordinates.js'; -import { splitRatioAfterMove } from '../src/lib/utils/splitPanes.ts'; -import { sourceLineOf } from '../src/lib/utils/tocFollow.js'; -import { callbackBodies, functionSource, readSource, sliceBetween } from './sourceTree.js'; - -describe('scroll sync input', () => { - // The effect is identified by what it does — it is the one that reads - // `onscrollsync` — not by where its text starts and stops. - // - // It used to be sliced between `'&& onscrollsync)'` and `'\n\t$effect(() => {'`, - // and both halves of that were the same kind of fragile. The start anchor had - // already drifted once, silently: the original marker spelled the whole guard - // condition, the effect gained an `editorReady &&` term when Monaco moved behind - // a dynamic import, and the slice became empty rather than failing — which is - // what the shortened anchor and its `notEqual(-1)` were added for. The end - // anchor never got that guard, and it depends on the *next* effect being - // tab-indented and spelled `$effect(() => {`; write it any other way and this - // file silently starts asserting about the rest of the component. - const editor = readSource('src/lib/components/Editor.svelte'); - const syncEffects = callbackBodies(editor, '$effect').filter((body) => body.includes('onscrollsync')); - assert.equal(syncEffects.length, 1, `expected exactly one $effect reading onscrollsync (got ${syncEffects.length})`); - const syncEffect = syncEffects[0]; - - test('typing does not initiate split scroll synchronization', () => { - assert.match(syncEffect, /editor\.onDidScrollChange/); - assert.doesNotMatch(syncEffect, /onDidChangeCursorPosition/); - }); -}); +import { FOLD_TRANSITION_MS } from '../src/lib/utils/foldLayout.js'; +import { readSource } from './sourceTree.js'; describe('split pane ratio', () => { test('the bounds are not re-stated here', () => { // `TabManager.setSplitRatio` clamps. A second copy of 0.1/0.9 in this - // module would be the shape where the two answers drift apart. - assert.equal(splitRatioAfterMove(0.9, 0.5, 'left'), 1.4); - assert.equal(splitRatioAfterMove(0.1, -0.5, 'left'), -0.4); - + // module would be the shape where the two answers drift apart. The drag + // against the store's bounds is run in layout.spec.ts; an identical copy is + // only visible in the text (lint-candidate). const source = readSource(new URL('../src/lib/utils/splitPanes.ts', import.meta.url)); assert.equal(/Math\.(min|max)/.test(source), false); }); - test('both splitter routes take the direction from the same place', () => { - // The failure this guards is a splitter that follows the pointer one way - // and the arrow keys the other — invisible until someone swaps the panes. + test('no splitter route spells the direction itself', () => { + // The routes' agreement is layout.test.ts "both splitter routes take the + // direction from the same place". This is the absence of a third copy. const viewer = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - - const calls = viewer.match(/splitRatioAfterMove\(/g) ?? []; - assert.equal(calls.length, 2, 'expected the drag and the arrow keys, and nothing else'); - - // No route may reintroduce the sign itself. assert.equal(/splitEditorSide === 'left' \? /.test(viewer), false); }); + }); describe('fold layout', () => { - test('fold layout observes rendered content and publishes its measured height', () => { - const source = readSource('src/lib/utils/foldLayout.ts'); - - assert.match(source, /new ResizeObserver/); - assert.match(source, /--fold-content-height/); - assert.match(source, /requestAnimationFrame/); - }); test('fold wrapper animates an explicit measured height instead of a fractional grid track', () => { const styles = readSource('src/styles.css'); @@ -77,29 +39,18 @@ describe('fold layout', () => { // lands on a target that is still moving — a defect that only shows up as "find // sometimes scrolls to the wrong place". test('the find-bar fold re-aim delay outlasts the CSS fold transition', () => { - const findBar = readSource('src/lib/components/FindBar.svelte'); const styles = readSource('src/styles.css'); - const declared = findBar.match(/const FOLD_TRANSITION_MS = (\d+);/); - assert.ok(declared, 'FindBar.svelte must declare the delay it waits for the fold to settle'); - const expandedRule = styles.match(/\.foldable-content-wrapper\s*\{([^}]*)\}/)?.[1] || ''; const transition = expandedRule.match(/transition:[^;]*?height\s+([\d.]+)s/); assert.ok(transition, 'styles.css must animate the fold wrapper height'); assert.ok( - Number(declared[1]) >= Number(transition[1]) * 1000, - `FOLD_TRANSITION_MS (${declared[1]}ms) must outlast the ${transition[1]}s height transition`, + FOLD_TRANSITION_MS >= Number(transition[1]) * 1000, + `FOLD_TRANSITION_MS (${FOLD_TRANSITION_MS}ms) must outlast the ${transition[1]}s height transition`, ); }); - test('preview lifecycle starts and cleans up fold observation', () => { - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - assert.match(viewer, /const host = previewBlocks;[\s\S]*?observeFoldLayout\(host\)/); - assert.match(viewer, /observation\.stop\(\)/); - }); - // The direction the previous spelling could not state. `observeFoldLayout` used // to be handed `markdownBody`, and that was right while the article held one // document; it now holds one `.markdown-blocks` host per open tab, all but one @@ -121,46 +72,10 @@ describe('fold layout', () => { assert.doesNotMatch( viewer, - /observeFoldLayout\(\s*markdownBody/, + /(?:observeFoldLayout\(|followFoldLayout\(\s*\w+\s*,\s*\(\)\s*=>)\s*markdownBody/, 'observing the article would measure every hidden tab and write zero heights into it', ); }); - - test('fold measurement pauses while the preview pane is hidden by edit mode', () => { - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - assert.match(viewer, /if \(!host \|\| !hasPreviewPane\) return;/); - assert.match(viewer, /let hasPreviewPane = \$derived\(!isEditing \|\| isSplit\);/); - }); - - // The observation used to be torn down and rebuilt on every render, which is - // how a document's every fold came to re-measure once per character: - // `ResizeObserver.observe` re-registers a target it is already watching, and a - // registration delivers an initial observation. What replaced it is a live - // observation plus `observe` calls for the blocks the patch inserted, so the - // two halves of that have to stay coupled — a viewer that re-observes the whole - // host, or a `foldLayout.ts` that only observes from a root, puts the cost back - // without changing a single assertion above. - test('only newly patched blocks are handed to the fold observer', () => { - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - const foldLayout = readSource('src/lib/utils/foldLayout.ts'); - - assert.match( - viewer, - /for \(const \w+ of patch\.inserted\) foldLayout\?\.observe\(\w+\)/, - 'the viewer must observe the patched blocks, not the whole preview', - ); - assert.doesNotMatch( - viewer, - /foldLayout\?\.observe\(host\)/, - 're-observing the host re-registers every fold in the document', - ); - assert.match( - foldLayout, - /observe\(scope: Element\)/, - 'observeFoldLayout must accept a scope smaller than the article', - ); - }); }); /** @@ -182,108 +97,9 @@ describe('fold layout', () => { * settle and flicker the highlight between two entries. */ describe('outline follows the editor', () => { - const tocSource = readSource(new URL('../src/lib/components/Toc.svelte', import.meta.url)); - const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', 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)); - /* --------------------------------------------------------------- wiring */ - - test('the outline carries a source line for every entry it builds', () => { - // Both kinds — headings and block anchors — or the mapping above is asked - // about entries it cannot place. - assert.match(tocSource, /isBlock: false, line: sourceLineOf\(h\.dataset\.sourcepos\)/); - assert.match(tocSource, /isBlock: true, line: sourceLineOf\(el\.dataset\.sourcepos\)/); - // The fingerprint decides whether a re-render replaces the items. An edit - // that only moves a heading changes nothing else about it, so without the - // line in there the outline would keep answering with the old positions. - assert.match(tocSource, /newFingerprint = result\.map\(i => `\$\{i\.id\}-\$\{i\.text\}-\$\{i\.level\}-\$\{i\.line\}`\)/); - }); - - test('a click still wins over the editor, as it does over the preview', () => { - // `clickLock` holds the entry the user picked until the jump's smooth scroll - // settles. The preview's handler returns early on it; so must this one, or - // clicking an entry would highlight it and then lose it to the scroll the - // click itself caused. - assert.match(tocSource, /const line = activeLine;\s*\n\s*if \(line === null \|\| clickLock\) return;/); - }); - - test('the editor position reaches the outline whether or not scroll sync is on', () => { - // The line is recorded before the sync check, not inside it: following the - // editor is not the same feature as moving the preview, and #169 asks for it - // in editor-only mode, where there is no preview to move. - // - // The line is converted on the way in: `position` is in the editor's - // numbering and the outline is built from `data-sourcepos`, which counts - // from the body. See navigation.checks.test.ts for that boundary. - // It goes through `tabAnchorForEditorTopLine`, the crossing the tab's own - // reading position takes, so the outline follows the line the reader is - // on rather than the one the viewport cuts in half (#744). - const handler = functionSource(viewerSource, 'handleEditorScrollSync'); - const record = handler.indexOf("followToc('editor'"); - const syncCheck = handler.indexOf('splitScrollSyncOn()'); - - assert.ok(record !== -1 && syncCheck !== -1, 'both statements must still be here'); - assert.ok(record < syncCheck, 'the outline is fed before the sync check, not inside it'); - assert.match(handler, /followToc\('editor', tabAnchorForEditorTopLine\(lineCoords, asBufferLine\(position\.line\)\)\)/); - }); - - test('the preview feeds the same state, off the line it already measures', () => { - // `getPreviewScrollAnchor` is computed on every preview scroll for the - // tab's reading position. Taking the outline's answer from it is what makes - // this one rule rather than two — and it costs nothing, because the descent - // it does was already happening on that event. - const handler = sliceBetween(viewerSource, 'function handleScroll(e: Event)', '\n\tfunction '); - assert.match(handler, /const anchorLine = getPreviewScrollAnchor\(target\);/); - assert.match(handler, /followToc\('preview', anchorLine\);/); - // One input. Which pane feeds it is `followToc`'s call. - assert.match(viewerSource, /activeLine=\{tocActiveLine\}/); - }); - - test('the outline no longer measures the preview to decide', () => { - // The loop this replaces did a `querySelector` and a `getBoundingClientRect` - // per visible entry on every scroll event, and could only ever answer for - // one of the two panes. - const handler = sliceBetween(tocSource, 'function handleScroll()', '\n\tfunction '); - assert.doesNotMatch(handler, /getBoundingClientRect/); - assert.doesNotMatch(handler, /querySelector/); - // What it still does: a click leaves a highlight on its target, and the - // next scroll takes it off. - assert.match(handler, /clearTargetHighlight\(\)/); - }); - - test('the jump highlight ends on anything that means the reader moved on', () => { - // It is a temporary emphasis. Hanging it off scrolling alone made it - // permanent whenever that one event did not arrive: the jump's own smooth - // scroll is swallowed by `clickLock`, and nothing else scrolls the preview - // unless the reader does. A click or a keystroke has to end it too. - const clear = sliceBetween(tocSource, 'function clearTargetHighlight()', '\n\tfunction '); - assert.match(clear, /classList\.remove\('toc-target-active'\)/, 'one place removes the class'); - assert.match(clear, /activeTargetEl = null/); - - // And the reader's own actions must not go through the lock, which exists - // only to ignore scrolling the app itself caused. - const reader = sliceBetween(tocSource, 'function handleReaderAction()', '\n\t}'); - assert.doesNotMatch(reader, /clickLock/, 'a deliberate action is never locked out'); - assert.match(reader, /clearTargetHighlight\(\)/); - - for (const [target, event] of [ - ['el', 'pointerdown'], - ['window', 'keydown'], - ] as const) { - assert.match( - tocSource, - new RegExp(`${target}\\.addEventListener\\('${event}', handleReaderAction`), - `${event} clears it`, - ); - assert.match( - tocSource, - new RegExp(`${target}\\.removeEventListener\\('${event}', handleReaderAction\\)`), - `${event} is detached again`, - ); - } - }); - test('there is no preference for it, because the preview never had one', () => { // The outline has always followed the preview unconditionally. This is the // same feature answering for the other pane, so a switch would have made @@ -291,58 +107,4 @@ describe('outline follows the editor', () => { assert.doesNotMatch(settingsSource, /tocFollowsEditor/); assert.doesNotMatch(settingsComponentSource, /tocFollowsEditor/); }); - - test('the current entry is centred in the outline, not merely kept on screen', () => { - // `block: 'nearest'` scrolls the least it can: the highlight walked down to - // the last visible row and stayed pinned there, and what came next was - // never shown. Both callers share this one function, so the preview gets it - // too. - assert.match(tocSource, /scrollIntoView\(\{ block: 'center', behavior: 'smooth' \}\)/); - assert.doesNotMatch(tocSource, /scrollIntoView\(\{ block: 'nearest'/); - }); -}); - -describe('outline jump motion', () => { - test('an outline click is a jump, so it asks the same preference', () => { - // #199 was reported against the table of contents, and `Toc.svelte` was the - // one jump the preference never reached: its `scrollTo` kept a literal - // `'smooth'`. `.svelte` files cannot run under `node --test`, so the anchor - // is the jump function's text: no second copy of the answer in it. - const jumpTo = functionSource(readSource('src/lib/components/Toc.svelte'), 'jumpTo'); - assert.doesNotMatch(jumpTo, /'smooth'/); - assert.match(jumpTo, /jumpScrollBehavior\(/); - }); -}); - -describe('outline overlay placement', () => { - test('the outline collapses itself only when it is in the way', () => { - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - // Both auto-collapse paths are gated on the same predicate, so a window wide - // enough to hold the outline beside the text keeps the old behaviour. - assert.match(viewer, /isOverhanging && !settings\.pinnedToc/); - // Click-outside must not fight the toggle button, which owns its own click. - assert.match(viewer, /tocToggleEl\?\.contains\(target\)/); - }); - - test('the outline\'s toggle drops the editor toolbar\'s offset once it is over the outline', () => { - // Collapsed, the button floats over the editor pane and `--pane-top-chrome` - // is what keeps it clear of the toolbar. Expanded it floats over the OUTLINE, - // which has no toolbar — and the offset pushed it onto the outline's first - // entry instead. This is an absence, and an absence in CSS is not reachable - // from a runtime test in this suite: jsdom applies no stylesheet. - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - const base = viewer.match(/\.toc-toggle-floating \{([^}]*)\}/); - assert.ok(base, 'the floating toggle rule moved or was renamed'); - assert.match(base[1], /top:\s*calc\([^)]*--pane-top-chrome/); - - const expanded = viewer.match(/\.toc-toggle-floating\.expanded \{([^}]*)\}/); - assert.ok(expanded, 'the expanded rule moved or was renamed'); - assert.match(expanded[1], /top:\s*\d+px/); - assert.equal( - /--pane-top-chrome/.test(expanded[1]), - false, - 'the expanded toggle is over the outline, which has no editor toolbar to clear', - ); - }); }); diff --git a/scripts/layout.spec.ts b/scripts/layout.spec.ts index c4396197..774766bd 100644 --- a/scripts/layout.spec.ts +++ b/scripts/layout.spec.ts @@ -1,11 +1,21 @@ import assert from 'node:assert/strict'; import { flushSync, mount, unmount } from 'svelte'; -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, onTestFinished, test, vi } from 'vitest'; import Toc from '../src/lib/components/Toc.svelte'; import { collapsedFoldsAround, type FoldHost, revealFold, toggleFold, toggleFoldFromClick } from '../src/lib/utils/foldState.js'; import { runeProps } from './runeProps.svelte.js'; +import { observeFoldLayout, type FoldLayoutObservation } from '../src/lib/utils/foldLayout.js'; +import { splitRatioAfterMove } from '../src/lib/utils/splitPanes.js'; +import { previewLayout } from '../src/lib/utils/previewLayout.js'; +import { dismissesToc, tocAutoCollapses } from '../src/lib/utils/tocOverlay.js'; +import { followFoldLayout, previewPaneShown } from '../src/lib/utils/foldLayoutEffect.svelte.js'; +import { runeEffect } from './runeEffect.svelte.js'; +import { patchHost } from '../src/lib/utils/previewPatch.js'; +import { followPreviewPatch } from '../src/lib/utils/previewPatchEffect.svelte.js'; +import { refreshesPreviewCache } from '../src/lib/utils/previewRefresh.js'; +import { showsHomeScreen } from '../src/lib/utils/homeTab.js'; describe('split scroll-sync preference', async () => { /* @@ -116,6 +126,23 @@ describe('split scroll-sync preference', async () => { tabManager.setSplitEnabled(id, false); assert.equal(tabManager.splitScrollSyncPreference, true); }); + + test('a splitter dragged past the end stops at the store bounds', () => { + // The move is unclamped and the store holds 0.1-0.9, so the two cannot + // disagree about where the bar stops. + reset(); + const { id, tab } = splitTab('/notes/dragged.md'); + + assert.equal(splitRatioAfterMove(0.9, 0.5, 'left'), 1.4); + tabManager.setSplitRatio(id, splitRatioAfterMove(0.9, 0.5, 'left')); + assert.equal(tab().splitRatio, 0.9); + + assert.equal(splitRatioAfterMove(0.1, -0.5, 'left'), -0.4); + tabManager.setSplitRatio(id, splitRatioAfterMove(0.1, -0.5, 'right')); + assert.equal(tab().splitRatio, 0.6); + tabManager.setSplitRatio(id, splitRatioAfterMove(0.1, -0.5, 'left')); + assert.equal(tab().splitRatio, 0.1); + }); }); /** @@ -972,6 +999,66 @@ describe('outline document passes', () => { }); }); +/* + * The viewer's effects call these helpers. Each skips a reactive read the way + * the inline condition it replaced did, so the effect does not subscribe to a + * value that cannot change its outcome. + */ +describe('viewer effect helpers read only what decides them', () => { + /** Runs `read` in an effect; returns how many times it has run. */ + function runs(read: () => void) { + let count = 0; + const stop = runeEffect(() => { + count++; + read(); + }); + onTestFinished(stop); + flushSync(); + assert.equal(count, 1, 'the effect ran'); + return () => count; + } + + test('split view does not read the outline toggle', () => { + const s = runeProps({ isSplit: true, isEditing: true, showToc: false, rawContent: 'x' }); + const count = runs(() => refreshesPreviewCache(s, () => s.isEditing, () => s.showToc)); + s.showToc = true; + flushSync(); + assert.equal(count(), 1); + s.isSplit = false; + flushSync(); + s.showToc = false; + flushSync(); + assert.equal(count(), 3, 'out of split view the toggle is read'); + }); + + test('the Home overlay does not read the active tab', () => { + const s = runeProps({ showHome: true, tab: { path: '/a.md' } as { path: string } | undefined }); + const count = runs(() => showsHomeScreen(() => s.tab, s.showHome)); + s.tab = { path: '/b.md' }; + flushSync(); + assert.equal(count(), 1); + }); + + test('a pinned outline does not read the overhang', () => { + const s = runeProps({ pinned: true, overhanging: false }); + const count = runs(() => tocAutoCollapses(() => s.overhanging, s.pinned)); + s.overhanging = true; + flushSync(); + assert.equal(count(), 1); + }); + + test('the preview patch does not subscribe to what it reads', () => { + const host = document.createElement('div'); + const s = runeProps({ folds: new Set() }); + let html = '

a

'; + const count = runs(() => patchHost(host, html, () => ({ filePath: '/a.md', foldOverrides: s.folds }), () => {})); + html = '

b

'; + s.folds = new Set(['x']); + flushSync(); + assert.equal(count(), 1); + }); +}); + describe('outline render signal', () => { /** * The outline reads the preview's DOM, so it has to be told when that DOM holds @@ -1022,3 +1109,222 @@ describe('outline render signal', () => { toc.stop(); }); }); + +describe('fold layout', () => { + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + test('fold layout observes rendered content and publishes its measured height', () => { + // The wrapper animates `height: var(--fold-content-height)`, so it opens to + // the height its content measures, and re-measures when that content + // changes size (an image loading, a diagram drawing, the window narrowing). + const frames: FrameRequestCallback[] = []; + const observers: ResizeObserverCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + observers.push(callback); + } + observe() {} + disconnect() {} + }, + ); + const runFrames = () => frames.splice(0).forEach((frame) => frame(0)); + + document.body.innerHTML = + '

x

'; + const root = document.querySelector('.markdown-body')!; + const wrapper = root.querySelector('.foldable-content-wrapper')!; + let height = 120; + root.querySelector('.content-inner')!.getBoundingClientRect = () => ({ height }) as DOMRect; + + const layout = observeFoldLayout(root); + runFrames(); + assert.equal(wrapper.style.getPropertyValue('--fold-content-height'), '120px'); + + height = 200; + for (const callback of observers) callback([], {} as ResizeObserver); + runFrames(); + assert.equal(wrapper.style.getPropertyValue('--fold-content-height'), '200px'); + layout.stop(); + }); +}); + +describe('fold layout observation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // The fold layout used to be re-measured from the whole article on every + // patch, and `ResizeObserver.observe` on a target it already watches + // re-registers it and delivers a fresh observation. So a keystroke must hand + // the observer only what the patch inserted. + test('only newly patched blocks are handed to the fold observer', () => { + const observed: Element[] = []; + vi.stubGlobal('requestAnimationFrame', () => 0); + vi.stubGlobal( + 'ResizeObserver', + class { + observe(target: Element) { + observed.push(target); + } + disconnect() {} + }, + ); + const host = document.createElement('div'); + document.body.replaceChildren(host); + const doc = (last: string) => + [ + '

A

', + '

a

', + '

B

', + `

${last}

`, + ].join('\0\n'); + const tab = () => ({ filePath: '/docs/a.md', foldOverrides: new Set() }); + const layout = observeFoldLayout(host); + + patchHost(host, doc('b'), tab, (scope) => layout.observe(scope)); + const folds = [...host.querySelectorAll('.foldable-content-wrapper > .content-inner')]; + assert.equal(folds.length, 2); + assert.deepEqual(new Set(observed.splice(0)), new Set(folds), 'a first render observes every fold'); + + patchHost(host, doc('b, edited'), tab, (scope) => layout.observe(scope)); + const edited = [...host.querySelectorAll('p')].find((p) => p.textContent === 'b, edited')!; + assert.ok(observed.every((target) => edited.contains(target)), 'only the edited block is observed'); + layout.stop(); + }); + + /** A host with one fold, a recording ResizeObserver, and the effect following it. */ + function follow(initial: { host: 'a' | 'b' | null; shown: boolean }) { + const observed: Element[] = []; + let disconnects = 0; + vi.stubGlobal('requestAnimationFrame', () => 0); + vi.stubGlobal( + 'ResizeObserver', + class { + observe(target: Element) { + observed.push(target); + } + disconnect() { + disconnects++; + } + }, + ); + const article = document.createElement('article'); + const fold = '

x

'; + article.innerHTML = `
${fold}
`; + document.body.replaceChildren(article); + const hosts = { a: article.querySelector('#a')!, b: article.querySelector('#b')! }; + const state = runeProps(initial); + const holder: { current: FoldLayoutObservation | null } = { current: null }; + const dispose = runeEffect(() => + followFoldLayout(holder, () => (state.host ? hosts[state.host] : null), () => state.shown), + ); + flushSync(); + return { state, holder, hosts, observed, disconnects: () => disconnects, dispose }; + } + + test('preview lifecycle starts and cleans up fold observation', () => { + const run = follow({ host: 'a', shown: true }); + assert.ok(run.holder.current, 'the observation is published for the patch'); + assert.deepEqual(run.observed, [run.hosts.a.querySelector('.content-inner')]); + + // Switching tabs moves the observation to the newly visible host. + run.state.host = 'b'; + flushSync(); + assert.equal(run.disconnects(), 1); + assert.equal(run.observed.at(-1), run.hosts.b.querySelector('.content-inner')); + + run.dispose(); + assert.equal(run.disconnects(), 2); + assert.equal(run.holder.current, null); + }); + + test('fold measurement pauses while the preview pane is hidden by edit mode', () => { + assert.equal(previewPaneShown(true, false), false, 'edit mode hides the preview'); + assert.equal(previewPaneShown(true, true), true, 'split keeps it'); + assert.equal(previewPaneShown(false, false), true); + + const run = follow({ host: 'a', shown: false }); + assert.equal(run.holder.current, null); + assert.deepEqual(run.observed, []); + + run.state.shown = true; + flushSync(); + assert.ok(run.holder.current); + run.state.shown = false; + flushSync(); + assert.equal(run.holder.current, null); + assert.equal(run.disconnects(), 1); + run.dispose(); + }); +}); + +describe('outline overlay placement', () => { + test('the outline collapses itself only when it is in the way', () => { + const settings = { previewMaxWidth: 880, previewFullWidth: false, tocSide: 'left' as const, splitEditorSide: 'left' as const, tocWidth: 240 }; + const wide = previewLayout(settings, { isEditing: false, isSplit: false, viewerWidth: 1400 }); + const narrow = previewLayout(settings, { isEditing: false, isSplit: false, viewerWidth: 1000 }); + // A window wide enough to hold the outline beside the text keeps it open. + assert.equal(tocAutoCollapses(() => wide.isOverhanging, false), false); + assert.equal(tocAutoCollapses(() => narrow.isOverhanging, false), true); + // Pinned, it is a sidebar and stays. + assert.equal(tocAutoCollapses(() => narrow.isOverhanging, true), false); + + // Click-outside must not fight the toggle button, which owns its own click. + const panel = document.createElement('nav'); + const handle = panel.appendChild(document.createElement('div')); + const toggle = document.createElement('button'); + const icon = toggle.appendChild(document.createElement('span')); + const text = document.createElement('p'); + assert.equal(dismissesToc(icon, panel, toggle), false); + assert.equal(dismissesToc(handle, panel, toggle), false); + assert.equal(dismissesToc(text, panel, toggle), true); + assert.equal(dismissesToc(null, panel, toggle), false); + }); +}); + +describe('outline render signal', () => { + test('the revision is published by the patch, not by the render', () => { + // A signal derived from the rendered string rather than from the write to + // the DOM is the bug: on a mount the outline scanned an article the patch + // had not filled yet, and the string never changed again to make it look. + const state = runeProps({ host: null as HTMLElement | null, html: '

One

' }); + let published: { readonly revision: number } | null = null; + const seen: string[] = []; + const dispose = runeEffect(() => { + published = followPreviewPatch( + () => state.host, + () => state.html, + (host, html) => (host.innerHTML = html), + ); + }); + flushSync(); + const revision = () => published!.revision; + const watch = runeEffect(() => { + void revision(); + seen.push(state.host?.textContent ?? ''); + }); + flushSync(); + onTestFinished(() => (watch(), dispose())); + + state.html = '

Two

'; + flushSync(); + assert.equal(revision(), 0, 'a render with nowhere to patch it publishes nothing'); + + state.host = document.createElement('article'); + flushSync(); + assert.equal(revision(), 1); + assert.equal(seen.at(-1), 'Two', 'once published, the article already holds the document'); + + state.html = '

Three

'; + flushSync(); + assert.equal(revision(), 2); + assert.equal(seen.at(-1), 'Three'); + }); +}); diff --git a/scripts/layout.test.ts b/scripts/layout.test.ts index 75bb9fbc..f7b9d8ef 100644 --- a/scripts/layout.test.ts +++ b/scripts/layout.test.ts @@ -4,9 +4,10 @@ import { describe, test } from 'node:test'; import { asBufferLine, asRendererLine, lineCoordinates, tabAnchorForEditorTopLine } from '../src/lib/utils/lineCoordinates.js'; import { outwardSign, planPaneSlide } from '../src/lib/utils/paneSlide.js'; import { getScrollSyncPositionFromPixels, getScrollTopForSyncPosition, type ScrollSyncPosition } from '../src/lib/utils/scrollSync.js'; -import { splitRatioAfterMove } from '../src/lib/utils/splitPanes.ts'; +import { splitRatioAfterDrag, splitRatioAfterKey, splitRatioAfterMove } from '../src/lib/utils/splitPanes.ts'; import { activeTocIdForLine, sourceLineOf, type TocLineEntry } from '../src/lib/utils/tocFollow.js'; import { isTocOverhanging, isTocOverPreview } from '../src/lib/utils/tocOverlay.ts'; +import { bindScrollSync } from '../src/lib/utils/editorJumps.js'; describe('scroll sync section mapping', () => { // These tests import and run the mapping. The previous scroll-sync tests read @@ -218,6 +219,19 @@ describe('split pane ratio', () => { assert.equal(splitRatioAfterMove(0.5, 0.1, 'right'), 0.4); assert.equal(splitRatioAfterMove(0.5, -0.1, 'right'), 0.6); }); + + test('both splitter routes take the direction from the same place', () => { + // A splitter that follows the pointer one way and the arrow keys the other + // is invisible until someone swaps the panes. + for (const side of ['left', 'right'] as const) { + const dragged = splitRatioAfterDrag(0.5, 400, 450, 1000, side); + const keyed = splitRatioAfterKey(0.5, 'ArrowRight', side)!; + assert.equal(Math.sign(dragged - 0.5), Math.sign(keyed - 0.5), side); + assert.equal(Math.sign(keyed - 0.5), side === 'left' ? 1 : -1, side); + assert.equal(Math.sign(splitRatioAfterKey(0.5, 'ArrowLeft', side)! - 0.5), -Math.sign(keyed - 0.5), side); + } + assert.equal(splitRatioAfterKey(0.5, 'Enter', 'left'), null); + }); }); describe('pane slide on view mode switch', () => { @@ -706,3 +720,28 @@ describe('outline overlay placement', () => { ); }); }); + +describe('scroll sync input', () => { + test('typing does not initiate split scroll synchronization', () => { + // The editor-to-preview sync listens to scrolling. A caret move by itself, + // which every keystroke is, must not drag the other pane along. + const listeners: Record void)[]> = { scroll: [], cursor: [] }; + const on = (kind: string) => (listener: (e: unknown) => void) => { + listeners[kind].push(listener); + return { dispose: () => listeners[kind].splice(listeners[kind].indexOf(listener), 1) }; + }; + const editor = { onDidScrollChange: on('scroll'), onDidChangeCursorPosition: on('cursor') }; + let synced = 0; + const binding = bindScrollSync(editor as never, () => synced++); + + for (const listener of listeners.cursor) listener({ position: { lineNumber: 2, column: 4 } }); + for (const listener of listeners.scroll) listener({ scrollTopChanged: false, scrollLeftChanged: true }); + assert.equal(synced, 0); + + for (const listener of listeners.scroll) listener({ scrollTopChanged: true }); + assert.equal(synced, 1); + + binding.dispose(); + assert.deepEqual(listeners, { scroll: [], cursor: [] }); + }); +}); diff --git a/scripts/layout.toc.spec.ts b/scripts/layout.toc.spec.ts new file mode 100644 index 00000000..60addd97 --- /dev/null +++ b/scripts/layout.toc.spec.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; + +import { afterEach, describe, onTestFinished, test, vi } from 'vitest'; + +import { asRendererLine } from '../src/lib/utils/lineCoordinates.js'; +import { buildTocEntries, tocFingerprint } from '../src/lib/utils/tocEntries.js'; + +describe('outline follows the editor', async () => { + (window as any).__TAURI_INTERNALS__ = { invoke: async (cmd: string) => (cmd === 'get_os_type' ? 'macos' : null) }; + const { settings } = await import('../src/lib/stores/settings.svelte.js'); + const { CLICK_LOCK_MS, TocNavigator } = await import('../src/lib/stores/tocNavigator.svelte.js'); + + const DOC = + '

Intro

' + + '

text

' + + '' + + '

Details

'; + + function preview(html = DOC) { + const markdownBody = document.createElement('article'); + markdownBody.innerHTML = html; + markdownBody.scrollTo = vi.fn() as any; + document.body.replaceChildren(markdownBody); + return markdownBody; + } + + /** The outline's own list, one button per entry, as Toc.svelte renders it. */ + function outlineList(ids: string[]) { + const container = document.createElement('div'); + const scrolled: { id: string; options: unknown }[] = []; + for (const id of ids) { + const button = document.createElement('button'); + button.dataset.id = id; + button.scrollIntoView = ((options: unknown) => scrolled.push({ id, options })) as any; + container.append(button); + } + return { container, scrolled }; + } + + function setup() { + const markdownBody = preview(); + const items = buildTocEntries(markdownBody); + const list = outlineList(items.map((item) => item.id)); + const outline = new TocNavigator(() => list.container); + const stop = outline.listen(markdownBody); + onTestFinished(stop); + const jump = (id: string) => outline.jumpTo(id, { contentRoot: markdownBody, markdownBody, items }); + return { markdownBody, items, list, outline, jump }; + } + + afterEach(() => { + vi.useRealTimers(); + }); + + test('the outline carries a source line for every entry it builds', () => { + // Both kinds — headings and block anchors — or the mapping above is asked + // about entries it cannot place. + const entries = buildTocEntries(preview()); + assert.deepEqual( + entries.map((entry) => [entry.id, entry.isBlock, entry.line]), + [ + ['intro', false, 1], + ['^note', true, 5], + ['details', false, 7], + ], + ); + // The fingerprint decides whether a re-render replaces the items. An edit + // that only moves a heading changes nothing else about it, so without the + // line in there the outline would keep answering with the old positions. + const moved = buildTocEntries(preview(DOC.replace('7:1-7:8', '9:1-9:8'))); + assert.notEqual(tocFingerprint(moved), tocFingerprint(entries)); + }); + + test('a click still wins over the editor, as it does over the preview', () => { + // `clickLock` holds the entry the user picked until the jump's smooth scroll + // settles. Without it, clicking an entry would highlight it and then lose it + // to the scroll the click itself caused. + vi.useFakeTimers(); + const { items, outline, jump } = setup(); + jump('details'); + outline.follow(asRendererLine(1), () => items); + assert.equal(outline.activeId, 'details'); + + vi.advanceTimersByTime(CLICK_LOCK_MS); + outline.follow(asRendererLine(1), () => items); + assert.equal(outline.activeId, 'intro'); + }); + + test('the outline no longer measures the preview to decide', () => { + // The loop this replaces did a `querySelector` and a `getBoundingClientRect` + // per visible entry on every scroll event. + vi.useFakeTimers(); + const { markdownBody, jump } = setup(); + jump('intro'); + vi.advanceTimersByTime(CLICK_LOCK_MS); + const target = markdownBody.querySelector('#intro')!; + assert.ok(target.classList.contains('toc-target-active')); + + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + const query = vi.spyOn(Element.prototype, 'querySelector'); + try { + markdownBody.dispatchEvent(new Event('scroll')); + assert.equal(rect.mock.calls.length, 0); + assert.equal(query.mock.calls.length, 0); + } finally { + rect.mockRestore(); + query.mockRestore(); + } + // What it still does: a click leaves a highlight on its target, and the + // next scroll takes it off. + assert.ok(!target.classList.contains('toc-target-active')); + }); + + test('the jump highlight ends on anything that means the reader moved on', () => { + // The jump's own smooth scroll is swallowed by the lock, so scrolling alone + // would leave it on whenever the reader does not scroll. A pointerdown in the + // preview or a keystroke anywhere ends it, and the lock does not apply. + vi.useFakeTimers(); + const { markdownBody, jump } = setup(); + const target = markdownBody.querySelector('#details')!; + + jump('details'); + markdownBody.dispatchEvent(new Event('scroll')); + assert.ok(target.classList.contains('toc-target-active'), 'the jump’s own scroll keeps it'); + markdownBody.dispatchEvent(new Event('pointerdown')); + assert.ok(!target.classList.contains('toc-target-active'), 'pointerdown clears it'); + + jump('details'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + assert.ok(!target.classList.contains('toc-target-active'), 'keydown clears it'); + }); + + test('the current entry is centred in the outline, not merely kept on screen', () => { + // `block: 'nearest'` scrolls the least it can: the highlight walked down to + // the last visible row and stayed pinned there. + const { items, list, outline } = setup(); + outline.follow(asRendererLine(7), () => items); + assert.equal(outline.activeId, 'details'); + assert.deepEqual(list.scrolled, [{ id: 'details', options: { block: 'center', behavior: 'smooth' } }]); + }); + + test('an outline click is a jump, so it asks the same preference', () => { + // #199: the outline was the one jump the preference never reached. + const original = settings.animateJumpScroll; + try { + for (const animate of [false, true]) { + settings.animateJumpScroll = animate; + const { markdownBody, jump } = setup(); + jump('details'); + const [[options]] = (markdownBody.scrollTo as any).mock.calls; + assert.equal(options.behavior, animate ? 'smooth' : 'auto'); + } + } finally { + settings.animateJumpScroll = original; + } + }); +}); diff --git a/scripts/navigation.checks.test.ts b/scripts/navigation.checks.test.ts index 0945c798..a098a627 100644 --- a/scripts/navigation.checks.test.ts +++ b/scripts/navigation.checks.test.ts @@ -1,30 +1,10 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { readSource, functionSource, sliceFrom } from './sourceTree.js'; -import { viewerCommandFor } from '../src/lib/utils/viewerKeymap.js'; -import { viewerCommandTable } from './keymapHarness.js'; +import { readSource } from './sourceTree.js'; const { frontMatterLineOffset } = await import('../src/lib/utils/frontMatter.js'); -describe('file history', () => { - // History is a list of PATHS. `createFileHistory` used to take the tab's - // initial content as an ignored second parameter, and all three callers passed - // one — which is how `addNewTab` came to seed a history with a content string - // in it. A source assertion because that is the whole of what the parameter - // was: nothing reads it, so no behaviour can fail when it comes back, and - // `Function.length` cannot see an optional one either. - test('no caller hands a file history anything but a path', () => { - const declaration = readSource('src/lib/utils/tabHistory.ts').match(/export function createFileHistory\([^)]*\)/); - assert.ok(declaration); - assert.equal(declaration[0], 'export function createFileHistory(path: string)'); - - for (const call of readSource('src/lib/stores/tabs.svelte.ts').matchAll(/createFileHistory\([^)]*\)/g)) { - assert.doesNotMatch(call[0], /,/, `${call[0]} passes something that is not a path`); - } - }); -}); - describe('preview anchor restore wiring', () => { /** * The assertions below are source assertions, not behavior assertions: the @@ -63,138 +43,15 @@ describe('preview anchor restore wiring', () => { assert.doesNotMatch(viewer, /tab\.scrollPercentage/); assert.doesNotMatch(viewer, /Array\.from\(body\.children\)/); }); - - /** - * The cold start: the rich-content libraries arrive after the document is - * already in the preview and after the position has been restored against it. - * - * Measured in Chromium — the numbers and the method are in the patch effect's - * comment in `MarkdownViewer.svelte` — the anchored text moves down by up to - * 812px when the enrichment lands, and re-running this cascade afterwards - * brings it back to within 0.8px. jsdom has no layout, so that magnitude cannot - * be asserted here and this is a source assertion like the one above: what it - * pins is that the second restore is wired up at all, and that nothing has - * quietly gone back to the arrangement that had no owner for this case. - */ - test('a cold start restores again once its enrichment has landed', async () => { - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - // The patch effect is the owner: a host's first enrichment is the one that - // imports the libraries its document needs, so it lands after the restore. - assert.match( - viewer, - /const cold = patch\.inserted\.length > 0 && !enrichedHosts\.has\(host\)/, - 'the patch effect must recognise the first enrichment of a host', - ); - assert.match( - viewer, - /if \(cold\) restoreAfterColdEnrichment\(/, - 'and only that run may re-restore, or every no-op patch would move the reader', - ); - - // The restore itself goes through the shared cascade, waits for the - // enrichment, and speaks only for the tab still on screen. - const helper = viewer.match( - /async function restoreAfterColdEnrichment\([\s\S]*?\n\t\}/, - )?.[0]; - assert.ok(helper, 'restoreAfterColdEnrichment must exist'); - assert.match(helper, /await enrichment;/); - assert.match(helper, /tabManager\.activeTabId !== tabId/); - assert.match(helper, /restorePreviewReadingPosition\(body, tab,/); - - // And the accident that used to stand in for it must not come back. The - // theme effect's `renderRichContent()` reads reactive state before its - // first await, so without `untrack` that effect re-runs on unrelated - // changes — re-enriching every open tab's host on top of the pass above. - // - // Matched on the `untrack`, not on the guard beside it: the guard was - // `markdownBody && !isEditing` when this was written and is `markdownBody` - // alone now (scripts/preview.spec.ts owns that question). What - // this test is for is the tracking. - assert.match( - viewer, - /untrack\(\(\) => \{? ?if \(markdownBody\) renderRichContent\(\);/, - 'the theme effect must depend on the theme, not on the libraries arriving', - ); - }); }); describe('jump to the selected fragment wiring', () => { - const editorSource = readSource(new URL('../src/lib/components/Editor.svelte', import.meta.url)); const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - /* ------------------------------------------------------------------ */ - /* the jump itself */ - /* ------------------------------------------------------------------ */ - - test('the outline and the context menu share one line-to-editor jump', () => { - // `revealHeader` used to reveal and select the line itself. Leaving that - // copy in place while #90 grew a second one is the drift - // release.checks.test.ts exists to catch: the two would - // scroll differently, focus differently, and only one of them would clamp. - const revealHeader = functionSource(editorSource, 'revealHeader'); - assert.match(revealHeader, /revealSourceRange\(lineNumber, lineNumber, 'top'\)/); - assert.doesNotMatch(revealHeader, /setSelection\(\{[\s\S]*?startColumn: 1/); - }); - - test('the jump clamps to the buffer before asking Monaco for a column', () => { - // `getLineMaxColumn` throws past the end of the model, and the preview can - // hand over a stale line: its HTML is the render of a buffer that may have - // been replaced by a shorter one since. - const reveal = functionSource(editorSource, 'revealSourceRange'); - assert.match(reveal, /lastLine = model\.getLineCount\(\)/); - assert.match(reveal, /end = Math\.min\([^\n]*lastLine\)/); - assert.doesNotMatch(reveal, /getLineMaxColumn\((?!end\))/); - }); - - test('a jump asked for before Monaco has loaded is queued, not dropped', () => { - // `monaco-editor` is imported dynamically, so the editor exists several - // frames after the component does — and the preview calls in immediately - // after flipping into edit mode. - const reveal = functionSource(editorSource, 'revealSourceRange'); - assert.match(reveal, /if \(!editorReady \|\| !editor\) \{\s*\n\s*pendingReveal = \{ startLine, endLine, placement \};/); - - // Spent after the view-state / anchor-line restore, so an explicit "edit - // this fragment" wins over the position the tab was left at. - const afterReady = sliceFrom(editorSource, 'editorReady = true;'); - assert.ok( - afterReady.indexOf('pendingReveal') < afterReady.indexOf('return () => {'), - 'the queued jump must be spent inside onMount, before the teardown closure', - ); - }); - /* ------------------------------------------------------------------ */ /* the context menu wiring */ /* ------------------------------------------------------------------ */ - test('the Edit entry resolves its target while the selection still exists', () => { - // Resolving inside the `onClick` would read the selection AFTER the reader - // clicked a menu item, and a click is how a selection goes away. - const handler = functionSource(viewerSource, 'handleContextMenu'); - assert.match(handler, /const editSourceTarget = getContextMenuSourceRange\(e\);/); - assert.match(handler, /t\('menu\.edit', settings\.language\), onClick: \(\) => editSourceRange\(editSourceTarget\)/); - assert.doesNotMatch(handler, /onClick: \(\) => toggleEdit\(\)/); - }); - - test('Edit with a target never leaves edit mode', () => { - // In split view the editor is already on screen, and "edit this fragment" - // is the one thing that cannot mean "close the editor". - const edit = functionSource(viewerSource, 'editSourceRange'); - assert.match(edit, /if \(!isEditing\) await toggleEdit\(\)/, 'entered only when not already editing'); - // And a read that failed leaves the tab in reading mode — arming the jump - // anyway would fire it at whatever document is edited next. - assert.match(edit, /tabManager\.activeTab\?\.isEditing/); - }); - - test('the target is a source range and never a column', () => { - // `parseSourceposLineRange` is the only reader of the attribute, and it - // keeps line numbers only. Nothing in the preview may take the `:col` half - // and aim Monaco with it — see the header of this file for why. - const resolve = functionSource(viewerSource, 'getContextMenuSourceRange'); - assert.match(resolve, /findSourceLineRange\(/); - assert.doesNotMatch(resolve, /startColumn|endColumn/); - }); - test('the real stress document shifts by its front matter', () => { // The fixture the reporter of the offset was reading: 10 lines of front // matter plus the blank line after it, heading on buffer line 12. @@ -203,55 +60,6 @@ describe('jump to the selected fragment wiring', () => { assert.equal(raw.split('\n')[11], '# Markdown Reader Stress Test'); }); - test('every renderer line reaching the editor goes through the shift', () => { - // Two consumers hand a `data-sourcepos` number to the editor, and both have - // to shift it. `toggleEditView` is not a third: it resolves the range and - // passes it to `editSourceRange`, which is where the shift happens. - const edit = functionSource(viewerSource, 'editSourceRange'); - assert.match(edit, /pendingEditReveal = lineCoords\.toBufferRange\(range\)/, 'the context menu and ⌘E shift'); - - // The outline is wired inline in the markup rather than in a function. - assert.match( - viewerSource, - /editorPane\.revealHeader\(\s*sourceLine === null \? null : lineCoords\.toBufferLine\(/, - 'the outline shifts', - ); - }); - - // Scroll sync crosses the same boundary in BOTH directions, and it was the - // consumer this file forgot: `ScrollSyncPosition.line` is a buffer line, - // because the editor is the only pane that can produce one, while everything - // the preview answers with counts from the body. On a document with front - // matter the panes drifted apart by exactly that many lines — a constant - // offset, unaffected by how precise the block mapping underneath got. - - test('scroll sync converts in both directions', () => { - const capture = functionSource(viewerSource, 'getPreviewScrollSyncPosition'); - assert.match( - capture, - /lineCoords\.bufferLineAtPreviewOffset\(/, - 'a line read off the preview is shifted before the editor sees it', - ); - - const apply = functionSource(viewerSource, 'scrollPreviewToSyncPosition'); - assert.match( - apply, - /lineCoords\.previewOffsetForBufferLine\(/, - 'a line coming from the editor is shifted back before the preview seeks', - ); - }); - - test('the outline is fed body lines from both panes', () => { - // `tocActiveLine` has two writers. The preview's already answers in body - // lines (`getPreviewScrollAnchor`); the editor's has to be converted, or the - // highlighted heading changes depending on which pane you scrolled. - const fromEditor = functionSource(viewerSource, 'handleEditorScrollSync'); - assert.match(fromEditor, /followToc\('editor', tabAnchorForEditorTopLine\(lineCoords, asBufferLine\(position\.line\)\)\)/); - - const fromPreview = functionSource(viewerSource, 'getPreviewScrollAnchor'); - assert.doesNotMatch(fromPreview, /toBufferLine|toRendererLine/, 'already a body line'); - }); - test('the shift reads the buffer, not the rendered body', () => { // `lineCoordinates(rawContent)` — measuring the render would answer 0 // forever, since the render is what the front matter was stripped out of. @@ -260,62 +68,4 @@ describe('jump to the selected fragment wiring', () => { assert.match(viewerSource, /let lineCoords = \$derived\(lineCoordinates\(rawContent\)\)/); assert.equal(viewerSource.match(/lineCoordinates\(/g)?.length, 1); }); - - // The menu is opened BY right-clicking a selection, and two of its items act - // on that selection (Copy, Edit). Anything that moves focus off the document - // stops the selection being painted, so the highlight disappears under the - // menu that exists to act on it. - - test('the context menu does not take focus away from the selection', () => { - const menuSource = readSource('src/lib/components/ContextMenu.svelte'); - assert.doesNotMatch(menuSource, /\.focus\(\)/, 'focusing the menu unpaints the selection'); - - // Escape still closes it — from the window, since the menu never has focus. - assert.match(menuSource, /window\.addEventListener\('keydown'/); - assert.match(menuSource, /e\.key === 'Escape'/); - }); - - test('the selection is read before anything switches mode', () => { - // `toggleEdit` and `editSourceRange` both flip `isEditing`, and a selection - // read after that would answer for the editor rather than for the preview - // the reader was looking at. - const view = functionSource(viewerSource, 'toggleEditView'); - assert.match(view, /const selected = getSelectionSourceRange\(\);/); - assert.ok( - view.indexOf('const selected') < view.indexOf('toggleEdit()'), - 'resolved first, then the mode changes', - ); - }); - - test('one function owns what ⌘E means, and every entry point uses it', () => { - // The hotkey, the toolbar, the title bar and Monaco's own command all route - // here, so the chord cannot mean one thing with the caret in the editor and - // another with it in the preview — which is what - // `shortcuts.checks.test.ts` pins from the other side. - // The chord is a call now, not a pattern: the branch structure moved to - // `viewerKeymap.ts` and #647 had already shown what the old spelling cost — - // five assertions of this shape went red for a renamed local. - assert.equal( - viewerCommandFor( - { key: 'e', code: 'KeyE', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false }, - { mode: 'app', osType: 'windows', isSplit: false, overlayOpen: false, dialogOpen: false, isEditing: false, editorHasFocus: false }, - ), - 'toggle-edit-view', - ); - assert.match(viewerCommandTable()['toggle-edit-view'], /toggleEditView\(\)/); - // The editor entry point hands Monaco's ⌘E to the same switch. - assert.match(viewerSource, /oncommand=\{runViewerCommand\}/, 'the editor entry point'); - // The title bar's Edit button, now one of three mode buttons (#806). - assert.match(functionSource(viewerSource, 'setViewMode'), /target === 'edit' \? toggleEditView\(\)/); - }); - - test('split view with nothing selected is deliberately inert', () => { - // The editor is already on screen, so the ability ⌘E asks for is already - // granted; with no selection there is nothing to travel to either. Closing - // the preview would be a layout change nobody asked for, and a mistyped ⌘E - // would cost the reader the pane. - const view = functionSource(viewerSource, 'toggleEditView'); - assert.match(view, /if \(isSplit && !selected\) \{[\s\S]*?return;/); - assert.doesNotMatch(view, /setSplitEnabled/, 'the chord never changes the layout'); - }); }); diff --git a/scripts/navigation.spec.ts b/scripts/navigation.spec.ts index 3d4dc78f..c2357631 100644 --- a/scripts/navigation.spec.ts +++ b/scripts/navigation.spec.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { flushSync, mount, unmount } from 'svelte'; import { afterAll, beforeAll, describe, test } from 'vitest'; import { anchorScrollTop, previewZoomFactor, PREVIEW_ANCHOR_OFFSET } from '../src/lib/utils/previewAnchor.js'; @@ -30,6 +31,7 @@ const { tabAnchorForEditorTopLine, EDITOR_ANCHOR_LINE_OFFSET, } = await import('../src/lib/utils/lineCoordinates.js'); +const { default: ContextMenu } = await import('../src/lib/components/ContextMenu.svelte'); describe('anchor jump under zoom', () => { /* @@ -1440,3 +1442,39 @@ describe('tab anchor across panes', () => { tabManager.updateTabAnchorLine(tab.id, 812); }); }); + +describe('the preview context menu', () => { + // The menu is opened BY right-clicking a selection, and two of its items act + // on that selection (Copy, Edit). Anything that moves focus off the document + // stops the selection being painted, so the highlight disappears under the + // menu that exists to act on it. + test('the context menu does not take focus away from the selection', () => { + const selected = document.createElement('textarea'); + selected.value = 'some selected text'; + document.body.append(selected); + selected.focus(); + selected.setSelectionRange(5, 13); + + let hidden = 0; + const target = document.createElement('div'); + document.body.append(target); + const menu = mount(ContextMenu, { + target, + props: { show: true, x: 10, y: 10, items: [{ label: 'Copy', onClick: () => {} }], onhide: () => hidden++ }, + }); + flushSync(); + try { + assert.ok(target.querySelector('.context-menu'), 'the menu is open'); + assert.equal(document.activeElement, selected, 'focusing the menu unpaints the selection'); + assert.deepEqual([selected.selectionStart, selected.selectionEnd], [5, 13]); + + // Escape still closes it — from the window, since the menu never has focus. + selected.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(hidden, 1, 'Escape closes the menu without it having focus'); + } finally { + unmount(menu); + target.remove(); + selected.remove(); + } + }); +}); diff --git a/scripts/navigation.test.ts b/scripts/navigation.test.ts index f613566c..ad50484c 100644 --- a/scripts/navigation.test.ts +++ b/scripts/navigation.test.ts @@ -12,6 +12,10 @@ import { } from '../src/lib/utils/tabHistory.js'; import { getMarkdownLinkTarget, isOpenInNewTabMarkdownTarget, resolveMarkdownTargetPath } from '../src/lib/utils/markdownLinks.js'; import { jumpScrollBehavior } from '../src/lib/utils/motion.js'; +import { EditorJumps, type JumpEditor } from '../src/lib/utils/editorJumps.js'; +import { asBufferLine } from '../src/lib/utils/lineCoordinates.js'; +import { viewerCommandFor } from '../src/lib/utils/viewerKeymap.js'; +import { viewerCommandCalls } from './keymapHarness.js'; describe('file history and markdown link targets', () => { test('new file tabs start history with their path', () => { @@ -21,6 +25,16 @@ describe('file history and markdown link targets', () => { }); }); + // History is a list of PATHS. `createFileHistory` used to take the tab's + // initial content as an ignored second parameter, which is how `addNewTab` + // came to seed a history with a content string in it. Nothing reads such a + // parameter, so only the type checker can refuse it: with it back, this + // directive is unused and `npm run check` fails. + test('no caller hands a file history anything but a path', () => { + // @ts-expect-error a history is built from a path alone + assert.deepEqual(createFileHistory('/notes/a.md', '# a'), { history: ['/notes/a.md'], historyIndex: 0 }); + }); + // Entries exist to be re-opened, and an untitled buffer cannot be. See the // behaviour this protects in scripts/navigation.spec.ts. test('an untitled tab starts with no history entry at all', () => { @@ -151,3 +165,161 @@ describe('jump scroll motion', () => { assert.equal(jumpScrollBehavior(false), 'auto'); }); }); + +describe('jump to the selected fragment', () => { + /** A Monaco editor over `lines`, recording what a jump does to it. */ + function fakeEditor(lines: string[]) { + const log: unknown[] = []; + const model = { + getLineCount: () => lines.length, + getLineMaxColumn: (line: number) => { + // Monaco throws here too, for a line past the end of the model. + if (line < 1 || line > lines.length) throw new Error(`Illegal value for lineNumber: ${line}`); + return lines[line - 1].length + 1; + }, + findNextMatch: () => null, + }; + const editor = { + getModel: () => model, + revealLineNearTop: (line: number) => log.push(['top', line]), + revealLineInCenterIfOutsideViewport: (line: number) => log.push(['center', line]), + setSelection: (selection: unknown) => log.push(['select', selection]), + focus: () => log.push(['focus']), + } as unknown as JumpEditor; + return { editor, log }; + } + + function jumps(editor: JumpEditor, state = { ready: true }) { + return new EditorJumps({ + monaco: () => ({ editor: { ScrollType: { Smooth: 0, Immediate: 1 } } }) as never, + editor: () => editor, + ready: () => state.ready, + scrollTo: () => {}, + }); + } + + const lines = ['# One', 'text', '## Two']; + + test('the outline and the context menu share one line-to-editor jump', () => { + // `revealHeader` used to reveal and select the line itself. A second copy + // next to #90's would scroll differently, focus differently, and only one + // of them would clamp. + for (const line of [3, 40]) { + const outline = fakeEditor(lines); + const contextMenu = fakeEditor(lines); + jumps(outline.editor).revealHeader(asBufferLine(line), 'Two'); + jumps(contextMenu.editor).revealSourceRange(line, line, 'top'); + assert.deepEqual(outline.log, contextMenu.log, `line ${line}`); + } + }); + + test('the jump clamps to the buffer before asking Monaco for a column', () => { + // The preview can hand over a stale line: its HTML is the render of a + // buffer that may have been replaced by a shorter one since. + const { editor, log } = fakeEditor(lines); + jumps(editor).revealSourceRange(50, 60); + assert.deepEqual(log, [ + ['center', 3], + ['select', { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 7 }], + ['focus'], + ]); + }); + + test('a jump asked for before Monaco has loaded is queued, not dropped', () => { + // `monaco-editor` is imported dynamically, so the editor is ready several + // frames after the component exists, and the preview calls in right after + // flipping into edit mode. + const { editor, log } = fakeEditor(lines); + const state = { ready: false }; + const pane = jumps(editor, state); + pane.revealSourceRange(2, 2); + assert.deepEqual(log, []); + + state.ready = true; + pane.spendPending(); + assert.deepEqual(log, [ + ['center', 2], + ['select', { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 5 }], + ['focus'], + ]); + + pane.spendPending(); + assert.equal(log.length, 3, 'a queued jump is spent once'); + }); +}); + +describe('preview scroll sync across the front matter', async () => { + const { asBufferLine, lineCoordinates } = await import('../src/lib/utils/lineCoordinates.js'); + const { getPreviewScrollAnchor, getPreviewScrollSyncPosition, getPreviewScrollTopForSyncPosition } = await import( + '../src/lib/utils/previewScrollSync.js' + ); + const { PREVIEW_ANCHOR_OFFSET } = await import('../src/lib/utils/previewAnchor.js'); + type PreviewScroller = import('../src/lib/utils/previewScrollSync.js').PreviewScroller; + + // Scroll sync crosses the line boundary in BOTH directions: + // `ScrollSyncPosition.line` is a buffer line, because the editor is the only + // pane that can produce one, while everything the preview answers with + // counts from the body. On a document with front matter the panes drifted + // apart by exactly that many lines. + const FRONT_MATTER = 3; + const coords = lineCoordinates('---\na: 1\n---\n# body\n'); + + type FakeBlock = { nodeType: 1; tagName: string; childNodes: FakeBlock[]; getAttribute: (n: string) => string | null; top: number; height: number }; + const block = (line: number, top: number, height: number): FakeBlock => ({ + nodeType: 1, + tagName: 'P', + childNodes: [], + getAttribute: (name) => (name === 'data-sourcepos' ? `${line}:1-${line}:10` : null), + top, + height, + }); + /** Body lines 1, 5 and 9, and no front matter panel. */ + const SCROLL_MAX = 800; + function preview(scrollTop: number) { + return { + nodeType: 1, + tagName: 'DIV', + childNodes: [block(1, 100, 40), block(5, 300, 40), block(9, 500, 60)], + getAttribute: () => null, + scrollTop, + querySelector: () => null, + } as unknown as PreviewScroller; + } + const measure = (node: unknown) => ({ top: (node as FakeBlock).top, height: (node as FakeBlock).height }); + + test('scroll sync converts in both directions', () => { + // The preview at body line 5 tells the editor buffer line 8. + const captured = getPreviewScrollSyncPosition(preview(300), SCROLL_MAX, coords, measure); + assert.equal(captured.section, 'body'); + assert.equal(captured.line, 5 + FRONT_MATTER, 'a line read off the preview is shifted before the editor sees it'); + + // And the editor at buffer line 8 puts the preview on body line 5. + const target = getPreviewScrollTopForSyncPosition(preview(0), SCROLL_MAX, { section: 'body', ratio: 0, line: asBufferLine(5 + FRONT_MATTER) }, coords, measure); + assert.equal(target, 300, 'a line coming from the editor is shifted back before the preview seeks'); + }); + + test("the preview's reading position is already a body line", () => { + // The outline and the tab's anchor both count from the body, so the + // preview's answer is not shifted. + assert.equal(getPreviewScrollAnchor(preview(300 - PREVIEW_ANCHOR_OFFSET), measure), 5); + }); +}); + +describe('edit view toggle', () => { + test('one function owns what ⌘E means, and every entry point uses it', () => { + // The hotkey, the toolbar, the title bar and Monaco's own command all route + // to `toggleEditView`, so the chord cannot mean one thing with the caret in + // the editor and another with it in the preview. The title bar's Edit button + // runs in navigation.viewMode.spec.ts. The viewer passing `runViewerCommand` + // as the editor's `oncommand` prop is not tested: it needs MarkdownViewer + // mounted. + assert.equal( + viewerCommandFor( + { key: 'e', code: 'KeyE', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false }, + { mode: 'app', osType: 'windows', isSplit: false, overlayOpen: false, dialogOpen: false, isEditing: false, editorHasFocus: false }, + ), + 'toggle-edit-view', + ); + assert.deepEqual(viewerCommandCalls()['toggle-edit-view'], ['toggleEditView']); + }); +}); diff --git a/scripts/navigation.viewMode.spec.ts b/scripts/navigation.viewMode.spec.ts new file mode 100644 index 00000000..e2d0d274 --- /dev/null +++ b/scripts/navigation.viewMode.spec.ts @@ -0,0 +1,296 @@ +import assert from 'node:assert/strict'; + +import { afterEach, beforeAll, describe, test } from 'vitest'; + +// Importing the store boots the settings singleton, which asks for the OS type. +(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 { createViewMode } = await import('../src/lib/sessions/viewMode.svelte.js'); +const { createColdEnrichmentRestore } = await import('../src/lib/sessions/coldEnrichmentRestore.js'); +const { asBufferLine, EDITOR_ANCHOR_LINE_OFFSET, lineCoordinates } = await import('../src/lib/utils/lineCoordinates.js'); + +type Tab = NonNullable; +type ViewModeOptions = Parameters[0]; + +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')), + }; +}); + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ''; +}); + +/** Three lines of front matter: renderer line N is buffer line N + 3. */ +const FRONT_MATTER = 3; +const coords = lineCoordinates('---\na: 1\n---\n# body\n'); + +/* + * "Edit this fragment": the context menu's Edit, ⌘E and the title bar's Edit + * button, run through `sessions/viewMode` against the real tab store and a + * real jsdom selection. The doubles are the file read and the render. + */ +describe('jump to the selected fragment', () => { + type Fakes = { readCalls: string[]; readFails: boolean; duringRead: () => void }; + + function build(fakes: Fakes) { + return createViewMode({ + tabManager, + settings, + documentSession: { ensureFullContent: async () => true }, + flushBeforeLeavingEditableMode: async () => {}, + readFile: async (path: string) => { + fakes.readCalls.push(path); + fakes.duringRead(); + if (fakes.readFails) throw new Error('No such file or directory (os error 2)'); + return ['---\na: 1\n---\n# body\n', false, 'UTF-8']; + }, + renderTabPreviewFromRaw: async () => {}, + tick: async () => {}, + addToast: () => {}, + t: (key: string) => key, + editorPane: undefined, + markdownBody: undefined, + previewBlocks: undefined, + lineCoords: coords, + readRendererLine: () => '', + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0 }), + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => null, + } as ViewModeOptions); + } + + /** A saved, clean file in the given mode, and a preview with two blocks. */ + function setup(mode: 'read' | 'edit' | 'split' | 'edit+split') { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, '---\na: 1\n---\n# body\n'); + tab.isEditing = mode === 'edit' || mode === 'edit+split'; + tabManager.setSplitEnabled(tab.id, mode === 'split' || mode === 'edit+split'); + document.body.innerHTML = + '

first block

' + + '

the picked fragment

'; + const fakes: Fakes = { readCalls: [], readFails: false, duringRead: () => {} }; + return { tab, fakes, viewMode: build(fakes) }; + } + + const select = (id: string) => window.getSelection()!.selectAllChildren(document.getElementById(id)!); + const rightClickOn = (id: string) => ({ target: document.getElementById(id)!.firstChild }) as unknown as MouseEvent; + + /** `picked` is renderer lines 7–9, so buffer lines 10–12. */ + const PICKED = { startLine: 7 + FRONT_MATTER, endLine: 9 + FRONT_MATTER }; + + test('the Edit entry resolves its target while the selection still exists', async () => { + const { tab, viewMode } = setup('read'); + select('picked'); + + // The menu opens on the right-click; the reader then clicks "Edit", and + // that click is what clears the selection. + const edit = viewMode.editSourceRangeAt(rightClickOn('first')); + window.getSelection()!.removeAllRanges(); + await edit(); + + assert.equal(tab.isEditing, true); + assert.deepEqual(viewMode.pendingEditReveal, PICKED, 'the editor opens on the selection, not on what is under the pointer'); + }); + + test('Edit with a target never leaves edit mode', async () => { + // In split view the editor is already on screen, and "edit this + // fragment" is the one thing that cannot mean "close the editor". + const { tab, fakes, viewMode } = setup('edit+split'); + + await viewMode.editSourceRange({ startLine: 7, endLine: 9 } as never); + + assert.equal(tab.isEditing, true, 'still editing'); + assert.equal(tab.isSplit, true, 'and the layout is unchanged'); + assert.deepEqual(fakes.readCalls, [], 'no mode change, so no read'); + assert.deepEqual(viewMode.pendingEditReveal, PICKED); + }); + + test('a jump whose read failed is not armed for the next document', async () => { + const { tab, fakes, viewMode } = setup('read'); + fakes.readFails = true; + + await viewMode.editSourceRange({ startLine: 7, endLine: 9 } as never); + + assert.equal(tab.isEditing, false, 'the failed read leaves the tab in reading mode'); + assert.equal(viewMode.pendingEditReveal, null, 'and nothing waits to fire at whatever is edited next'); + }); + + test('the target is a source range and never a column', async () => { + // `data-sourcepos="7:4-9:3"` carries columns; the jump selects whole + // lines, and the editor is never aimed with the `:col` half. + const { viewMode } = setup('read'); + + assert.deepEqual(viewMode.getContextMenuSourceRange(rightClickOn('picked')), { startLine: 7, endLine: 9 }); + await viewMode.editSourceRangeAt(rightClickOn('picked'))(); + + assert.deepEqual(viewMode.pendingEditReveal, PICKED); + assert.deepEqual(Object.keys(viewMode.pendingEditReveal!).sort(), ['endLine', 'startLine']); + }); + + test('every renderer line reaching the editor goes through the shift', async () => { + // The context menu's target, ⌘E's selection: both are renderer lines off + // `data-sourcepos`, and the editor counts the front matter too. + const viaMenu = setup('read'); + await viaMenu.viewMode.editSourceRangeAt(rightClickOn('first'))(); + assert.deepEqual(viaMenu.viewMode.pendingEditReveal, { startLine: 1 + FRONT_MATTER, endLine: 1 + FRONT_MATTER }); + + const viaChord = setup('read'); + select('picked'); + await viaChord.viewMode.toggleEditView(); + assert.deepEqual(viaChord.viewMode.pendingEditReveal, PICKED); + }); + + test('the selection is read before anything switches mode', async () => { + // Entering the editor takes the reader's focus, and with it the + // preview's selection. Read after that, ⌘E would answer for nothing. + const { tab, fakes, viewMode } = setup('read'); + select('picked'); + fakes.duringRead = () => window.getSelection()!.removeAllRanges(); + + await viewMode.toggleEditView(); + + assert.equal(tab.isEditing, true); + assert.deepEqual(viewMode.pendingEditReveal, PICKED); + }); + + test("the title bar's Edit button means what ⌘E means", async () => { + // From reading mode, Edit with a selection opens the editor on it, the + // same as the chord and the context menu. + const { tab, viewMode } = setup('read'); + select('picked'); + + await viewMode.setViewMode('edit'); + + assert.equal(tab.isEditing, true); + assert.deepEqual(viewMode.pendingEditReveal, PICKED); + }); + + test('split view with nothing selected is deliberately inert', async () => { + // The editor is already on screen, so the ability ⌘E asks for is already + // granted; with no selection there is nothing to travel to either. + const { tab, fakes, viewMode } = setup('split'); + + await viewMode.toggleEditView(); + + assert.equal(tab.isSplit, true, 'the chord never changes the layout'); + assert.equal(tab.isEditing, false); + assert.deepEqual(fakes.readCalls, []); + assert.equal(viewMode.pendingEditReveal, null); + }); +}); + +describe('the outline is fed body lines from both panes', () => { + test("the editor's scroll moves the outline in body lines", () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = true; + const before = settings.tocFollows; + settings.tocFollows = 'scroll'; + try { + const viewMode = createViewMode({ + tabManager, + settings, + lineCoords: coords, + scrollPreviewToSyncPosition: () => {}, + } as unknown as ViewModeOptions); + + // Monaco's top line is buffer line 20; the tab would record the line + // `EDITOR_ANCHOR_LINE_OFFSET` below it, counted from the body. + viewMode.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: asBufferLine(20) }); + + assert.equal(viewMode.tocActiveLine, 20 + EDITOR_ANCHOR_LINE_OFFSET - FRONT_MATTER); + } finally { + settings.tocFollows = before; + } + }); +}); + +/* + * The cold start: the rich-content libraries arrive after the document is + * already in the preview and after the position has been restored against it, + * and the enrichment moves the text by up to 812px (measured in Chromium; see + * the patch effect in MarkdownViewer.svelte). The second restore runs once per + * host, after the enrichment, for the tab still on screen. + */ +describe('a cold start restores again once its enrichment has landed', () => { + function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; + } + const settle = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }; + + function setup() { + tabManager.closeAll(); + tabManager.addTab('/notes/a.md'); + tabManager.addTab('/notes/b.md'); + const body = document.createElement('article'); + const restored: string[] = []; + const coldRestore = createColdEnrichmentRestore({ + tabManager, + tick: async () => {}, + markdownBody: body, + restore: (_body: HTMLElement, tab: Tab) => void restored.push(tab.path), + }); + return { body, restored, coldRestore }; + } + + test("a host's first enrichment restores after it lands", async () => { + const { body, restored, coldRestore } = setup(); + const enrichment = deferred(); + + coldRestore.afterEnrichment(body, 3, enrichment.promise); + await settle(); + assert.deepEqual(restored, [], 'not before the layout has changed'); + + enrichment.resolve(); + await settle(); + assert.deepEqual(restored, [tabManager.activeTab!.path]); + }); + + test('later patches of the same host do not move the reader', async () => { + const { body, restored, coldRestore } = setup(); + + coldRestore.afterEnrichment(body, 3, Promise.resolve()); + await settle(); + coldRestore.afterEnrichment(body, 2, Promise.resolve()); + await settle(); + + assert.equal(restored.length, 1, 'only the first enrichment of a host restores'); + }); + + test('a patch that inserted nothing is not a cold start', async () => { + const { body, restored, coldRestore } = setup(); + + coldRestore.afterEnrichment(body, 0, Promise.resolve()); + await settle(); + + assert.deepEqual(restored, []); + }); + + test('a tab switch during the enrichment cancels the restore', async () => { + const { body, restored, coldRestore } = setup(); + const enrichment = deferred(); + + coldRestore.afterEnrichment(body, 3, enrichment.promise); + tabManager.setActive(tabManager.tabs[0].id); + enrichment.resolve(); + await settle(); + + assert.deepEqual(restored, [], "another tab's position is never restored into the article"); + }); +}); diff --git a/scripts/preview.checks.spec.ts b/scripts/preview.checks.spec.ts deleted file mode 100644 index 07b05ffc..00000000 --- a/scripts/preview.checks.spec.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import assert from 'node:assert/strict'; -import { readSource } from './sourceTree.js'; -import { parse } from 'svelte/compiler'; -import ts from 'typescript'; -import { hasExportableDocument } from '../src/lib/utils/tabFileActions.js'; - -/** - * 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.
- */
-describe('diagram theme refresh', () => {
-	/**
-	 * 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\(\);/);
-	});
-});
-
-describe('line samples cache', () => {
-	test('a tab switch that skips the patch still drops the table', () => {
-		// The hosts' `display` is what changed, and `isAnchorable` reads it.
-		const viewer = readSource('src/lib/MarkdownViewer.svelte');
-		expect(viewer).toMatch(/if \(unchanged\) invalidateAnchorMemos\(\);/);
-	});
-});
-
-describe('rendered HTML field', () => {
-	// ---------------------------------------------------------------- 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, and it
-	// has to be in place before the stores are imported, so both happen in beforeAll.
-	let tabManager: (typeof import('../src/lib/stores/tabs.svelte.js'))['tabManager'];
-	let settings: (typeof import('../src/lib/stores/settings.svelte.js'))['settings'];
-
-	beforeAll(async () => {
-		(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),
-		};
-		({ tabManager } = await import('../src/lib/stores/tabs.svelte.js'));
-		({ settings } = await import('../src/lib/stores/settings.svelte.js'));
-	});
-
-	afterAll(() => {
-		delete (window as any).__TAURI_INTERNALS__;
-	});
-
-	// ------------------------------------------------------------------ AST walk
-
-	type Node = Record;
-
-	function collect(node: unknown, hit: (node: Node) => void): void {
-		if (!node || typeof node !== 'object') return;
-		if (Array.isArray(node)) {
-			for (const child of node) collect(child, hit);
-			return;
-		}
-		hit(node as Node);
-		for (const key of Object.keys(node as Node)) {
-			if (key === 'parent') continue;
-			collect((node as Node)[key], hit);
-		}
-	}
-
-	/** Free identifiers of an expression — property names and object keys excluded. */
-	function freeIdentifiers(expression: Node): string[] {
-		const skip = new Set();
-		collect(expression, (node) => {
-			if (node.type === 'MemberExpression' && !node.computed) skip.add(node.property);
-			if (node.type === 'Property' && !node.computed) skip.add(node.key);
-		});
-		const names = new Set();
-		collect(expression, (node) => {
-			if (node.type === 'Identifier' && !skip.has(node)) names.add(node.name);
-		});
-		return [...names].sort();
-	}
-
-	/**
-	 * Turn a source expression into a predicate over the names it happens to
-	 * reference, so a rewrite that reaches for a different helper fails with "the
-	 * gate references X" rather than a bare ReferenceError out of `new Function`.
-	 */
-	function compileGate(source: string, expression: Node, available: Record unknown>, where: string) {
-		const names = freeIdentifiers(expression).filter((name) => name !== 'undefined');
-		for (const name of names) {
-			assert.ok(
-				name in available,
-				`${where} references \`${name}\`, which these tests cannot supply — add it to \`available\` ` +
-					'in scripts/renderedHtmlField.test.ts so the gate can still be evaluated',
-			);
-		}
-		const text = source.slice(expression.start, expression.end);
-		const fn = new Function(...names, `return Boolean(${text});`) as (...args: unknown[]) => boolean;
-		return { text, run: () => fn(...names.map((name) => available[name]())) };
-	}
-
-	// ------------------------------------------- 1. the export gate, as written
-
-	const TITLE_BAR = 'src/lib/components/TitleBar.svelte';
-
-	const titleBarSource = readSource(TITLE_BAR);
-
-	const exportMenuGate = (() => {
-		const ast = parse(titleBarSource, { modern: true, filename: TITLE_BAR });
-
-		const candidates: Node[] = [];
-		collect(ast.fragment, (node) => {
-			if (node.type !== 'IfBlock') return;
-			const text = titleBarSource.slice(node.start, node.end);
-			if (text.includes('onexportHtml?.()') && text.includes('onexportPdf?.()')) candidates.push(node);
-		});
-		// Innermost only: every ancestor `{#if}` contains the buttons too.
-		const innermost = candidates.filter(
-			(gate) => !candidates.some((other) => other !== gate && other.start >= gate.start && other.end <= gate.end),
-		);
-
-		assert.equal(
-			innermost.length,
-			1,
-			`expected exactly one {#if} in ${TITLE_BAR} wrapping both export buttons, found ${innermost.length} — ` +
-				'if the export entries moved, move these tests with them',
-		);
-		return innermost[0];
-	})();
-
-	const exportsOffered = compileGate(
-		titleBarSource,
-		exportMenuGate.test,
-		{
-			tabManager: () => tabManager,
-			// The gate moved into a helper when the Export as PDF chord needed the
-			// same condition (#673). Supplied as the real function, so what these
-			// tests evaluate is still the shipped decision rather than a stand-in.
-			hasExportableDocument: () => hasExportableDocument,
-		},
-		'the export menu gate',
-	);
-
-	// ------------------------------- 2. the condition that refreshes tab.content
-
-	const VIEWER = 'src/lib/MarkdownViewer.svelte';
-
-	const viewerSource = readSource(VIEWER);
-
-	const previewCacheRefreshes = (() => {
-		const ast = parse(viewerSource, { modern: true, filename: VIEWER });
-		const matches: Node[] = [];
-		collect(ast.instance, (node) => {
-			if (node.type !== 'IfStatement') return;
-			const test = viewerSource.slice(node.test.start, node.test.end);
-			const body = viewerSource.slice(node.consequent.start, node.consequent.end);
-			if (test.includes('settings.showToc') && body.includes('updateTabContent')) matches.push(node);
-		});
-		assert.equal(
-			matches.length,
-			1,
-			`expected exactly one condition in ${VIEWER} guarding the preview-cache render, found ${matches.length}`,
-		);
-
-		return compileGate(
-			viewerSource,
-			matches[0].test,
-			{
-				tab: () => tabManager.activeTab,
-				settings: () => settings,
-				// MarkdownViewer.svelte passes the active tab's own flag down as
-				// the `isEditing` prop.
-				isEditing: () => tabManager.activeTab?.isEditing ?? false,
-			},
-			'the preview-cache refresh condition',
-		);
-	})();
-
-	// ------------------------------------- 3. exportAsHtml's own precondition
-
-	const EXPORT = 'src/lib/utils/export.ts';
-
-	const exportSource = readSource(EXPORT);
-
-	/** `if (!ctx.rawContent) return null;` — evaluated, not matched. */
-	const exportWouldRun = (() => {
-		const file = ts.createSourceFile(EXPORT, exportSource, ts.ScriptTarget.ES2022, true);
-		let guard: ts.Expression | null = null;
-		const visit = (node: ts.Node) => {
-			if (ts.isFunctionDeclaration(node) && node.name?.text === 'exportAsHtml' && node.body) {
-				const first = node.body.statements.find(ts.isIfStatement);
-				assert.ok(first, 'exportAsHtml no longer opens with a precondition');
-				guard = first.expression;
-				return;
-			}
-			ts.forEachChild(node, visit);
-		};
-		visit(file);
-		assert.ok(guard, `expected ${EXPORT} to declare exportAsHtml`);
-
-		const text = exportSource.slice((guard as ts.Expression).getStart(file), (guard as ts.Expression).getEnd());
-		const bails = new Function('ctx', 'hasExportableDocument', `return Boolean(${text});`) as (
-			ctx: unknown,
-			gate: typeof hasExportableDocument,
-		) => boolean;
-		// The context MarkdownViewer.svelte builds for the active tab.
-		return () => {
-			const tab = tabManager.activeTab;
-			if (!tab) return false;
-			return !bails({ rawContent: tab.rawContent, tabPath: tab.path, tabTitle: tab.title }, hasExportableDocument);
-		};
-	})();
-
-	// ----------------------------------------------------------------- fixtures
-
-	function reset() {
-		tabManager.closeAll();
-		localStorage.clear();
-		settings.showToc = false;
-		settings.newFileDefaultMode = true;
-	}
-
-	/**
-	 * Ctrl+T, then type — an untitled buffer being edited, TOC closed, not split.
-	 * `content` is left at whatever `addNewTab` produced precisely because the
-	 * refresh condition asserted below never runs for this state.
-	 */
-	function untitledBufferBeingEdited(text = '# notes\n\nsomething worth exporting\n') {
-		reset();
-		tabManager.addNewTab();
-		const tab = tabManager.activeTab!;
-		tab.isEditing = true;
-		tabManager.updateTabRawContent(tab.id, text);
-		return tab;
-	}
-
-	// --------------------------------------------------------------- the regression
-
-	test('the preview HTML cache is not refreshed while editing with the TOC closed', () => {
-		// The premise of the bug, taken from the shipping condition rather than
-		// asserted by hand. Without this, the repro below would be a test fixture
-		// choosing its own outcome.
-		const tab = untitledBufferBeingEdited();
-
-		assert.equal(tab.isEditing, true);
-		assert.equal(tab.isSplit, false);
-		assert.equal(settings.showToc, false);
-		assert.equal(
-			previewCacheRefreshes.run(),
-			false,
-			`nothing re-renders tab.content in this state. Condition: ${previewCacheRefreshes.text}`,
-		);
-		assert.equal(tab.content, '', 'so the rendered-HTML cache is still empty');
-		assert.notEqual(tab.rawContent, '', 'while the document itself is not');
-	});
-
-	test('an untitled buffer being edited is offered the export commands', () => {
-		untitledBufferBeingEdited();
-
-		assert.equal(exportWouldRun(), true, 'precondition: exportAsHtml would produce a file for this buffer');
-		assert.equal(
-			exportsOffered.run(),
-			true,
-			'the Home menu hid Export as HTML / Export as PDF for a document that exports fine — ' +
-				`it read the rendered-HTML cache instead of the buffer. Gate: ${exportsOffered.text}`,
-		);
-	});
-
-	/**
-	 * Bring `tab.content` to what the running app would hold in this state.
-	 *
-	 * Editable panes: only the condition lifted above refreshes it. Reading mode:
-	 * `renderTabPreviewFromRaw` drew the pane the user is looking at, so it is
-	 * current by construction. This is what makes the matrix below a statement
-	 * about the app rather than about fixtures the test wrote by hand.
-	 */
-	function settleRenderedCache() {
-		const tab = tabManager.activeTab;
-		if (!tab) return;
-		const editablePane = tab.isEditing || tab.isSplit;
-		if (!editablePane || previewCacheRefreshes.run()) {
-			tabManager.updateTabContent(tab.id, tab.rawContent ? `

rendered

` : ''); - } - } - - test('the menu is never stricter than the export it fronts', () => { - // The invariant behind the fix, over the states a tab can be in. Being - // STRICTER is the defect this test is for; being looser is pinned by the - // saved-empty-file test below. - const states: Array<[string, () => void]> = [ - ['untitled, edited, TOC closed', () => void untitledBufferBeingEdited()], - [ - 'untitled, edited, TOC open', - () => { - untitledBufferBeingEdited(); - settings.showToc = true; - }, - ], - [ - 'untitled, split view', - () => { - const tab = untitledBufferBeingEdited(); - tabManager.setSplitEnabled(tab.id, true); - }, - ], - [ - 'untitled, reading mode', - () => { - const tab = untitledBufferBeingEdited(); - tab.isEditing = false; - }, - ], - [ - 'saved file, edited, TOC closed', - () => { - reset(); - tabManager.addTab('/notes/a.md'); - const tab = tabManager.activeTab!; - tabManager.setTabRawContent(tab.id, '# a\n'); - tab.isEditing = true; - }, - ], - ]; - - // Collected rather than asserted one at a time, so a failure names every - // state the menu is wrong in instead of only the first. - const hidden: string[] = []; - for (const [name, arrange] of states) { - arrange(); - settleRenderedCache(); - if (exportWouldRun() && !exportsOffered.run()) hidden.push(name); - } - - assert.deepEqual(hidden, [], 'the export is possible in these states and the menu hides it'); - }); - - // ------------------------------------------------------------------ the fence - // - // A gate that simply always returned true would pass everything above. - - test('an empty untitled buffer is offered nothing to export', () => { - reset(); - tabManager.addNewTab(); - tabManager.activeTab!.isEditing = true; - - assert.equal(exportWouldRun(), false, 'precondition: exportAsHtml returns null for an empty buffer'); - assert.equal(exportsOffered.run(), false, 'so the menu must not offer it either'); - }); - - test('a window with no tab at all offers no export', () => { - reset(); - - assert.equal(exportsOffered.run(), false); - }); - - test('a saved file is offered the export commands', () => { - reset(); - tabManager.addTab('/notes/a.md'); - tabManager.setTabRawContent(tabManager.activeTab!.id, '# a\n'); - - assert.equal(exportsOffered.run(), true); - }); - - test('a saved file with an empty buffer is offered nothing to export', () => { - // A saved empty file, or a tab whose read has not landed yet: a path and an - // empty buffer. The menu used to list the exports on the path alone, so - // Export as HTML then did nothing and Export as PDF printed a blank page. - // The menu, the PDF chord and the HTML export now ask one predicate. - reset(); - tabManager.addTab('/notes/a.md'); - - assert.equal(tabManager.activeTab!.rawContent, '', 'precondition: the buffer is empty'); - assert.equal(exportWouldRun(), false, 'precondition: exportAsHtml returns null for an empty buffer'); - assert.equal(exportsOffered.run(), false, 'so the menu must not offer it either'); - assert.equal(hasExportableDocument(tabManager.activeTab!.rawContent), false, 'nor the PDF chord'); - }); -}); diff --git a/scripts/preview.checks.test.ts b/scripts/preview.checks.test.ts index 3021f1f5..7d7c825c 100644 --- a/scripts/preview.checks.test.ts +++ b/scripts/preview.checks.test.ts @@ -1,9 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { callSiteOffsets, enclosingFunctionName, filesMatching, functionSource, offsetOf, readSource, readSourceFiles, SANITIZER_FILES, sliceBetween } from './sourceTree.js'; -import ts from 'typescript'; -import { getMarkdownLinkTarget, resolveMarkdownTargetPath } from '../src/lib/utils/markdownLinks.js'; -import { isSameFilePath } from '../src/lib/utils/pathIdentity.js'; +import { callSiteOffsets, enclosingFunctionName, filesMatching, readSource, readSourceFiles, SANITIZER_FILES, sliceBetween } from './sourceTree.js'; import { ALLOWED_MARKDOWN_URI_REGEXP, MARKDOWN_SANITIZE_CONFIG } from '../src/lib/utils/sanitize.js'; import { getStoredPreviewFullWidth } from '../src/lib/utils/previewWidth.js'; @@ -14,8 +11,6 @@ describe('inline code in headings', () => { const styles = readSource('src/styles.css'); - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - test('a heading lays its content out as one line of text', () => { // A flex heading makes every text run, ``, `` and image its own // flex item: #681 floated inline code above the words, #791 ate the spaces @@ -25,22 +20,6 @@ describe('inline code in headings', () => { assert.ok(rule, 'styles.css must still define .foldable-header'); assert.doesNotMatch(rule[0], /display:\s*(inline-)?(flex|grid)/); }); - - test('inline code in a heading scales with the heading', () => { - // The Code Font Size setting is an absolute px — right for code in prose, - // wrong for code in a heading, where 14px inside a 24px `##` reads as a - // footnote. GitHub sizes inline code relative to what contains it. - assert.match( - viewer, - /:global\(\.markdown-body :is\(h1, h2, h3, h4, h5, h6\) code\) \{\s*\n\s*font-size: 0\.85em !important;/, - ); - }); - - test('the setting still governs code everywhere else', () => { - // The fix is scoped to headings; body text and code blocks keep answering to - // `--code-font-size`, which is what the setting promises. - assert.match(viewer, /:global\(\.markdown-body code\) \{[\s\S]*?font-size: var\(--code-font-size, 14px\) !important;/); - }); }); /** @@ -60,112 +39,7 @@ describe('KaTeX contrib flavour', () => { }); }); -describe('link hover preview', () => { - /* - * The box that names a link while the cursor is on it had two faults, both - * reported from a build. - * - * It said `tauri://localhost/3.md` for `[3.md](./3.md)`. That is the DOM - * resolving `href` against the webview's own origin — the same resolution - * `resolveLocalFileLinkPath` exists to undo on the click side, and the same - * origin `preview.test.ts` documents. `handleMouseOver` had already - * read `getAttribute('href')` into `rawHref` for its other two branches; the - * one that prints the address was the one that did not use it. - * - * And it stayed on screen after the click. `mouseout` is the only thing that - * takes it down, and a click that replaces the document removes the anchor - * while the cursor is still on it — removing a node dispatches no `mouseout`, - * so nothing ever fired. - * - * Neither half has a seam a Node test can reach: both are branches inside a - * 4,700-line component, and what they do is set component state. So these - * assert against the source, and what they establish is narrow — that the - * resolved href is not read where the address is printed, and that the click - * handler drops the box before it does anything else. Whether the box is then - * painted or not is checked by hand. - */ - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - test('the hover preview never prints the webview origin', () => { - const hover = functionSource(viewer, 'handleMouseOver'); - - // Stated as the thing that must not come back rather than as today's - // spelling of what replaced it: the resolved property is what put a - // `tauri://` URL in front of the user, so this function may not read it. - // - // The whole function, comments included — deliberately. The first version of - // this test went red on the comment explaining the fix, and that is the rule - // working: a comment naming the property is how the next person is talked - // into writing it again. Describe it, do not spell it. - assert.doesNotMatch( - hover, - /anchor\.href/, - 'the hover preview reads the resolved href, which is the webview origin plus a path that is not the file', - ); - assert.match(hover, /getAttribute\('href'\)/); - }); - - test('a click takes the hover preview down before it does anything else', () => { - const click = functionSource(viewer, 'handleLinkClick'); - - // Before the branches, because every one of them can be the one that - // replaces the document — a fold, an anchor, a relative link, a new tab. - const hide = offsetOf(click, 'tooltip.show = false'); - const firstBranch = offsetOf(click, 'toggleFoldFromClick'); - - assert.ok(hide >= 0, 'the click handler never drops the hover preview'); - assert.ok( - hide < firstBranch, - 'the hover preview is dropped after a branch that can already have replaced the document', - ); - }); -}); - describe('local file links', () => { - // --- wiring ------------------------------------------------------------------ - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - - // Extracted by name rather than by an anchor pair: the closing anchor used to be - // `'let zoomLevel'`, the next declaration in the file, and it vanished the day - // zoom moved into the settings store — an anchor whose only qualification is - // being the next thing down is an anchor that moves for unrelated reasons. - const handler = functionSource(viewer, 'handleDocumentClick'); - - // The OS half of a local-file click, shared with the folder sidebar so a file - // opened from either place gets the same launchable-target guard. - const opener = functionSource(viewer, 'openWithSystem'); - - test('markdown targets are still claimed before the local-file branch', () => { - const markdown = offsetOf(handler, 'getRelativeMarkdownTarget(rawHref)'); - const local = offsetOf(handler, 'resolveLocalFileLinkPath(rawHref, currentFile)'); - assert.ok(markdown < local, '`./other.md` must open in a tab, not in an external editor'); - }); - - test('a local file is handed to the OS as a path, not as a URL', () => { - assert.match(handler, /await openWithSystem\(localFilePath\)/); - assert.match(opener, /await openPath\(path\)/); - // The raw attribute, not `anchor.href`: the latter is the origin-resolved - // URL that caused the bug. - assert.match(handler, /resolveLocalFileLinkPath\(rawHref, currentFile\)/); - const local = offsetOf(handler, 'resolveLocalFileLinkPath'); - const url = offsetOf(handler, 'await openUrl(anchor.href)'); - assert.ok(local < url, 'a local file must be caught before the URL fallback'); - }); - - test('a launchable target is revealed, not opened', () => { - // `openPath` hands the file to the OS default handler, and for - // `./setup.command`, `Calculator.app` or `x.exe` that handler runs it. The - // capability scope stays `**` because a link may point anywhere; this - // check, answered in Rust where the file's metadata is, is the guard. - const check = offsetOf(opener, "invoke('is_launchable_path', { path })"); - const reveal = offsetOf(opener, "invoke('open_file_folder', { path })"); - const open = offsetOf(opener, 'await openPath(path)'); - assert.ok(check < reveal && reveal < open, 'the check must decide between reveal and open'); - assert.match(opener, /t\('toast\.launchableLinkRevealed'/); - }); - test('the capability still grants the command this depends on', () => { // `open_path` needs both the command grant and a path scope. Granting the // command alone leaves the plugin resolving @@ -178,145 +52,6 @@ describe('local file links', () => { const capability = readSource('src-tauri/capabilities/default.json'); assert.match(capability, /opener:allow-open-path/); }); - - test('neither OS call can leave an unhandled rejection behind', () => { - // `openUrl` rejects for anything outside the opener scope. Unawaited-in- - // try/catch, that rejection was the whole visible symptom on macOS: nothing - // happened, and nothing said why. - for (const [source, call] of [ - [opener, 'await openPath(path)'], - [handler, 'await openUrl(anchor.href)'], - ] as const) { - const at = offsetOf(source, call); - const before = source.slice(0, at); - const tryAt = before.lastIndexOf('try {'); - assert.notEqual(tryAt, -1, `${call} must be inside a try block`); - offsetOf(source, '} catch (error) {', at); // must have a catch after it - assert.ok(before.slice(tryAt).split('} catch').length === 1, `${call} must be inside the nearest try`); - } - assert.equal(opener.match(/t\('toast\.openFailed'/g)?.length, 1, 'a failed file open is reported'); - assert.equal(handler.match(/t\('toast\.openFailed'/g)?.length, 1, 'a failed URL open is reported'); - }); - - // --- the preview claims the click before the router does ---------------------- - - /* - * #772: `[PDF_1](Files/PDF_1.pdf)` opened the file AND left the app on a 404 - * page that could only be closed from Task Manager. - * - * Both, from one click, because three listeners see it in this order: - * - *
handleLinkClick — declined a non-markdown link - * document.documentElement SvelteKit's router — `if (event.defaultPrevented) return` - * document handleDocumentClick — resolves the path, calls openPath - * - * The router claimed the undecided click and navigated client-side to a route - * an SPA does not have, so its 404 page replaced the app — the title bar with - * it, which on Windows (`decorations(false)`) is the only way to close the - * window. `handleDocumentClick` then ran and opened the PDF, its own - * `preventDefault()` two listeners too late. - * - * A markdown link never had the bug because its branch calls - * `stopPropagation()`, so the event never reaches the router at all. - */ - - const linkClick = ts.transpileModule(functionSource(viewer, 'handleLinkClick'), { - compilerOptions: { target: ts.ScriptTarget.ES2022 }, - }).outputText; - - const handleLinkClick = new Function( - 'deps', - `"use strict"; - const { - tooltip, foldHost, toggleFoldFromClick, getRelativeMarkdownTarget, opensInNewTab, - settings, openMarkdownTargetInNewTab, openRelativeMarkdownTarget, scrollToAnchorWhenReady, - } = deps; - let zoomData; - ${linkClick} - return handleLinkClick;`, - )({ - tooltip: { show: true }, - foldHost: null, - toggleFoldFromClick: () => false, - getRelativeMarkdownTarget: getMarkdownLinkTarget, - opensInNewTab: () => false, - settings: { osType: 'macos', linksOpenInNewTab: false }, - openMarkdownTargetInNewTab: async () => {}, - openRelativeMarkdownTarget: async () => {}, - scrollToAnchorWhenReady: async () => {}, - }) as (e: unknown) => Promise; - - async function clickLink(href: string) { - const anchor = { getAttribute: () => href }; - const result = { defaultPrevented: false, propagationStopped: false }; - await handleLinkClick({ - detail: 1, - target: { closest: (selector: string) => (selector === 'a' ? anchor : null) }, - preventDefault: () => { result.defaultPrevented = true; }, - stopPropagation: () => { result.propagationStopped = true; }, - }); - return result; - } - - test('a link the document handler opens leaves the preview already claimed', async () => { - for (const href of ['Files/PDF_1.pdf', './Files/MP4_1.mp4', '/srv/data.csv', 'https://example.com']) { - const { defaultPrevented, propagationStopped } = await clickLink(href); - assert.equal(defaultPrevented, true, `${href} must not reach the router undecided`); - // Claimed, not swallowed: `handleDocumentClick` is one listener further - // up and is the thing that opens it. - assert.equal(propagationStopped, false, `${href} must still reach the document handler`); - } - }); - - test('a markdown link is handled here and goes no further', async () => { - const { defaultPrevented, propagationStopped } = await clickLink('./other.md'); - assert.equal(defaultPrevented, true); - assert.equal(propagationStopped, true); - }); - - // "Is this link the file I am reading?" is a question about the FILE. On - // macOS `[x](readme.md#a)` inside `Readme.md` names the open document, and - // comparing spellings answered no: the click asked to save or discard the - // unsaved edits and reloaded the file instead of scrolling to `#a`. - const openRelative = ts.transpileModule(functionSource(viewer, 'openRelativeMarkdownTarget'), { - compilerOptions: { target: ts.ScriptTarget.ES2022 }, - }).outputText; - - async function followLink(currentFile: string, path: string) { - const calls: string[] = []; - const run = new Function( - 'deps', - `"use strict"; - const { - currentFile, resolveMarkdownTargetPath, canonicalizePath, isSameFilePath, - scrollToAnchorWhenReady, markdownBody, pushScrollHistory, tabManager, canCloseTab, loadMarkdown, - } = deps; - ${openRelative} - return openRelativeMarkdownTarget;`, - )({ - currentFile, - resolveMarkdownTargetPath, - // A case-insensitive volume whose directory holds `Readme.md`. - canonicalizePath: async (p: string) => (p.toLowerCase() === '/notes/readme.md' ? '/notes/Readme.md' : p), - isSameFilePath, - scrollToAnchorWhenReady: async () => void calls.push('scroll'), - markdownBody: null, - pushScrollHistory: () => {}, - tabManager: { activeTabId: 'tab' }, - canCloseTab: async () => { - calls.push('ask to close'); - return true; - }, - loadMarkdown: async () => void calls.push('load'), - }) as (target: { path: string; hash: string }) => Promise; - await run({ path, hash: 'a' }); - return calls; - } - - test('a link to the open file under another spelling scrolls instead of reloading', async () => { - assert.deepEqual(await followLink('/notes/Readme.md', 'readme.md'), ['scroll']); - assert.deepEqual(await followLink('/notes/Readme.md', 'other.md'), ['ask to close', 'load', 'scroll']); - }); }); describe('preview copy handler', () => { @@ -341,40 +76,6 @@ describe('preview copy handler', () => { const viewer = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - const handler = functionSource(viewer, 'handleCopy'); - - test('the document-level copy handler is attached', () => { - // Document level rather than the preview's `
`: `styles.css` puts - // `user-select: none` on the app root, so the only selectable surfaces are - // the preview and the update dialog's release notes (#532) — and both take - // the same native path. - const svelteDocument = sliceBetween(viewer, ''); - assert.match(svelteDocument, /oncopy=\{handleCopy\}/); - }); - - test('the copy handler writes plain text and cancels the native copy', () => { - assert.match(handler, /setData\(\s*'text\/plain'/); - // Without this the native copy still runs and still builds the archive. - assert.match(handler, /preventDefault\(\)/); - }); - - test('the rich flavour is the one this handler builds, and the only one', () => { - // `text/html` is built here, from a fragment the app has already pruned, - // rather than left to WebKit — which is what keeps the WebArchive out of - // shipped builds while the paste keeps its formatting. - assert.match(handler, /setData\(\s*'text\/html',\s*html\)/); - // RTF is WebKit's own flavour and nothing here can produce a good one: it - // would be the scheme deciding again, which is the defect #549 fixed. - assert.doesNotMatch(handler, /text\/rtf/i); - }); - - test('both flavours come from one decision', () => { - // Not two calls: the plain text depends on what the HTML pass found — a - // selection containing math takes its text from the formula sources — and - // splitting them is how the two come to describe different selections. - assert.match(handler, /const \{ text, html \} = copyableFlavours\(selection, currentFile\)/); - }); - test('nothing else installs a copy handler', () => { // `katex/contrib/copy-tex` did, firing only for selections with math and // rewriting both flavours from its own fragment — so it silently undid this @@ -388,26 +89,6 @@ describe('preview copy handler', () => { assert.doesNotMatch(richContent, /import\('katex\/dist\/contrib\/copy-tex/); assert.deepEqual(filesMatching(files, /addEventListener\(\s*'copy'/), []); }); - - test('a selection inside a form control is left to the platform', () => { - // Mirrors WebKit's own carve-out in `Editor::performCutOrCopy`: such a - // selection already gets plain text and no archive, and `getSelection()` - // cannot read it — cancelling here would copy an empty string. Monaco takes - // input through a hidden textarea and is covered by the same test, which is - // what keeps the editor on its own Rust path (#548). - assert.match(handler, /document\.activeElement/); - assert.match(handler, /HTMLInputElement/); - assert.match(handler, /HTMLTextAreaElement/); - const carveOut = handler.indexOf('HTMLTextAreaElement'); - const write = handler.indexOf('setData'); - assert.ok(carveOut !== -1 && write !== -1 && carveOut < write, 'the carve-out must return before the write'); - }); - - test('a collapsed selection is not copied', () => { - // Edit ▸ Copy is enabled with nothing selected; writing '' would clear the - // clipboard instead of leaving it alone. - assert.match(handler, /isCollapsed/); - }); }); describe('preview sanitizing', () => { @@ -444,19 +125,11 @@ describe('preview sanitizing', () => { const viewerSource = readSource('src/lib/MarkdownViewer.svelte'); - const sanitizeSource = readSource('src/lib/utils/sanitize.ts'); - const richContentSource = readSource('src/lib/utils/richContent.ts'); const previewPatchSource = readSource('src/lib/utils/previewPatch.ts'); - test('the preview sanitizes through the shared policy, not a local config', () => { - assert.match( - previewPatchSource, - /import\s*\{[^}]*\bsanitizeMarkdownFragment\b[^}]*\}\s*from\s*'[^']*\/sanitize\.js'/, - 'the preview patch must import the shared sanitizer', - ); - + test('the viewer injects markup only through patchPreview and the footnote tooltip', () => { // The sinks: every `{@html …}` in the viewer, whatever the expression looks // like. Stated over *all* of them rather than over one known-good spelling, // so adding an injection point of the raw document is a failure instead of @@ -496,51 +169,19 @@ describe('preview sanitizing', () => { ); // The document sink, in the form it takes now: one call, handed the - // renderer's output. `patchPreview` is the only way into the preview's - // article, so what it does with that string is the whole path. + // renderer's output. `patchHost` (which calls `patchPreview`) is the only + // way into the preview's article, so what it does with that string is the + // whole path. assert.equal( - [...viewerSource.matchAll(/\bpatchPreview\(\s*host\s*,\s*htmlContent\s*,/g)].length, + [...viewerSource.matchAll(/\bpatchHost\(\s*host\s*,\s*htmlContent\s*,/g)].length, 1, - 'the preview must reach the article through patchPreview, once', + 'the preview must reach the article through patchHost, once', ); - // Inside it: the one place markup is parsed is a block of the renderer's - // output, into a detached document of its own, before the filter — and what - // a block puts into the article is the filter's own nodes. Nothing is - // parsed after `sanitizeMarkdownFragment`, and no markup is assembled. - const parses = [...previewPatchSource.matchAll(/(\w+)\.(?:innerHTML|outerHTML|insertAdjacentHTML)\s*=\s*([^;]+);/g)].map( - (m) => `${m[1]} = ${m[2].trim()}`, - ); - assert.deepEqual(parses, ['box = markup'], 'previewPatch.ts parses only the renderer output, before the filter'); - assert.match(previewPatchSource, /const box = scratch\.createElement\('div'\);/, 'into the detached scratch document'); - const processAndSanitize = sliceBetween(previewPatchSource, 'function processAndSanitize(', '\n}\n'); - assert.match(processAndSanitize, /return sanitizeMarkdownFragment\(box\.innerHTML\);$/, 'and the filter has the last word'); - // What a block is inserted as is always the filter's own nodes. - const renderBlocks = sliceBetween(previewPatchSource, 'function renderBlocks(', '\n}\n'); - assert.deepEqual( - [...renderBlocks.matchAll(/Array\.from\((\w+)\.children\)/g)].map((m) => m[1]), - ['clean', 'lone', 'mark'], - ); - assert.match(renderBlocks, /const clean = processAndSanitize\(/); - assert.match(renderBlocks, /const lone = processAndSanitize\(/); - assert.match(renderBlocks, /const marks = Array\.from\(clean\.children\);/); - assert.match(renderBlocks, /return marks\.map\(\(mark\) =>/); - assert.doesNotMatch(previewPatchSource, /insertAdjacentHTML|outerHTML|createContextualFragment/); - - // Why the footnote sink is allowed, pinned as a direction rather than as a - // spelling: the tooltip body is read out of the rendered document, never - // built from a string the sanitizer has not seen. - // - // `previewBlocks` and not `markdownBody`, and the difference is the whole - // point of the assertion rather than a rename. The article now holds one - // host per open tab, and `previewBlocks` is the one `patchPreview` was - // handed above — so this pins the tooltip to the exact element the sanitized - // string was parsed into, where the previous spelling only reached the box - // containing it. - const footnote = sliceBetween(viewerSource, "anchor.hasAttribute('data-footnote-ref')", 'isFootnote: true'); - assert.match(footnote, /previewBlocks\?\.querySelector/, 'the footnote body is found in the rendered document'); - assert.match(footnote, /\.innerHTML/, 'and taken from the DOM the shared sanitizer already filtered'); - assert.doesNotMatch(footnote, /rawContent/, 'never from the unrendered buffer'); + // What patchPreview does with that string, and where the footnote tooltip's + // body comes from, are run in preview.spec.ts ("the preview path patches in + // the filtered nodes, with the same policy", "a footnote tooltip is read back + // out of the rendered preview"). What stays here is the set of sinks. // The regression itself — a DOMPurify call with a config assembled at the // call site — is caught for the whole tree by the call-site allowlist below; @@ -554,25 +195,6 @@ describe('preview sanitizing', () => { ); }); - test('the shared policy the preview now gets is the one that forbids author stylesheets', () => { - // Read as one chain: the preview patch calls sanitizeMarkdownFragment (test - // above), which passes MARKDOWN_SANITIZE_CONFIG and only asks for nodes - // back, as the export's sanitizeMarkdownHtml passes it, and that config - // forbids the tag the payload needs. - assert.match(sanitizeSource, /return DOMPurify\.sanitize\(html, MARKDOWN_SANITIZE_CONFIG\)/); - assert.match( - sanitizeSource, - /return DOMPurify\.sanitize\(html, \{ \.\.\.MARKDOWN_SANITIZE_CONFIG, RETURN_DOM_FRAGMENT: true \}\)/, - ); - assert.deepEqual(Object.keys(MARKDOWN_SANITIZE_CONFIG).sort(), ['ALLOWED_URI_REGEXP', 'FORBID_TAGS']); - assert.deepEqual(MARKDOWN_SANITIZE_CONFIG.FORBID_TAGS, ['style']); - // Identity, not equality: the regression this file exists for is a *copy* of - // the URI pattern, and a copy that happens to be spelled the same today is - // the thing that drifts tomorrow. Measured — rebuilding the config's regexp - // from the exported one's source and flags fails here. - assert.equal(MARKDOWN_SANITIZE_CONFIG.ALLOWED_URI_REGEXP, ALLOWED_MARKDOWN_URI_REGEXP); - }); - // Every `DOMPurify.sanitize` in `src/` is a decision about what a piece of // untrusted input is allowed to be, and the compiler cannot tell that a new one // silently reintroduces a private policy — this file exists because that is @@ -661,43 +283,13 @@ describe('preview width', () => { const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - const settingsComponentSource = readSource(new URL('../src/lib/components/Settings.svelte', import.meta.url)); - - const tocOverlaySource = readSource(new URL('../src/lib/utils/tocOverlay.ts', import.meta.url)); - - test('settings load, persist, and reset the preview width through one normalizer', () => { - assert.match(settingsSource, /previewMaxWidth = \$state\(DEFAULT_PREVIEW_MAX_WIDTH\)/); - // Persistence moved from one all-fields effect to one entry per localStorage - // key (so that a change in one window stops rewriting every other key from a - // stale snapshot). The preview width still round-trips under the same key and - // through the same normalizer; only the plumbing that reads and writes it is - // now shared. See scripts/settings.spec.ts. - assert.match(settingsSource, /key: 'preview\.maxWidth'/); - assert.match(settingsSource, /read: \(s\) => String\(s\.previewMaxWidth\)/); - assert.match(settingsSource, /normalizePreviewMaxWidth\(savedPreviewMaxWidth\)/); - assert.match(settingsSource, /resetPreviewMaxWidth\(\)[\s\S]*this\.previewMaxWidth = DEFAULT_PREVIEW_MAX_WIDTH/); - }); - - test('preview layout derives width and ToC geometry from the same preference', () => { - assert.match(viewerSource, /getPreviewContentWidth\(settings\.previewMaxWidth, settings\.previewFullWidth\)/); - // The gutter arithmetic moved to `tocOverlay.ts` with #176, which added the - // question the inline expression could not answer: whether the pane under - // the outline is the preview at all. Both halves still feed on this same - // preference — see scripts/layout.test.ts for the geometry itself. - assert.match(viewerSource, /isTocOverhanging\(\{[\s\S]*?previewContentWidth,[\s\S]*?\}\)/); - assert.match(tocOverlaySource, /input\.viewerWidth - input\.previewContentWidth/); - assert.match(viewerSource, /--preview-max-width:/); + test('the stylesheet reads the width the markup writes', () => { + // Contract between the inline style and the component's '), - ); - - /** 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