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
209 changes: 178 additions & 31 deletions test/webview/cm-block-widget-bounded.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// @vitest-environment happy-dom
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { ensureSyntaxTree } from "@codemirror/language";
import { syntaxTreeAvailable } from "@codemirror/language";
import {
EditorSelection,
EditorState,
type Extension,
type SelectionRange,
} from "@codemirror/state";
import { type DecorationSet, EditorView, type WidgetType } from "@codemirror/view";
import type { DecorationSet, WidgetType } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { imageBlockField } from "../../src/webview/cm/image/index.js";
import { settledState } from "./helpers/settled-state.js";
import { settledMount } from "./helpers/settled-view.js";

interface Slot {
from: number;
Expand All @@ -25,7 +27,13 @@ function slots(set: DecorationSet): Slot[] {
}
return out;
}
function assertEquivalent(actual: Slot[], oracle: Slot[]): void {
// `expectedSlots` is per-row on purpose. Comparing actual to oracle is vacuous when BOTH
// are empty, and a field-wide break that emptied imageBlockField would leave the whole
// table green; but a blanket "non-empty" pin would be wrong too — the "G1 merge" row
// legitimately ends at zero standalone widgets (the image is demoted to inline). Only the
// per-row count distinguishes "this row's expected shape" from "the field stopped working".
function assertEquivalent(actual: Slot[], oracle: Slot[], expectedSlots: number): void {
expect(oracle).toHaveLength(expectedSlots);
expect(actual.map((s) => ({ from: s.from, to: s.to }))).toEqual(
oracle.map((s) => ({ from: s.from, to: s.to }))
);
Expand All @@ -45,87 +53,206 @@ interface Edit {
cursorAtEnd?: boolean; // resolve to cursor(doc.length) AFTER the change (avoids RangeError)
}

function checkEquivalence(initial: string, edits: Edit[]): void {
const parent = document.createElement("div");
document.body.appendChild(parent);
const view = new EditorView({
state: EditorState.create({ doc: initial, extensions: exts() }),
parent,
});
try {
ensureSyntaxTree(view.state, view.state.doc.length, 10_000);
for (const e of edits) {
view.dispatch({ changes: e.changes, selection: e.selection });
if (e.cursorAtEnd) {
view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
}
ensureSyntaxTree(view.state, view.state.doc.length, 10_000);
// The two ways a `changes` object normalises to an empty ChangeSet are pinned directly by
// the door-guard test below (`{ changes: { from: 0 } }` and `{ changes: { from: 0, to: 0 } }`,
// with and without an explicit `insert: ""`), not restated here. An inert `changes` alone
// only kills imageBlockField.update's docChanged arm (image-field.ts:272) — paired with a
// real `selection` on the same `Edit`, it can still reach `computeBounded` through the
// selection arm (image-field.ts:284); the door guard below requires `!e.selection` too.
const inertChanges = (c: Edit["changes"]) =>
c !== undefined && !c.insert && (c.to === undefined || c.to === c.from);

// Where the settles go, and — just as load-bearing — where they do NOT.
//
// The MOUNT settles: imageBlockField.create() reads syntaxTree(state) (image-field.ts),
// so on a truncated init snapshot the field's INITIAL value is built over a partial tree
// and the first edit's bounded path starts from a wrong `prev`. A bare ensureSyntaxTree
// cannot fix that — it advances the parse CONTEXT and leaves the field's published
// SNAPSHOT alone. Settling here precedes every edit, so it vacates nothing.
//
// The ORACLE settles: that is what makes `want` the true full-recompute result. It also
// removes a live vacuity — assertEquivalent([], []) passes when BOTH sides are truncated
// to nothing, which is exactly what two bare ensureSyntaxTree calls used to produce.
//
// The EDIT LOOP must NOT settle. `forceParsing` dispatches whenever the completed tree
// differs from the published snapshot, and that dispatch drives imageBlockField.update's
// tree-identity branch into computeFreshFull — silently turning this bounded-vs-full
// compare into full-vs-full. That trades a flake for a vacuous pass, which is worse.
// The pin below is what keeps the bounded result trustworthy without a settle.
//
// ⚠️ The comparison is ATTEMPTED rather than asserted on the first try. CodeMirror gives its
// post-edit reparse a 20ms WALL-CLOCK budget, and under CPU starvation that window can
// elapse while this process is descheduled; image-field.ts's G2 arm then self-heals with a
// full recompute, so the bounded path is not what ran and there is nothing to compare.
// Retrying from a fresh view neither hides a regression (a real bounded bug reds every
// attempt that gets far enough to compare — measured by breaking computeBounded) nor passes
// vacuously (an all-starved run throws below), which a vitest-level `{ retry: n }` would
// fail on both counts.
function checkEquivalence(initial: string, edits: Edit[], oracleSlots: number): void {
if (
edits.every((e) => (!e.changes || inertChanges(e.changes)) && !e.selection && !e.cursorAtEnd)
) {
// What this rules out: the ALL-inert call — every edit in the array has no `changes` (or
// a `changes` object that normalises to an empty ChangeSet, per `inertChanges` above), no
// `selection`, and no `cursorAtEnd` (`[]`, `[{}]`, an array of empty-object `Edit`s). Only
// then is every dispatch below a literal no-op, so comparing the settled mount against the
// settled oracle would report success having exercised no bounded path. The predicate is
// `.every(...)`, so a MIXED array — even one live edit among otherwise-inert ones — passes
// the door; that is deliberate, since one live edit is enough for the comparison below to
// be ABLE to exercise a bounded arm. Whether it actually does is decided downstream, by
// image-field.ts's own gates — see "What this does NOT rule out" below.
// What this does NOT rule out: a `selection`/`cursorAtEnd` edit that dispatches something
// real but whose selection LINE SPAN happens not to change. On a non-docChanged
// transaction, reaching image-field.ts's `computeBounded` requires first surviving its G3
// frontmatter check (:269) and its tree-identity check (:278), and only then finding
// `!selectionLineSpansEqual(tr.startState, tr.state)` (:281) — so that inequality is a
// NECESSARY condition for the bounded arm, not a sufficient one, and deciding it at the
// door would mean reimplementing all three checks here (none are exported from
// image-field.ts). Unlike the `changes` shape above — which `inertChanges` decides from
// the `Edit` literal alone — whether a selection move crosses a line boundary depends on
// both transaction states, which this predicate does not have. So this guard is a floor,
// not a guarantee: it catches the fully inert call, not every selection-only call that
// fails to cross a line boundary. The "selection-only onto then off an image" case below
// is deliberately NOT inert — moving the cursor onto and then off the image's line
// crosses that boundary and exercises computeBounded via the selection arm, not the
// docChanged one.
throw new Error(
"checkEquivalence: at least one edit with `changes`, `selection`, or `cursorAtEnd` is required to exercise the bounded path"
);
}
for (let attempt = 0; attempt < 5; attempt++) {
if (runOnce()) {
return;
}
const oracle = EditorState.create({
doc: view.state.doc.toString(),
selection: view.state.selection,
extensions: exts(),
});
ensureSyntaxTree(oracle, oracle.doc.length, 10_000);
assertEquivalent(
slots(view.state.field(imageBlockField)),
slots(oracle.field(imageBlockField))
}
throw new Error(
"checkEquivalence: no attempt reached a complete post-edit frontier, so nothing was compared"
);

/** One attempt. Returns false when the frontier was starved and nothing was compared. */
function runOnce(): boolean {
const parent = document.createElement("div");
document.body.appendChild(parent);
const view = settledMount(
{ state: EditorState.create({ doc: initial, extensions: exts() }), parent },
10_000
);
} finally {
view.destroy();
try {
for (const e of edits) {
view.dispatch({ changes: e.changes, selection: e.selection });
if (e.cursorAtEnd) {
view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
}
// Anti-masking gate, once per `Edit` — which on a `cursorAtEnd` row is after TWO
// dispatches, not one. (The sibling in
// decorations/cm-decoration-callout-marker-conceal.test.ts says "per-dispatch"
// because there an `Edit` IS exactly one dispatch.)
//
// Operating rule for this loop: nothing may sit between any two of the dispatches
// this loop performs — within a `cursorAtEnd` pair AND across iterations, since the
// gate read right below is itself inside that window — that advances the parse or
// publishes a tree: no settle, no parse-advancing read (ensureSyntaxTree, a
// `fullTree` probe, forceParsing, …), no second doc-changing dispatch, and no `await`
// or timer flush that yields to the event loop. The gate's no-op guarantee depends on
// this loop staying straight-line synchronous code end to end; break that shape and
// the guarantee breaks with it.
//
// ⚠️ What a `true` rules out is the STARVED-frontier full walk, and nothing more.
// imageBlockField.update takes its G3 arm — computeFreshFull — whenever
// leadingFrontmatterEnd changes, BEFORE this predicate is ever consulted
// (image-field.ts), so `true` does not mean the bounded path ran. The "G3
// frontmatter length shift" row below takes that arm, and what it compares there is
// the field's INCREMENTALLY parsed full walk against the oracle's freshly parsed
// one — not bounded against full. (Measured 2026-09-02: deleting the G3 arm leaves
// every row here green, so this table does not pin that guard either way.) A false
// means the frontier was starved, so abandon the attempt instead of comparing a
// full walk over a PARTIAL tree against the settled oracle.
if (!syntaxTreeAvailable(view.state, view.state.doc.length)) {
return false;
}
}
const oracle = settledState(
EditorState.create({
doc: view.state.doc.toString(),
selection: view.state.selection,
extensions: exts(),
})
);
assertEquivalent(
slots(view.state.field(imageBlockField)),
slots(oracle.field(imageBlockField)),
oracleSlots
);
return true;
} finally {
view.destroy();
parent.remove();
}
}
}

const IMG = "![alt](https://example.com/a.png)";

describe("imageBlockField bounded ≡ full", () => {
const cases: Array<{ name: string; initial: string; edits: Edit[] }> = [
// `oracleSlots` is the widget count the settled oracle must hold AFTER the edits —
// measured, not guessed. See assertEquivalent for why a per-row count and not a blanket
// non-empty pin.
const cases: Array<{ name: string; initial: string; edits: Edit[]; oracleSlots: number }> = [
{
name: "type prose far from an image",
initial: `# Top\n\nprose\n\n${IMG}\n\nmore`,
edits: [{ changes: { from: 2, insert: "x" }, selection: EditorSelection.cursor(3) }],
oracleSlots: 1,
},
{
name: "introduce a standalone image from scratch",
initial: "plain text\n",
edits: [{ changes: { from: 0, to: 10, insert: IMG }, cursorAtEnd: true }],
oracleSlots: 1,
},
{
name: "insert an image before an existing one",
initial: `${IMG}\n\n${IMG}\n`,
edits: [{ changes: { from: 0, insert: `${IMG}\n\n` }, cursorAtEnd: true }],
oracleSlots: 3,
},
{
name: "edit the url inside an image",
initial: `${IMG}\n\nbelow`,
edits: [{ changes: { from: 20, insert: "z" }, cursorAtEnd: true }],
oracleSlots: 1,
},
{
name: "delete an image",
initial: `${IMG}\n\nmid\n\n${IMG}\n`,
edits: [{ changes: { from: 0, to: IMG.length + 1 }, cursorAtEnd: true }],
oracleSlots: 1,
},
// G1: blank-line toggle ADJACENT to the image flips standalone eligibility
// without touching the image's bytes.
{
name: "G1 split: blank line above promotes image to standalone",
initial: `prose\n${IMG}\n`,
edits: [{ changes: { from: 5, insert: "\n" }, cursorAtEnd: true }],
oracleSlots: 1,
},
{
name: "G1 merge: delete blank line above demotes image",
initial: `prose\n\n${IMG}\n`,
edits: [{ changes: { from: 5, to: 6 }, cursorAtEnd: true }],
oracleSlots: 0, // the demoted image is inline, so ZERO standalone widgets is the answer
},
{
name: "G1 below: blank line below promotes image",
initial: `${IMG}\ntext\n`,
edits: [{ changes: { from: IMG.length, insert: "\n" }, cursorAtEnd: true }],
oracleSlots: 1,
},
{
name: "G3 frontmatter length shift before image",
initial: `---\ntitle: a\n---\n\n${IMG}\n`,
edits: [{ changes: { from: 11, insert: "bb" }, cursorAtEnd: true }],
oracleSlots: 1,
},
{
name: "multi-cursor far apart",
Expand All @@ -139,14 +266,34 @@ describe("imageBlockField bounded ≡ full", () => {
]),
},
],
oracleSlots: 1, // two images, but the cursor at 0 reveals the first one
},
{
name: "selection-only onto then off an image",
initial: `${IMG}\n\nbelow text`,
edits: [{ selection: EditorSelection.cursor(3) }, { selection: EditorSelection.cursor(40) }],
oracleSlots: 1,
},
];
for (const c of cases) {
it(c.name, () => checkEquivalence(c.initial, c.edits));
it(c.name, () => checkEquivalence(c.initial, c.edits, c.oracleSlots));
}

it("door guard throws when no edit can produce a doc-visible transaction", () => {
const inertEditLists: Edit[][] = [
[],
[{}],
[{ changes: { from: 0 } }], // no `to`/`insert` — normalises to an empty ChangeSet
[{ changes: { from: 0, to: 0 } }], // `to === from`, no `insert` — same normalisation
[{ changes: { from: 0, to: 0, insert: "" } }], // `to === from` with an explicit empty insert
];
// `prose\n\n${IMG}\n` settles to exactly 1 standalone image slot at rest (measured), so
// a non-vacuous check on the guard's throw does not depend on it: with the guard
// disabled these edits would dispatch as true no-ops and the comparison below would
// pass, not throw, for an unrelated reason (an oracleSlots mismatch on a doc that
// doesn't settle to that count at rest).
for (const edits of inertEditLists) {
expect(() => checkEquivalence(`prose\n\n${IMG}\n`, edits, 1)).toThrow();
}
});
});
36 changes: 21 additions & 15 deletions test/webview/cm-code-block-highlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
// activation, prototype-safe lookup (a ```constructor fence must not crash the parse),
// display-only rendering, and picker<->parser-registry sync. Part B (appended in Task 2)
// adds the language-scoped styling pins.
import { ensureSyntaxTree } from "@codemirror/language";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { highlightTree, tags as t } from "@lezer/highlight";
Expand All @@ -18,6 +17,8 @@ import {
import { LANGUAGE_OPTIONS } from "../../src/webview/cm/fenced-code/fenced-code-languages.js";
import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js";
import { quollCodeHighlightSpec } from "../../src/webview/cm/theme.js";
import { fullTree } from "./helpers/full-tree.js";
import { settledMount, settledView } from "./helpers/settled-view.js";

const lang = quollMarkdownLanguage();
const FENCED = ["```js", "const x = 1 // hi", "```", ""].join("\n");
Expand All @@ -31,8 +32,7 @@ afterEach(() => {
function mount(doc: string): EditorView {
const parent = document.createElement("div");
document.body.appendChild(parent);
const v = new EditorView({ parent, state: EditorState.create({ doc, extensions: [lang] }) });
ensureSyntaxTree(v.state, v.state.doc.length, 5000);
const v = settledMount({ parent, state: EditorState.create({ doc, extensions: [lang] }) });
view = v;
return v;
}
Expand All @@ -41,10 +41,7 @@ function mount(doc: string): EditorView {
// highlightTree directly, exactly as the runtime treeHighlighter does.
function codeClassesAt(doc: string, needle: string): string[] {
const state = EditorState.create({ doc, extensions: [lang] });
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
if (!tree) {
throw new Error("no tree");
}
const tree = fullTree(state);
const from = doc.indexOf(needle);
const to = from + needle.length;
const out: string[] = [];
Expand All @@ -65,15 +62,14 @@ function codeClassesAt(doc: string, needle: string): string[] {
describe("code block nested parsing", () => {
it("nests a sub-language inside a ```js fence (interior is not a bare CodeText leaf)", () => {
const state = EditorState.create({ doc: FENCED, extensions: [lang] });
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
expect(tree).not.toBeNull();
const tree = fullTree(state);
const codeStart = FENCED.indexOf("const");
const names = new Set<string>();
for (let n = tree!.resolveInner(codeStart, 1); n; n = n.parent as typeof n) {
for (let n = tree.resolveInner(codeStart, 1); n; n = n.parent as typeof n) {
names.add(n.type.name);
}
expect(names).toContain("FencedCode");
expect(tree!.resolveInner(codeStart, 1).type.name).not.toBe("CodeText");
expect(tree.resolveInner(codeStart, 1).type.name).not.toBe("CodeText");
});

it("codeParserFor maps known ids, strips info, and is case-insensitive", () => {
Expand All @@ -91,7 +87,18 @@ describe("code block nested parsing", () => {
for (const evil of ["constructor", "__proto__", "toString", "hasOwnProperty", "valueOf"]) {
expect(codeParserFor(evil)).toBeNull();
}
expect(() => mount(["```constructor", "x", "```", ""].join("\n"))).not.toThrow();
// Construction (which runs the initial parse over the ```constructor fence) is what
// must not crash; the settle below is a separate step so a parse-budget failure there
// is reported as its own throw rather than misattributed to the fence crashing.
const doc = ["```constructor", "x", "```", ""].join("\n");
let v: EditorView | undefined;
expect(() => {
const parent = document.createElement("div");
document.body.appendChild(parent);
v = new EditorView({ parent, state: EditorState.create({ doc, extensions: [lang] }) });
view = v;
}).not.toThrow();
settledView(v as EditorView);
});

it("skips nested parsing for code blocks over the size cap (protects parse budgets)", () => {
Expand All @@ -101,11 +108,10 @@ describe("code block nested parsing", () => {
const huge = `${"x = 1\n".repeat(10000)}`; // ~60KB of code body
const doc = `\`\`\`js\n${huge}\`\`\`\n`;
const state = EditorState.create({ doc, extensions: [lang] });
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
expect(tree).not.toBeNull();
const tree = fullTree(state);
const codeStart = doc.indexOf("x = 1");
// Interior stays a bare CodeText leaf — no sub-language mount above the cap.
expect(tree!.resolveInner(codeStart, 1).type.name).toBe("CodeText");
expect(tree.resolveInner(codeStart, 1).type.name).toBe("CodeText");
});
});

Expand Down
Loading
Loading