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
24 changes: 13 additions & 11 deletions test/webview/cm-block-widget-byte-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
// comparison is not vacuous.

import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { forceParsing, syntaxTreeAvailable } from "@codemirror/language";
import { syntaxTreeAvailable } from "@codemirror/language";
import {
EditorSelection,
EditorState,
Expand All @@ -58,6 +58,7 @@ import {
tableBlockField,
tableSkeletonField,
} from "../../src/webview/cm/table/index.js";
import { settledView } from "./helpers/settled-view.js";

// ── shared slot + equivalence machinery (mirrors cm-block-widget-bounded) ──────

Expand Down Expand Up @@ -140,9 +141,10 @@ interface Edit {
// tableBlockField reads its widget slices from the bounded-maintained
// tableSkeletonField, so BOTH must be registered for the bounded path to run
// (absent, buildAll falls back to a full walk and the oracle would be vacuous).
// A live view + forceParsing is required: the field converges to the fully-
// parsed result via the tree-identity self-heal branch, exactly as
// cm-table-skeleton.test.ts drives it.
// A live view settled through `settledView` is required: settling advances the
// parse to the doc end AND republishes the tree snapshot, and the field then
// converges to the fully-parsed result via the tree-identity self-heal branch,
// exactly as cm-table-skeleton.test.ts drives it.

const tableExts = (): Extension[] => [
EditorState.allowMultipleSelections.of(true),
Expand All @@ -162,7 +164,7 @@ function tableFullSlots(doc: string, selection: EditorSelection): Slot[] {
parent,
});
try {
forceParsing(view, view.state.doc.length, 10_000);
settledView(view, 10_000);
return slots(view.state.field(tableBlockField));
} finally {
view.destroy();
Expand All @@ -177,7 +179,7 @@ function checkTableEquivalence(initial: string, edits: Edit[]): void {
parent,
});
try {
forceParsing(view, view.state.doc.length, 10_000);
settledView(view, 10_000);
// create() correctness on the fully-parsed initial doc.
assertEquivalent(
slots(view.state.field(tableBlockField)),
Expand All @@ -192,7 +194,7 @@ function checkTableEquivalence(initial: string, edits: Edit[]): void {
const len = view.state.doc.length;
// Pre-self-heal bounded assertion: when the post-edit tree is already
// complete, boundedUpdate ran during dispatch — check its output BEFORE
// forceParsing so a self-heal can't mask a bounded byte bug (Codex R2-2,
// settling so a self-heal can't mask a bounded byte bug (Codex R2-2,
// cm-table-skeleton.test.ts). Whether or not the tree is synchronously
// available here is harness-dependent, so this stays conditional and the
// guaranteed bounded-path pin lives in its own test below.
Expand All @@ -203,7 +205,7 @@ function checkTableEquivalence(initial: string, edits: Edit[]): void {
);
assertTableByteAnchored(view.state);
}
forceParsing(view, len, 10_000); // publish → converge (also covers the G2 path)
settledView(view, 10_000); // publish → converge (also covers the G2 path)
assertEquivalent(
slots(view.state.field(tableBlockField)),
tableFullSlots(view.state.doc.toString(), view.state.selection)
Expand Down Expand Up @@ -297,7 +299,7 @@ describe("tableBlockField byte-identity: bounded ≡ full", () => {
// no-self-heal anchor): a small in-place edit on a complete tree keeps the
// frontier at doc end, so boundedUpdate ran during dispatch. Assert the tree
// IS available (not conditional) and check the field's output — plus its
// byte-anchor — BEFORE any forceParsing, so a broken bounded reuse can't be
// byte-anchor — BEFORE any settling, so a broken bounded reuse can't be
// masked by a self-heal. This is the case the matrix's conditional guard
// cannot guarantee runs.
it("exercises the bounded path without self-heal masking (revert-check anchor)", () => {
Expand All @@ -308,7 +310,7 @@ describe("tableBlockField byte-identity: bounded ≡ full", () => {
parent,
});
try {
forceParsing(view, view.state.doc.length, 10_000);
settledView(view, 10_000);
view.dispatch({
changes: { from: 0, insert: "x" }, // edit OUTSIDE both tables
selection: EditorSelection.cursor(1),
Expand Down Expand Up @@ -507,7 +509,7 @@ describe("CRLF-seeded byte-identity: line-ending-aware widget anchors", () => {
parent,
});
try {
forceParsing(view, view.state.doc.length, 10_000);
settledView(view, 10_000);
const st = view.state;
const emitted = slots(st.field(tableBlockField));
expect(emitted.length).toBe(1); // the table renders (guards against vacuity)
Expand Down
11 changes: 4 additions & 7 deletions test/webview/cm-enter-precedence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@
// (same factories, same order) so it pins the real behaviour, not a reconstruction.

import { defaultKeymap } from "@codemirror/commands";
import { forceParsing } from "@codemirror/language";
import { EditorSelection, EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap, runScopeHandlers } from "@codemirror/view";
import { type EditorView, keymap, runScopeHandlers } from "@codemirror/view";
import { describe, expect, it } from "vitest";

import { fencedCodeEnterKeymap } from "../../src/webview/cm/fenced-code/fenced-code-enter-keymap.js";
Expand All @@ -29,6 +28,7 @@ import {
listContinuationKeymap,
} from "../../src/webview/cm/list/list-continuation-keymap.js";
import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js";
import { settledMount } from "./helpers/settled-view.js";

/** Mount the same Enter-relevant extension slice editor.ts composes, in the same
* order: language (carries upstream markdownKeymap @ Prec.high) → Quoll list
Expand All @@ -46,9 +46,7 @@ function mount(doc: string, caret: number): EditorView {
Prec.default(keymap.of(defaultKeymap)),
],
});
const view = new EditorView({ state, parent });
forceParsing(view, view.state.doc.length, 5_000);
return view;
return settledMount({ state, parent });
}

function pressEnter(view: EditorView): boolean {
Expand All @@ -70,8 +68,7 @@ function quollListDirect(doc: string, caret: number): string {
selection: EditorSelection.cursor(caret),
extensions: [quollMarkdownLanguage()],
});
const view = new EditorView({ state, parent });
forceParsing(view, view.state.doc.length, 5_000);
const view = settledMount({ state, parent });
continueListOnEnter(view);
const out = view.state.doc.toString();
view.destroy();
Expand Down
25 changes: 12 additions & 13 deletions test/webview/cm-markdown-language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
// (dropped in markdown.ts); Quoll's own paste-URL-over-selection handler lives in
// src/webview/cm/paste/url-link-paste.ts and is covered by cm-paste-url-link.test.ts.
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { codeFolding, foldable, forceParsing, syntaxTree } from "@codemirror/language";
import { codeFolding, foldable, syntaxTree } from "@codemirror/language";
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { EditorView, runScopeHandlers } from "@codemirror/view";
import { afterEach, describe, expect, it } from "vitest";
import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js";
import { settledState } from "./helpers/settled-state.js";
import { settledView } from "./helpers/settled-view.js";

const quollLang = quollMarkdownLanguage();
const upstreamLang = markdown({ base: markdownLanguage });
Expand All @@ -48,14 +49,11 @@ function mount(doc: string, anchor: number, selEnd = anchor): EditorView {
// Settle the parse AND republish the language field's tree snapshot: the keymap
// commands exercised below (lang-markdown's insertNewlineContinueMarkupCommand and
// deleteMarkupBackward) read that snapshot via `syntaxTree(state)`, which a bare
// `ensureSyntaxTree` leaves truncated — see helpers/settled-state.ts. `forceParsing`
// is its view-carrying twin, and like `ensureSyntaxTree` it collapses "no language
// attached" and "budget exhausted" into one falsy result — so this assertion's
// failure is only a budget failure because mount() always attaches quollLang.
expect(
forceParsing(view, view.state.doc.length, 5000),
`mount: parse did not complete within 5s for a ${view.state.doc.length}-code-unit document`
).toBe(true);
// `ensureSyntaxTree` leaves truncated — see helpers/settled-state.ts. `settledView`
// is its view-carrying twin: it throws rather than returning a discardable boolean.
// The thrown message names which non-convergence fired; helpers/settled-view.ts's
// docblock is the authority on the full set, so this comment does not restate it.
settledView(view);
return view;
}

Expand Down Expand Up @@ -91,11 +89,12 @@ describe("quollMarkdownLanguage wires the active markdownKeymap", () => {
});

describe("mount() hands the keymap a settled tree snapshot", () => {
// Non-vacuity guard for mount()'s forceParsing settle, made deterministic by DOC
// SIZE instead of CPU load — same construction as cm-fold-blockquote.test.ts's
// Non-vacuity guard for mount()'s `settledView(view)` settle, made deterministic by
// DOC SIZE instead of CPU load — same construction as cm-fold-blockquote.test.ts's
// "reads a settled parse" describe, which documents why an over-long doc always
// lands a truncated snapshot. Swap mount()'s forceParsing back to a bare
// ensureSyntaxTree and the second assertion goes red.
// lands a truncated snapshot. Replace mount()'s `settledView(view)` with a bare
// `ensureSyntaxTree(view.state, view.state.doc.length)` — which advances the parse
// CONTEXT but never republishes the field snapshot — and the second assertion goes red.
const doc = `${"filler paragraph line\n\n".repeat(200)}- alpha`;

it("a freshly-created state's snapshot is truncated (the precondition)", () => {
Expand Down
35 changes: 17 additions & 18 deletions test/webview/decorations/cm-block-zone-arrow-keymap.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
// @vitest-environment happy-dom
import { defaultKeymap } from "@codemirror/commands";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { forceParsing } from "@codemirror/language";
import { EditorSelection, EditorState, type SelectionRange, Transaction } from "@codemirror/state";
import { type DecorationSet, EditorView, keymap, runScopeHandlers } from "@codemirror/view";
import { type DecorationSet, type EditorView, keymap, runScopeHandlers } from "@codemirror/view";
import { describe, expect, it } from "vitest";

import {
Expand All @@ -13,6 +12,7 @@ import {
} from "../../../src/webview/cm/decorations/block-zone-arrow-keymap.js";
import { quollBlockReplaceZones } from "../../../src/webview/cm/decorations/index.js";
import { tableBlockField } from "../../../src/webview/cm/table/index.js";
import { settledMount } from "../helpers/settled-view.js";

function rangesOf(set: DecorationSet): Array<{ from: number; to: number }> {
const out: Array<{ from: number; to: number }> = [];
Expand All @@ -28,17 +28,14 @@ function rangesOf(set: DecorationSet): Array<{ from: number; to: number }> {
// read. tableBlockField.create() builds from the LAZY syntaxTree(state); under
// CPU starvation the bounded initial parse can stop before reaching the table,
// leaving the field empty — a flake that only bit the full parallel suite (the
// `tableBlockField` length 0-vs-1 race). forceParsing(view, doc.length) advances
// the parse and dispatches so the field recomputes from the complete tree — the
// same "force AND publish" mechanism the production resync path uses
// (CellEditorController.revalidateOrResync). ensureSyntaxTree / fullTree alone
// would NOT fix it: they advance the parse but never republish into the field's
// snapshot. See LEARNING.md "syntaxTree(state) は LAZY".
function forceParse(view: EditorView): EditorView {
forceParsing(view, view.state.doc.length, 5_000);
return view;
}

// `tableBlockField` length 0-vs-1 race). `settledMount` (../helpers/settled-view.ts)
// forces the parse to the doc end and republishes the snapshot, so the field
// recomputes from the complete tree — the same "force AND publish" mechanism the
// production resync path uses (`forceParsing` in src/webview/editor.ts) — and it
// throws on non-convergence instead of returning a discardable boolean.
// ensureSyntaxTree / fullTree alone would NOT fix it: they advance the parse but
// never republish into the field's snapshot. See LEARNING.md
// "syntaxTree(state) は LAZY".
function mount(doc: string, selection: EditorSelection | SelectionRange): EditorView {
const parent = document.createElement("div");
document.body.appendChild(parent);
Expand All @@ -51,7 +48,7 @@ function mount(doc: string, selection: EditorSelection | SelectionRange): Editor
tableBlockField,
],
});
return forceParse(new EditorView({ state, parent }));
return settledMount({ state, parent });
}

const TABLE = "| H1 | H2 |\n| -- | -- |\n| a1 | a2 |";
Expand Down Expand Up @@ -174,7 +171,7 @@ describe("blockZoneArrowDown", () => {
observer,
],
});
const view = forceParse(new EditorView({ state, parent }));
const view = settledMount({ state, parent });
try {
expect(blockZoneArrowDown(view)).toBe(true);
expect(dispatched).toContain("select");
Expand Down Expand Up @@ -226,8 +223,10 @@ describe("mount — forces parse readiness (lazy-parse flake guard)", () => {
// parse of a tiny doc; here we trigger the SAME root condition without luck
// by pushing the table PAST CodeMirror's ~3000-char initial parse viewport,
// so the create-time lazy tree provably lacks the Table node. A mount that
// does not force+publish a complete parse yields an empty field. Revert the
// forceParse step in mount() and this assertion goes red.
// does not force+publish a complete parse yields an empty field. To see it go
// red: in mount(), return `new EditorView({ state, parent })` instead of
// `settledMount({ state, parent })`, dropping `type` from this file's
// `EditorView` import so the class is in scope as a value.
const pad = "padding paragraph line.\n\n".repeat(200); // > 3000 chars
const bigDoc = `${pad}${TABLE}\n\nbelow`;
const tableFrom = bigDoc.indexOf(TABLE);
Expand Down Expand Up @@ -311,7 +310,7 @@ describe("blockZoneArrowKeymap — precedence vs defaultKeymap", () => {
blockZoneArrowKeymap(),
],
});
return forceParse(new EditorView({ state, parent }));
return settledMount({ state, parent });
}

it("ArrowDown dispatched via runScopeHandlers lands caret at TABLE_FROM (Prec.high wins over defaultKeymap)", () => {
Expand Down
5 changes: 3 additions & 2 deletions test/webview/decorations/cm-decoration-orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { forceParsing, syntaxTree } from "@codemirror/language";
import { syntaxTree } from "@codemirror/language";
import {
Compartment,
EditorSelection,
Expand Down Expand Up @@ -30,6 +30,7 @@ import type {
BuildContext,
DecorationProvider,
} from "../../../src/webview/cm/decorations/types.js";
import { settledView } from "../helpers/settled-view.js";

function tagsOf(set: DecorationSet): string[] {
const out: string[] = [];
Expand Down Expand Up @@ -235,7 +236,7 @@ describe("decoration orchestrator — ViewPlugin", () => {
try {
const before = trees.length;
const beforeTree = syntaxTree(view.state);
forceParsing(view, view.state.doc.length, 5_000);
settledView(view);
const afterTree = syntaxTree(view.state);
// PRECONDITION: forceParsing advanced the tree. If this fires, the doc
// no longer exceeds the 3000-char init viewport (the mount tree is already
Expand Down
36 changes: 27 additions & 9 deletions test/webview/fenced-code/cm-fenced-code-enter-keymap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,14 @@ vi.mock("@codemirror/language", async (importActual) => {
return { ...actual, ensureSyntaxTree: vi.fn(actual.ensureSyntaxTree) };
});

import { ensureSyntaxTree, forceParsing } from "@codemirror/language";
import { ensureSyntaxTree, syntaxTreeAvailable } from "@codemirror/language";

import {
autoCloseFenceOnEnter,
fencedCodeEnterKeymap,
} from "../../../src/webview/cm/fenced-code/fenced-code-enter-keymap.js";
import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js";

function forceParse(view: EditorView): EditorView {
forceParsing(view, view.state.doc.length, 5_000);
return view;
}
import { settledMount } from "../helpers/settled-view.js";

function mount(
doc: string,
Expand All @@ -44,7 +40,7 @@ function mount(
...(opts.withKeymap ? [fencedCodeEnterKeymap()] : []),
],
});
return forceParse(new EditorView({ state, parent }));
return settledMount({ state, parent });
}

/** Cursor at the END of 1-based line `n`. */
Expand Down Expand Up @@ -264,8 +260,20 @@ describe("autoCloseFenceOnEnter — history + keymap wiring", () => {
// actually a fenced-block opener (whose extent the parser must resolve anyway).
// A plain-paragraph Enter must NOT force a parse to end-of-document.
describe("autoCloseFenceOnEnter — lazy parse (no EOF parse on non-triggers)", () => {
// Mount WITHOUT forceParse — a fresh, mostly-unparsed large doc, mirroring a
// just-opened file where an eager EOF parse would stall.
// Mount WITHOUT settling the parse — deliberately NOT this file's `mount()`, which
// builds through `settledMount`. Here we want a fresh, mostly-unparsed large doc,
// mirroring a just-opened file where an eager EOF parse would stall.
//
// ⚠️ The spy is cleared AFTER the mount, so settling this fixture adds no observed
// call: the spy-based assertions cannot notice, and before the guards below existed a
// settled fixture kept all 20 tests green while the doc silently stopped being
// unparsed (measured 2026-08-30). Re-check by wrapping this return in `settledMount`:
// three tests must now go red at their `syntaxTreeAvailable` lines.
// The three big-doc tests assert the precondition explicitly, rather than
// leaving "keep this one unsettled" as a rule the reader has to remember — the same
// reasoning that put the settle check inside `settledView` in the first place.
// The guard cannot live in this factory: the small `\`\`\`ruby\nputs 1` fixture below is
// already fully parsed at mount, and only the big docs have a real unparsed precondition.
function mountUnparsed(doc: string, caret: number): EditorView {
const parent = document.createElement("div");
document.body.appendChild(parent);
Expand All @@ -289,6 +297,9 @@ describe("autoCloseFenceOnEnter — lazy parse (no EOF parse on non-triggers)",
const view = mountUnparsed(bigProseDoc(), 3); // caret on line 1 (prose)
const caretLineTo = view.state.doc.lineAt(3).to;
const docLength = view.state.doc.length;
// PRECONDITION: the fixture really is unparsed. Without this, settling it would turn
// this test into a duplicate of its `mount()`-based sibling without going red.
expect(syntaxTreeAvailable(view.state, docLength)).toBe(false);
spy.mockClear();
try {
expect(autoCloseFenceOnEnter(view)).toBe(false);
Expand Down Expand Up @@ -316,6 +327,9 @@ describe("autoCloseFenceOnEnter — lazy parse (no EOF parse on non-triggers)",
const bodyLine = view.state.doc.line(2); // "first body line"
view.dispatch({ selection: EditorSelection.cursor(bodyLine.to) });
const docLength = view.state.doc.length;
// PRECONDITION: the fixture really is unparsed. Without this, settling it would turn
// this test into a duplicate of its `mount()`-based sibling without going red.
expect(syntaxTreeAvailable(view.state, docLength)).toBe(false);
spy.mockClear();
try {
expect(autoCloseFenceOnEnter(view)).toBe(false);
Expand All @@ -338,6 +352,10 @@ describe("autoCloseFenceOnEnter — lazy parse (no EOF parse on non-triggers)",
const trailing = Array.from({ length: 4000 }, (_, i) => `after ${i}`).join("\n");
const doc = `\`\`\`ruby\n${bigBody}\n\`\`\`\n${trailing}`;
const view = mountUnparsed(doc, 0);
// PRECONDITION: same as the two tests above — this one exists to exercise the LAZY
// path (its sibling at the top of the file force-parses), so a settled fixture would
// silently make it a duplicate.
expect(syntaxTreeAvailable(view.state, view.state.doc.length)).toBe(false);
view.dispatch({ selection: EditorSelection.cursor(view.state.doc.line(1).to) }); // opener line
try {
expect(autoCloseFenceOnEnter(view)).toBe(false);
Expand Down
Loading
Loading