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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions scripts/editorContextMenuI18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

import { getSupportedLanguages, t, type LanguageCode } from '../src/lib/utils/i18n.js';
import { getSupportedLanguages, t, translations, type LanguageCode, type Translation } from '../src/lib/utils/i18n.js';

// WHAT THIS FILE COVERS, AND WHAT IT DOES NOT
//
Expand All @@ -24,6 +24,33 @@ function count(source: string, pattern: RegExp): number {
return source.match(pattern)?.length ?? 0;
}

/**
* Does `lang`'s own dictionary define `key`, without the English fallback?
*
* `t()` falls back to English, so `assert.notEqual(t(key, lang), key)` can only
* fail when ENGLISH lacks the key — which each caller below already asserts one
* line earlier. The 26-language loop around it restated that 26 times and could
* not see the regression it was written for: deleting the German
* `menu.inlineCode` entry left the whole suite green while the German context
* menu rendered the English label.
*
* These labels are held to a stricter bar than the dictionary at large.
* i18nCoverage.test.ts reports per-locale gaps rather than failing on them,
* because 19 locales are missing >100 keys and nobody should have to translate
* a new English string 25 times before landing it. That trade does not apply
* here: this is a fixed list of a dozen context-menu entries, all 26 languages
* define all of them today, and adding an eleventh action means adding a row to
* FORMATTING_ACTIONS by hand — so the cost is visible where it is incurred.
*/
function defines(lang: LanguageCode, key: string): boolean {
let node: string | Translation | undefined = translations[lang];
for (const part of key.split('.')) {
if (typeof node !== 'object' || node === null || !(part in node)) return false;
node = node[part];
}
return typeof node === 'string';
}

// The markdown formatting entries that used to be English string literals.
const FORMATTING_ACTIONS: ReadonlyArray<[actionId: string, key: string, englishLabel: string]> = [
['fmt-inline-code', 'menu.inlineCode', 'Inline Code'],
Expand Down Expand Up @@ -60,13 +87,16 @@ test('every context-menu label is translated, none is an English literal', () =>
);
});

test('the formatting labels exist in English and resolve in every language', () => {
test('the formatting labels exist in English and are translated in every language', () => {
for (const [, key, englishLabel] of FORMATTING_ACTIONS) {
assert.equal(t(key, 'en'), englishLabel, `${key} is defined for English`);
for (const lang of supported) {
const label = t(key, lang as LanguageCode);
assert.notEqual(label, key, `${key} resolves for ${lang} instead of echoing the key`);
assert.ok(label.length > 0, `${key} is non-empty for ${lang}`);
// `defines`, not `t(...) !== key` — see the note on the helper.
assert.ok(
defines(lang as LanguageCode, key),
`${key} is translated for ${lang}; it currently falls back to the English label`,
);
assert.ok(t(key, lang as LanguageCode).length > 0, `${key} is non-empty for ${lang}`);
}
}
});
Expand All @@ -92,7 +122,11 @@ test('toggle-occurrences-highlight has its own label, not Show Whitespace', () =
for (const lang of supported) {
const occurrences = t('settings.occurrencesHighlight', lang as LanguageCode);
const whitespace = t('settings.showWhitespace', lang as LanguageCode);
assert.notEqual(occurrences, 'settings.occurrencesHighlight', `defined for ${lang}`);
// Same substitution as above: `occurrences !== 'settings.occurrencesHighlight'`
// only ever failed if English lacked the key, which the assertions at the
// top of this test already cover.
assert.ok(defines(lang as LanguageCode, 'settings.occurrencesHighlight'), `translated for ${lang}`);
assert.ok(defines(lang as LanguageCode, 'settings.showWhitespace'), `translated for ${lang}`);
assert.notEqual(
occurrences,
whitespace,
Expand Down Expand Up @@ -156,13 +190,13 @@ test('actions are re-registered when the UI language changes', () => {
);
assert.match(editor, /onDestroy\(disposeLocalizedActions\)/, 'teardown releases them too');

// The old design read a `uiLanguage` snapshot captured inside onMount.
// The old design read a `uiLanguage` snapshot captured inside onMount. The
// narrower "no label is still bound to the snapshot" count that used to
// follow this line is gone: it counted occurrences of a pattern containing
// `uiLanguage`, so the line above has to pass for it to be reached and,
// once it has, the count is zero by construction. There is no state of the
// component in which it, and not the line above, is the failure.
assert.doesNotMatch(editor, /uiLanguage/, 'no captured language snapshot remains');
assert.equal(
count(editor, /label: t\('[^']+', uiLanguage\)/g),
0,
'no label is still bound to the snapshot',
);
});

test('no action id is registered more than once', () => {
Expand Down
55 changes: 46 additions & 9 deletions scripts/previewSanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';

import { MARKDOWN_SANITIZE_CONFIG, ALLOWED_MARKDOWN_URI_REGEXP } from '../src/lib/utils/sanitize.js';
import { SANITIZER_FILES, callSiteOffsets, enclosingFunctionName, filesMatching, readSourceFiles } from './sourceTree.js';
import { SANITIZER_FILES, callSiteOffsets, enclosingFunctionName, filesMatching, readSourceFiles, sliceBetween } from './sourceTree.js';

// The preview is the path the `<style>` clause in the shared policy was written
// for, and it was the one path not using it. `tab.content` (the rendered,
Expand All @@ -28,9 +28,18 @@ import { SANITIZER_FILES, callSiteOffsets, enclosingFunctionName, filesMatching,
// halves are permitted by the shipped CSP — `style-src 'self' 'unsafe-inline' …`
// and `img-src 'self' asset: https: …` (src-tauri/tauri.conf.json).
//
// The payload was, verbatim:
//
// <style>.titlebar{display:none} body{background-image:url("https://attacker.example/beacon")}</style>
//
// It used to be a `POC_STYLE` constant with an `assert.match(POC_STYLE,
// /^<style>/)` under it. That assertion read as part of the chain below but had
// no end in `src/`: both sides were this file, one line apart, so no change to
// the application could ever fail it. The payload is documentation, so it is
// documentation now.
//
// What is checkable here is the wiring that decides which config the preview
// gets, and that is what these tests pin.
const POC_STYLE = '<style>.titlebar{display:none} body{background-image:url("https://attacker.example/beacon")}</style>';

const SOURCES = readSourceFiles('src');
const viewerSource = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');
Expand Down Expand Up @@ -62,12 +71,37 @@ test('the preview sanitizes through the shared policy, not a local config', () =
'the viewer must import the shared sanitizer',
);

// The sink itself: every bare `{@html ident}` in the viewer injects the
// sanitizer's output and nothing else. Stated over *all* of them rather than
// over one known-good spelling, so adding a second injection point of the raw
// document is a failure instead of an unnoticed addition.
const injected = [...new Set([...viewerSource.matchAll(/\{@html\s+([A-Za-z_$][\w$]*)\s*\}/g)].map((m) => m[1]))];
assert.deepEqual(injected, [sanitizedSinkName()], 'the preview sink must inject the shared sanitizer output');
// 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
// an unnoticed addition.
//
// The capture used to be `([A-Za-z_$][\w$]*)`, bare identifiers only, which
// made the claim unfalsifiable in exactly the direction it is about. The
// list it compared was `[sanitizedSinkName()]` by construction: the second
// sink already in the file, `{@html tooltip.html}`, was invisible to it, and
// a planted `{@html unsafe.rawHtml}` beside the sanitized one — the raw
// rendered document, injected — left the whole suite green.
//
// So the set is an allowlist now, and `tooltip.html` is on it with a reason
// rather than by accident: the footnote tooltip is a clone of a subtree of
// `markdownBody`, i.e. of the DOM the sanitized sink already injected. It is
// not a second policy, it is the same bytes read back out of the document —
// which is what the next two assertions pin.
const injected = [...new Set([...viewerSource.matchAll(/\{@html\s+([^}]+?)\s*\}/g)].map((m) => m[1]))].sort();
assert.deepEqual(
injected,
[sanitizedSinkName(), 'tooltip.html'].sort(),
'unexpected {@html} sink — what the preview injects is the shared sanitizer output',
);

// 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.
const footnote = sliceBetween(viewerSource, "anchor.hasAttribute('data-footnote-ref')", 'isFootnote: true');
assert.match(footnote, /markdownBody\?\.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');

// 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;
Expand All @@ -88,8 +122,11 @@ test('the shared policy the preview now gets is the one that forbids author styl
assert.match(sanitizeSource, /return DOMPurify\.sanitize\(html, MARKDOWN_SANITIZE_CONFIG\)/);
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);
assert.match(POC_STYLE, /^<style>/);
});

// Every `DOMPurify.sanitize` in `src/` is a decision about what a piece of
Expand Down
62 changes: 50 additions & 12 deletions scripts/recentFilesMultiWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ Object.defineProperty(globalThis, 'window', {
Object.defineProperty(globalThis, 'navigator', { value: { language: 'en-US' }, configurable: true });

const {
RECENT_FILES_KEY,
RECENT_FILES_LIMIT,
dropRecentFile,
isRecentFilesStorageEvent,
parseRecentFiles,
Expand All @@ -71,13 +69,32 @@ const {
updateStoredRecentFiles,
} = await import('../src/lib/utils/recentFiles.js');

/*
* The key and the cap, written out rather than imported.
*
* They used to be `RECENT_FILES_KEY` and `RECENT_FILES_LIMIT`, exported from
* `recentFiles.ts` for this file and for nothing else, and every assertion
* compared the module's value with itself. Measured: `'recent-files'` renamed
* to `'markpad-recent'` — green, and a rename orphans every existing user's
* list, because that literal appears nowhere else to contradict it. `9` changed
* to `3` — green, because the boundedness test seeded `LIMIT + 5` and then
* asserted the result was `LIMIT` long.
*
* Both are on-disk format. A localStorage key and the length of a list the user
* can see are things a reader should be able to look up in the test, and things
* that must not change without someone deciding to change them, so they are
* stated here and the exports are gone.
*/
const KEY = 'recent-files';
const LIMIT = 9;

function seed(files: string[]) {
backing.clear();
writes.length = 0;
backing.set(RECENT_FILES_KEY, JSON.stringify(files));
backing.set(KEY, JSON.stringify(files));
}

const stored = () => parseRecentFiles(backing.get(RECENT_FILES_KEY) ?? null);
const stored = () => parseRecentFiles(backing.get(KEY) ?? null);

/**
* A window: its own in-memory copy of the list, exactly as the component holds
Expand Down Expand Up @@ -153,10 +170,31 @@ test('renaming onto a path already in the list leaves one entry', () => {
});

test('the list stays bounded no matter how the entries arrived', () => {
const many = Array.from({ length: RECENT_FILES_LIMIT + 5 }, (_, index) => `/f${index}.md`);
const many = Array.from({ length: LIMIT + 5 }, (_, index) => `/f${index}.md`);
seed(many);
makeWindow([]).open('/new.md');
assert.equal(stored().length, RECENT_FILES_LIMIT);
assert.equal(stored().length, LIMIT);
assert.equal(stored()[0], '/new.md');
});

test('each of the two caps is load-bearing on its own', () => {
// An open goes through both of them — `promoteRecentFile` caps, and
// `updateStoredRecentFiles` caps again — so the test above stays green when
// either one is deleted, and only fails when both are. Each layer is driven
// alone here: the pure function directly, and the helper with a mutation
// that does not cap.
//
// Both layers earn their keep. `promoteRecentFile` is exported and is the
// one that defines what "recent" means; the cap in `updateStoredRecentFiles`
// is what bounds the stored list whatever a caller hands it, including the
// merge of a sibling window's longer list.
const many = Array.from({ length: LIMIT + 5 }, (_, index) => `/f${index}.md`);

assert.equal(promoteRecentFile(many, '/new.md').length, LIMIT, 'promoteRecentFile caps what it returns');

seed(many);
updateStoredRecentFiles((current) => ['/new.md', ...current]);
assert.equal(stored().length, LIMIT, 'and the stored list is capped even when the mutation is not');
assert.equal(stored()[0], '/new.md');
});

Expand All @@ -173,25 +211,25 @@ test('a write that changes nothing does not wake the other windows', () => {

test('a corrupt entry is survivable', () => {
backing.clear();
backing.set(RECENT_FILES_KEY, '{not json');
backing.set(KEY, '{not json');
assert.deepEqual(readStoredRecentFiles(), []);
backing.set(RECENT_FILES_KEY, '{"a":1}');
backing.set(KEY, '{"a":1}');
assert.deepEqual(readStoredRecentFiles(), []);
backing.set(RECENT_FILES_KEY, '["/a.md", 7, null]');
backing.set(KEY, '["/a.md", 7, null]');
assert.deepEqual(readStoredRecentFiles(), ['/a.md']);
// And a corrupt entry must not stop the next open from being recorded.
backing.set(RECENT_FILES_KEY, 'garbage');
backing.set(KEY, 'garbage');
makeWindow([]).open('/b.md');
assert.deepEqual(stored(), ['/b.md']);
});

test('only this key, and a wholesale clear, count as a remote change', () => {
assert.equal(isRecentFilesStorageEvent({ key: RECENT_FILES_KEY, storageArea: null }), true);
assert.equal(isRecentFilesStorageEvent({ key: KEY, storageArea: null }), true);
// `key: null` is localStorage being cleared.
assert.equal(isRecentFilesStorageEvent({ key: null, storageArea: null }), true);
assert.equal(isRecentFilesStorageEvent({ key: 'theme', storageArea: null }), false);
assert.equal(
isRecentFilesStorageEvent({ key: RECENT_FILES_KEY, storageArea: {} as Storage }),
isRecentFilesStorageEvent({ key: KEY, storageArea: {} as Storage }),
false,
'sessionStorage is a different store',
);
Expand Down
41 changes: 40 additions & 1 deletion scripts/sourceTree.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { callbackBodies, enclosingFunctionName, offsetOf, sliceBetween, sliceFrom } from './sourceTree.js';
import { callbackBodies, enclosingFunctionName, functionSource, offsetOf, sliceBetween, sliceFrom } from './sourceTree.js';

// scripts/sourceTree.ts is the plumbing four convention tests state their claims
// through, so a hole in it is a hole in all of them at once, and it is invisible
Expand Down Expand Up @@ -94,6 +94,45 @@ test('a callback body is its own body, whatever the closing brace looks like', (
assert.deepEqual(callbackBodies(source, 'onMount'), []);
});

test('a function is extracted as a function, not as "up to the next declaration"', () => {
// The hole this closes: an anchor pair that names the *neighbour* stops
// bounding the subject as soon as anything is declared between them, and a
// neighbour carrying the same guard then satisfies the assertion. Here the
// two functions are deliberately near-identical, which is the case in
// MarkdownViewer.svelte (`canTransfer`/`canDetach`,
// `handleDetach`/`moveTabToWindow`) that went undetected.
const source = [
'<script lang="ts">',
'\tasync function subject(id: string) {',
'\t\treturn guard(id);',
'\t}',
'',
'\tasync function neighbour(id: string) {',
'\t\treturn guard(id);',
'\t}',
'',
'\tconst options = {',
'\t\tpredicate: (id: string) => guard(id),',
'\t};',
'</script>',
].join('\n');

assert.equal(functionSource(source, 'subject'), 'async function subject(id: string) {\n\t\treturn guard(id);\n\t}');
assert.equal(functionSource(source, 'predicate'), '(id: string) => guard(id)');

// The neighbour is not part of the subject, which is the whole point.
assert.doesNotMatch(functionSource(source, 'subject'), /neighbour/);

// A renamed or duplicated subject is a failure with a name, not a wider slice.
assert.throws(() => functionSource(source, 'gone'), /exactly one function named "gone", found 0/);
// Two functions of the same name is the case a "first match wins" helper
// would silently pick one of — a wrapper and the method it delegates to, say.
assert.throws(
() => functionSource(source.replace('predicate:', 'subject:'), 'subject'),
/exactly one function named "subject", found 2/,
);
});

test('the parsers read .ts files as well as components', () => {
const module = 'export function fromModule() {\n\tHERE;\n}\n';
assert.equal(enclosingFunctionName(module, offsetOf(module, 'HERE')), 'fromModule');
Expand Down
28 changes: 28 additions & 0 deletions scripts/sourceTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,34 @@ export function enclosingFunctionName(text: string, index: number): string | nul
return name;
}

/**
* The source of the one function bound to `name`, whatever the spelling —
* `function name() {}`, `const name = () => {}`, `{ name: (…) => {} }`, a class
* method.
*
* For the checks that state something about *one function*, where the anchor
* pair `sliceBetween(text, 'async function subject', 'async function
* whatever-comes-next')` names the neighbour rather than the subject: the slice
* is then only as tight as the declaration order happens to make it, and it
* widens silently as the file grows. Both uses in truncatedBufferGuard.test.ts
* had already grown past their subject — the transfer slice ran from
* `canTransfer` through `canDetach`, the detach slice from `handleDetach`
* through `moveTabToWindow` — and each swallowed function carries a
* character-identical copy of the guard under test. Measured: the guard deleted
* from the subject, the neighbour's copy satisfying the `assert.match`, the
* whole suite green.
*
* A function extracted as a function cannot drift that way. `assert.equal` on
* the count rather than "the first one" is deliberate: a rename or a second
* definition of the same name fails here, loudly, instead of quietly changing
* what the caller is asserting about.
*/
export function functionSource(text: string, name: string): string {
const found = functionScopes(text).filter((scope) => scope.name === name);
assert.equal(found.length, 1, `expected exactly one function named ${JSON.stringify(name)}, found ${found.length}`);
return text.slice(found[0].start, found[0].end);
}

/**
* The source text of each function argument passed to `callee(…)`, braces
* included.
Expand Down
Loading
Loading