From d1ad9d858e21ed2f586472369017be1b4552c6b3 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Fri, 21 Aug 2026 23:27:57 +1000 Subject: [PATCH 1/4] test(table): extract the shared cell-render markup fixtures --- .../table/helpers/cell-render-fixtures.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/webview/table/helpers/cell-render-fixtures.ts diff --git a/test/webview/table/helpers/cell-render-fixtures.ts b/test/webview/table/helpers/cell-render-fixtures.ts new file mode 100644 index 00000000..4cf1a47c --- /dev/null +++ b/test/webview/table/helpers/cell-render-fixtures.ts @@ -0,0 +1,39 @@ +// Fixtures shared by the `cm-table-cell-render-*.test.ts` suites that assert on +// rendered markup — urls, emphasis and text. (The clicks suite dispatches events +// against the returned nodes and needs neither; inline-ir and render-map never +// touch innerHTML.) Extracted rather than copied per file because the tooltip +// strip had 11 occurrences before the split, spread over three files-to-be: one +// definition is one place to fix when the tooltip's text changes, eleven is +// eleven chances to miss one. Not a test file itself (no `.test.ts` suffix), +// mirroring helpers/widget-fixtures.ts. + +/** Serialise rendered cell nodes to markup. + * + * Appends CLONES: `appendChild` would move the caller's nodes into this + * throwaway root, leaving the array it still holds detached from whatever it + * was. No caller reads its nodes after serialising them today, so this changes + * no result — it removes the trap a shared serialiser would otherwise set for + * the first test that wants to check markup and then dispatch an event. */ +export function html(nodes: Node[]): string { + const root = document.createElement("div"); + for (const n of nodes) { + root.appendChild(n.cloneNode(true)); + } + return root.innerHTML; +} + +/** `html`, minus the discoverability tooltip, whose text resolves "Cmd" vs + * "Ctrl" at module load from `navigator.platform` — a structural snapshot that + * kept it would pass or fail by platform. + * + * Matched by its exact shape rather than as "any title attribute": the tooltip + * is the only title cell-render.ts sets today, and a broad strip would silently + * erase a meaningful one added later, leaving the snapshot green while the + * attribute went unpinned by anything. + * + * Stripping it here does NOT leave the tooltip unpinned. That links and + * autolinks both carry one, and that it names the modifier, is asserted off + * `a.title` in cm-table-cell-render-clicks.test.ts. */ +export function htmlWithoutTooltip(nodes: Node[]): string { + return html(nodes).replace(/ title="(?:Cmd|Ctrl)\+click to open"/g, ""); +} From c21d587deedb3fb69d3fb2600952691a90b44f7e Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Fri, 21 Aug 2026 23:28:28 +1000 Subject: [PATCH 2/4] test(table): split the cell-render suite by concern --- test/webview/styles-contract.test.ts | 2 +- .../table/cm-table-cell-inline-ir.test.ts | 186 +++ .../table/cm-table-cell-render-clicks.test.ts | 179 +++ .../cm-table-cell-render-emphasis.test.ts | 323 ++++ .../table/cm-table-cell-render-map.test.ts | 318 ++++ .../table/cm-table-cell-render-text.test.ts | 90 ++ .../table/cm-table-cell-render-urls.test.ts | 315 ++++ .../table/cm-table-cell-render.test.ts | 1314 ----------------- 8 files changed, 1412 insertions(+), 1315 deletions(-) create mode 100644 test/webview/table/cm-table-cell-inline-ir.test.ts create mode 100644 test/webview/table/cm-table-cell-render-clicks.test.ts create mode 100644 test/webview/table/cm-table-cell-render-emphasis.test.ts create mode 100644 test/webview/table/cm-table-cell-render-map.test.ts create mode 100644 test/webview/table/cm-table-cell-render-text.test.ts create mode 100644 test/webview/table/cm-table-cell-render-urls.test.ts delete mode 100644 test/webview/table/cm-table-cell-render.test.ts diff --git a/test/webview/styles-contract.test.ts b/test/webview/styles-contract.test.ts index c3ffbbd6..86334875 100644 --- a/test/webview/styles-contract.test.ts +++ b/test/webview/styles-contract.test.ts @@ -446,7 +446,7 @@ describe("styles.css — widgets consume the accent tokens (palette refresh use /\.quoll-table-block code\s*\{[^}]*background\s*:\s*var\(--quoll-surface-fill/s ); }); - // Strikethrough / highlight inside a table cell (cm-table-cell-render renders + // Strikethrough / highlight inside a table cell (cell-render.ts renders // /). happy-dom does not apply CSS, so pin the source rule text // (same idiom as the table-link/code pins above): line-through, // reusing the shared --quoll-highlight-bg tint. Non-vacuous — both red if the diff --git a/test/webview/table/cm-table-cell-inline-ir.test.ts b/test/webview/table/cm-table-cell-inline-ir.test.ts new file mode 100644 index 00000000..f9679d36 --- /dev/null +++ b/test/webview/table/cm-table-cell-inline-ir.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment happy-dom +// `parseCellInline` losslessness: the IR's spans must PARTITION the source — +// ordered, contiguous, gap-free, covering — so that slicing the source by them +// and concatenating gives the input back character for character. Everything +// downstream (dimming, the source map, drag mapping) reads offsets off this IR, +// so a construct whose arm forgets a span silently shifts every offset after it. +// Two levels are pinned: the outer partition over the whole cell, and each +// leaf's own boundary spans partitioning ITS outer span (a link's brackets, +// label, parens and destination) — the level at which dimming picks characters, +// and invisible to the outer check. +// The DOM is never touched here, but the pragma stays: it is how every file in +// this directory declares its environment, and the module under test is one +// import away from the renderer that does. +import { describe, expect, it } from "vitest"; + +import type { Resolved, Span } from "../../../src/webview/cm/inline/inline-emphasis.js"; +import type { CellLeaf } from "../../../src/webview/cm/inline/inline-ir.js"; +import { parseCellInline } from "../../../src/webview/cm/inline/inline-ir.js"; + +// ── parseCellInline losslessness ───────────────────────────────────────────── + +// Depth-first ordered leaf spans: text spans, leaf outer spans, and for +// emphasis the openDelim span, then children (recursive), then closeDelim. +function leafSpans(ir: Resolved[]): Array<{ from: number; to: number }> { + const out: Array<{ from: number; to: number }> = []; + for (const n of ir) { + if (n.kind === "emphasis") { + out.push(n.openDelim, ...leafSpans(n.children), n.closeDelim); + } else { + out.push(n.span); + } + } + return out; +} + +describe("parseCellInline losslessness", () => { + const corpus = [ + "hello", + "", + "*em*", + "**b**", + "***t***", + "a_b_c", + "*a**b*", + "**a*a*a*", + "x \\| y", + "`code`", + "see [docs](https://example.com)", + "![alt](https://x.test/i.png)", + "", + "[bad](javascript:1)", + "a*b©*c", + "pre **a *b* c** post", + "~~x~~", + "==x==", + "~~*x*~~", + "a ~~b~~ ==c== d", + ]; + for (const raw of corpus) { + it(`partitions ${JSON.stringify(raw)} into ordered leaves that reconstruct the source`, () => { + const spans = leafSpans(parseCellInline(raw)); + // ordered + contiguous + covering + let cursor = 0; + let rebuilt = ""; + for (const s of spans) { + expect(s.from).toBe(cursor); + rebuilt += raw.slice(s.from, s.to); + cursor = s.to; + } + expect(cursor).toBe(raw.length); + expect(rebuilt).toBe(raw); + }); + } + + it("exposes link boundary spans for dimming", () => { + const ir = parseCellInline("[docs](https://x.test)"); + const link = ir[0]; + if (link.kind !== "leaf" || link.leaf.kind !== "link") { + throw new Error("expected link leaf"); + } + expect(link.leaf.safeUrl).toBe("https://x.test"); + expect("[docs](https://x.test)".slice(link.leaf.label.from, link.leaf.label.to)).toBe("docs"); + expect("[docs](https://x.test)".slice(link.leaf.dest.from, link.leaf.dest.to)).toBe( + "https://x.test" + ); + }); + + // Per-construct boundary spans must partition each leaf's OUTER span in source + // order — else PR2 dims the wrong characters while the outer-span partition + // test above still passes (Codex plan review Conf 98). + it("each leaf's boundary spans partition its outer span in order", () => { + const samples: Array<{ raw: string; kind: CellLeaf["kind"] }> = [ + { raw: "a\\|b", kind: "escape" }, + { raw: "`code`", kind: "code" }, + { raw: "see [docs](https://example.com)", kind: "link" }, + { raw: "![alt](https://x.test/i.png)", kind: "image" }, + { raw: "", kind: "autolink" }, + ]; + for (const { raw, kind } of samples) { + const leaves = walkLeaves(parseCellInline(raw)); + // Pin that the construct is emitted as the EXPECTED leaf kind (not folded + // into text) — else the boundary check below is vacuous when the leaf is + // absent (Codex re-review Conf 97). + const matching = leaves.filter((n) => n.leaf.kind === kind); + expect(matching).toHaveLength(1); + let cursor = matching[0].span.from; + for (const p of leafBoundarySpans(matching[0].leaf)) { + expect(p.to).toBeGreaterThanOrEqual(p.from); // reject reversed/overlapping spans (Conf 95) + expect(p.from).toBe(cursor); + cursor = p.to; + } + expect(cursor).toBe(matching[0].span.to); + } + }); + + it("pins text values and emphasis delimiter span/length/char invariants", () => { + const raw = "pre **a *b* c** post"; + for (const n of walkAll(parseCellInline(raw))) { + if (n.kind === "text") { + expect(raw.slice(n.span.from, n.span.to)).toBe(n.value); + } else if (n.kind === "emphasis") { + expect(n.span).toEqual({ from: n.openDelim.from, to: n.closeDelim.to }); + const want = n.tag === "strong" ? 2 : 1; + expect(n.openDelim.to - n.openDelim.from).toBe(want); + expect(n.closeDelim.to - n.closeDelim.from).toBe(want); + const oc = raw.slice(n.openDelim.from, n.openDelim.to); + const cc = raw.slice(n.closeDelim.from, n.closeDelim.to); + expect(new Set(oc).size).toBe(1); // a run of one delimiter char + expect(oc[0]).toBe(cc[0]); + } + } + }); +}); + +// Structure helpers for the boundary/invariant tests. +type LeafNode = Extract, { kind: "leaf" }>; +function walkLeaves(ir: Resolved[]): LeafNode[] { + const out: LeafNode[] = []; + for (const n of ir) { + if (n.kind === "leaf") { + out.push(n); + } else if (n.kind === "emphasis") { + out.push(...walkLeaves(n.children)); + } + } + return out; +} +function walkAll(ir: Resolved[]): Resolved[] { + const out: Resolved[] = []; + for (const n of ir) { + out.push(n); + if (n.kind === "emphasis") { + out.push(...walkAll(n.children)); + } + } + return out; +} +function leafBoundarySpans(leaf: CellLeaf): Span[] { + switch (leaf.kind) { + case "escape": + return [leaf.marker, leaf.char]; + case "code": + return [leaf.openFence, leaf.content, leaf.closeFence]; + case "link": + return [ + leaf.openBracket, + leaf.label, + leaf.closeBracket, + leaf.openParen, + leaf.dest, + leaf.closeParen, + ]; + case "image": + return [ + leaf.bang, + leaf.openBracket, + leaf.alt, + leaf.closeBracket, + leaf.openParen, + leaf.dest, + leaf.closeParen, + ]; + case "autolink": + return [leaf.openAngle, leaf.content, leaf.closeAngle]; + } +} diff --git a/test/webview/table/cm-table-cell-render-clicks.test.ts b/test/webview/table/cm-table-cell-render-clicks.test.ts new file mode 100644 index 00000000..de6be5be --- /dev/null +++ b/test/webview/table/cm-table-cell-render-clicks.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom +// What a click does to a link the cell already rendered. The subject is the +// guard attached at render time, not the URL that got past the gate, so a row +// here asserts `defaultPrevented` and never markup. +// The rule, in one line: nothing inside a table-cell widget navigates on its +// own. A plain click belongs to the widget's caret-dispatch path, and the ONE +// escape hatch is Cmd/Ctrl+left-click on an absolute href, which falls through +// to the widget root handler and the host's open-external gate. Every other +// gesture — modifier-click on a relative or fragment href, `auxclick` from ANY +// non-primary button — is preventDefault'd, because each is a way to open a URL +// that would skip that gate. `contextmenu` is deliberately NOT suppressed +// (keyboard-invoked menus, a11y); that is safe only because an href the host +// would reject never became a live anchor at all, which is the cap pinned in +// cm-table-cell-render-urls.test.ts. +// The two tooltip rows are the directory's only assertions on `a.title` — the +// reason helpers/cell-render-fixtures.ts can strip it everywhere else. +import { describe, expect, it } from "vitest"; + +import { renderCellInline } from "../../../src/webview/cm/table/cell-render.js"; + +describe("renderCellInline — click routing on rendered links", () => { + // C6b smoke #5 — plain click on a widget-internal link must NOT navigate to + // the browser (that bypasses caret-reveal and locks the user out of editing + // the link source). Modifier-click is the documented escape hatch matching + // VS Code Markdown preview / Go-to-Definition convention. + it("inline-link plain click is preventDefault'd (so the widget's caret-dispatch path takes over)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it("inline-link Cmd/Ctrl-click falls through to default navigation (falls through to the widget root handler, which routes through the host open-external gate)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + }); + + // `isAllowedUrl` returns true for any schemeless string + // (relative paths / fragments fall through to the "safe" branch), so + // `./doc.md` and `#section` ship as live . Browser behaviour + // for modifier-click on a relative href inside the VS Code webview + // iframe is undefined. Pin modifier-click to preventDefault for + // non-absolute hrefs so the user lands on the widget's caret-dispatch + // path instead. + it("relative-URL modifier-click is preventDefault'd (no undefined webview navigation)", () => { + const [a] = renderCellInline("[local](./doc.md)") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + }); + + it("fragment-URL modifier-click is preventDefault'd", () => { + const [a] = renderCellInline("[section](#intro)") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + }); + + // Pin the positive case so the absolute-scheme allowlist doesn't tighten + // too far in a future refactor — mailto: must keep the external escape + // hatch alongside https / http. Iterate both modifiers so a regression + // that tightens the guard to `metaKey only` (or `ctrlKey only`) trips. + it("mailto: modifier-click falls through to default navigation (falls through to the widget root handler, which routes through the host open-external gate)", () => { + const [a] = renderCellInline("[mail](mailto:a@b.test)") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + }); + + // Button-1 (middle-click) rides `auxclick` + the browser's native "open in + // new tab" default — it does NOT fire `click` (per the UI Events spec, `click` + // is primary-button-only), so the click-only guard never runs and the open + // would skip the host `open-external` re-validation + MAX_HREF_LENGTH cap. + // Middle-click-to-open is not a supported gesture (the vetted escape hatch is + // Cmd/Ctrl+left-click), so every `auxclick` — even on an otherwise-openable + // absolute href — must preventDefault. + it("absolute-href middle-click (auxclick) is preventDefault'd (closes the open-external choke-point bypass)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + const event = new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it("middle-click (auxclick) with a modifier is also preventDefault'd (aux buttons have no escape hatch)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("auxclick", { + bubbles: true, + cancelable: true, + button: 1, + ...modifier, + }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + }); + + // The guard is intentionally button-agnostic — `auxclick` fires for any + // non-primary button (back/forward too), so a future narrowing to + // `event.button === 1` would silently reopen it for those. Pin a non-middle + // aux button (4 = forward) so such a narrowing trips. + it("non-middle auxclick (side button) is also preventDefault'd (button-agnostic guard)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + const event = new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 4 }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it("autolink middle-click (auxclick) is preventDefault'd (same gate as inline links)", () => { + const [a] = renderCellInline("") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + const event = new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it("autolink plain click is preventDefault'd (same gate as inline links)", () => { + const [a] = renderCellInline("") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it("does not attach a contextmenu handler on a live link (keyboard-invoked menu / Shift+F10 still works)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + + it("does not attach a contextmenu handler on a live autolink", () => { + const [a] = renderCellInline("") as HTMLAnchorElement[]; + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + + // Autolink positive case — parallel to the inline-link Cmd/Ctrl test above. + // Pins the autolink branch directly so a refactor that drops `attachLinkClickGuard` + // from the autolink path trips here. + it("autolink Cmd/Ctrl-click falls through to default navigation (absolute scheme — external open)", () => { + const [a] = renderCellInline("") as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + }); + + it("emits a discoverability tooltip on links (mentions the modifier key)", () => { + const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; + expect(a.title).toMatch(/(Cmd|Ctrl)\+click to open/); + }); + + // Parallel pin for autolinks — the existing snapshot tests strip + // `title="…"` before comparing (platform-dependent), so a regression + // that forgot to attach the tooltip to autolinks would slip through. + it("emits a discoverability tooltip on autolinks (mentions the modifier key)", () => { + const [a] = renderCellInline("") as HTMLAnchorElement[]; + expect(a.title).toMatch(/(Cmd|Ctrl)\+click to open/); + }); +}); diff --git a/test/webview/table/cm-table-cell-render-emphasis.test.ts b/test/webview/table/cm-table-cell-render-emphasis.test.ts new file mode 100644 index 00000000..0fbce2bd --- /dev/null +++ b/test/webview/table/cm-table-cell-render-emphasis.test.ts @@ -0,0 +1,323 @@ +// @vitest-environment happy-dom +// The shared delimiter stack. `*`/`_` (emphasis, strong), `~~` (strikethrough) +// and `==` (highlight) are not four features but one: all four are emitted as +// delimiter runs into the SAME stack (inline-emphasis.ts) and paired by a single +// `resolveInline` pass, which is why they nest and interleave with each other, +// and why the interleaving rows here (`*a~~b*c~~d*`, `*a==b*c==d*`, +// `~~a ==b== c~~`) belong with the emphasis rows rather than in a marks suite of +// their own — splitting the two would leave those rows with no home that owns +// both sides. +// The last row is where the stack STOPS: past MAX_INLINE_NESTING_DEPTH the +// walker quits recursing and renders literal source. It sat inside the text-node +// topology describe before this split, which is not what it asserts. +// The oracle for these expectations is @lezer/markdown, the parser the editor +// itself runs — EXCEPT the astral-plane flanking rows, which say in place why +// they use the CommonMark spec and markdown-it instead. +// Fixtures: helpers/cell-render-fixtures.ts. +import { describe, expect, it } from "vitest"; + +import { renderCellInline } from "../../../src/webview/cm/table/cell-render.js"; +import { html, htmlWithoutTooltip } from "./helpers/cell-render-fixtures.js"; + +describe("renderCellInline — the shared delimiter stack (emphasis, strong, strikethrough, highlight)", () => { + // Basic paired emphasis renders live. (Full CommonMark §6.4 — nesting, + // `_underscore_`, and delimiter-run flanking — is pinned by the dedicated + // cases further down.) The C4a orchestrator's reveal spans are still dropped + // because the table's range is in the exclusion facet. + it("renders `**bold**` as a live ", () => { + expect(html(renderCellInline("**bold**"))).toBe("bold"); + }); + + it("renders `*em*` as a live ", () => { + expect(html(renderCellInline("*em*"))).toBe("em"); + }); + + // The inner walk runs with emphasis disabled, but link / image / autolink / + // code parsing — and therefore the URL-safety gate — still apply. An + // unsafe URL inside emphasis MUST still be rendered inert (no live ``). + it("routes an unsafe URL inside emphasis through renderSafeUrl (`**[bad](javascript:1)**`)", () => { + expect(html(renderCellInline("**[bad](javascript:1)**"))).toBe( + "[bad](javascript:1)" + ); + }); + + it("leaves unpaired emphasis delimiters as literal text", () => { + expect(html(renderCellInline("**unclosed"))).toBe("**unclosed"); + expect(html(renderCellInline("*also unclosed"))).toBe("*also unclosed"); + }); + + // Full delimiter stack: a `**` opener with only a single `*` closer consumes + // one delimiter from each, leaving one literal `*` before a live . + // Verified via @lezer/markdown. + it("renders `**a*` as `*a` (leftover opener delimiter)", () => { + expect(html(renderCellInline("**a*"))).toBe("*a"); + }); + + // Task #2: positive pin that a safe link inside emphasis renders correctly. + // Strip the platform-specific `title` (Cmd vs Ctrl) so the assertion stays + // environment-agnostic — the title contract is pinned in its own test. + it("renders a safe link inside emphasis (`*[ok](https://x.test)*`)", () => { + expect(htmlWithoutTooltip(renderCellInline("*[ok](https://x.test)*"))).toBe( + 'ok' + ); + }); + + // Task #3: empty-emphasis boundary — `****` must not produce an empty + // `` (the `close > i + 2` guard rejects a close that is + // immediately adjacent to the opener, e.g. `****` where close == i + 2). + it("renders `****` as literal text (empty strong prevented by close > i + 2 guard)", () => { + expect(html(renderCellInline("****"))).toBe("****"); + }); + + it("renders bare `**` as literal text (no close)", () => { + expect(html(renderCellInline("**"))).toBe("**"); + }); + + // Task #5: CommonMark §6.2 flanking rule — whitespace immediately after + // the opener or before the closer disqualifies the delimiter run. + it("renders `* em *` as literal text (opener-after-whitespace, CommonMark flanking rule)", () => { + expect(html(renderCellInline("* em *"))).toBe("* em *"); + }); + + it("renders `**bold **` as literal text (closer-before-whitespace, CommonMark flanking rule)", () => { + expect(html(renderCellInline("**bold **"))).toBe("**bold **"); + }); + + // Task #6: CommonMark §6.1 backslash escape for `*` suppresses emphasis. + it("renders `\\*not em\\*` as literal `*not em*` (backslash escape suppresses em)", () => { + expect(html(renderCellInline("\\*not em\\*"))).toBe("*not em*"); + }); + + // Full CommonMark §6.1/§6.4: `\*` escapes the first `*` of each pair, leaving + // the second `*` as a live delimiter. The trailing `\*` is an escaped literal + // `*` INSIDE the span; the final bare `*` closes it. Verified via @lezer/markdown. + it("renders `\\**not strong\\**` as `*not strong*` (CommonMark escape + flanking)", () => { + expect(html(renderCellInline("\\**not strong\\**"))).toBe("*not strong*"); + }); + + // CommonMark §6.1 backslash parity: `\\` is itself an escape sequence + // (literal `\`), so `\\*em*` MUST parse as literal `\` followed by a live + // `em`. Without the `\\` guard, the second `\` would mis-fire as + // the start of `\*` and silently suppress the emphasis. + it("renders `\\\\*em*` as literal `\\` plus live (backslash parity)", () => { + expect(html(renderCellInline("\\\\*em*"))).toBe("\\em"); + }); + + it("renders `\\\\**bold**` as literal `\\` plus live ", () => { + expect(html(renderCellInline("\\\\**bold**"))).toBe("\\bold"); + }); + + // Full delimiter stack now nests: outer `**` strong contains an inner `*` em. + // Verified via @lezer/markdown. + it("nests inner emphasis inside outer emphasis (`**a *b* c**`)", () => { + expect(html(renderCellInline("**a *b* c**"))).toBe("a b c"); + }); + + // --- C6c: full CommonMark §6.4 delimiter-stack cases (all verified via + // @lezer/markdown). --- + + it("keeps the inner `**` literal in `*a**b*` (rule of 3)", () => { + expect(html(renderCellInline("*a**b*"))).toBe("a**b"); + }); + + it("renders `**a ** b**` as `a ** b` (whitespace-flanked inner `**` is literal)", () => { + expect(html(renderCellInline("**a ** b**"))).toBe("a ** b"); + }); + + it("splits `***text***` into nested ``", () => { + expect(html(renderCellInline("***text***"))).toBe("text"); + }); + + it("renders `_x_` as live (underscore emphasis)", () => { + expect(html(renderCellInline("_x_"))).toBe("x"); + }); + + it("renders `__b__` as live (underscore strong)", () => { + expect(html(renderCellInline("__b__"))).toBe("b"); + }); + + // Strikethrough (`~~…~~`) + highlight (`==…==`) parity: these render formatted + // everywhere else in the editor, but the table-cell widget used to leak the raw + // delimiters (`| ~~x~~ |` showed the tildes). They are emitted as delimiter runs + // into the SAME stack as `*`/`_` (inline-emphasis.ts), so resolveInline pairs + // them into / wraps that interleave with emphasis exactly as the + // editor's @lezer/markdown parser does. + it("renders `~~x~~` as a live (strikethrough)", () => { + expect(html(renderCellInline("~~x~~"))).toBe("x"); + }); + + it("renders `==x==` as a live (highlight)", () => { + expect(html(renderCellInline("==x=="))).toBe("x"); + }); + + it("nests emphasis inside a mark (`~~*x*~~`, `==**b**==`)", () => { + expect(html(renderCellInline("~~*x*~~"))).toBe("x"); + expect(html(renderCellInline("==**b**=="))).toBe("b"); + }); + + it("renders a mark amid surrounding text (`a ~~b~~ ==c== d`)", () => { + expect(html(renderCellInline("a ~~b~~ ==c== d"))).toBe("a b c d"); + }); + + // Flanking parity with the source parsers: a leading space after the opener + // means it cannot open, so the run stays literal (the editor would not strike + // it either). The `a == b` case is the common false-trigger — an `==` flanked + // by spaces neither opens nor closes. + it("leaves a non-flanking mark literal (`~~ x~~`, `a == b`)", () => { + expect(html(renderCellInline("~~ x~~"))).toBe("~~ x~~"); + expect(html(renderCellInline("a == b"))).toBe("a == b"); + }); + + // An unmatched opener (no closing pair) stays literal — the delimiter run + // survives as its literal characters, merged with adjacent text. + it("leaves an unmatched mark opener literal (`~~x`, `a==b`)", () => { + expect(html(renderCellInline("~~x"))).toBe("~~x"); + expect(html(renderCellInline("a==b"))).toBe("a==b"); + }); + + // A `~~`/`==` inside inline code is inert (code binds tighter, content literal). + it("does not mark inside an inline code span (`` `~~x~~` ``)", () => { + expect(html(renderCellInline("`~~x~~`"))).toBe("~~x~~"); + }); + + // Shared-delimiter-stack interleaving parity. A greedy nearest-closer scan + // would mis-pair these; the delimiter stack reproduces @lezer/markdown exactly. + // `*a~~b*c~~d*`: emphasis wins, the crossing `~~` are left inert. Verified + // against @lezer/markdown + GFM directly (code-quality review). + it("interleaves a mark with emphasis like the editor (`*a~~b*c~~d*`)", () => { + expect(html(renderCellInline("*a~~b*c~~d*"))).toBe("a~~bc~~d*"); + }); + + // Nested same-type marks: outer wraps the inner via the delimiter stack, no + // literal tail left behind (the greedy scan closed the outer at the inner). + it("nests same-type marks (`~~a ~~b~~ c~~`)", () => { + expect(html(renderCellInline("~~a ~~b~~ c~~"))).toBe("a b c"); + }); + + // The SAME interleave/nesting behaviour on the `==` side (highlight is the + // newer, less battle-tested delimiter — pin it independently so a future edit + // that broke only the `=` slot / `mark` tag branch cannot pass on `~~` alone). + it("interleaves `==` with emphasis like the editor (`*a==b*c==d*`)", () => { + expect(html(renderCellInline("*a==b*c==d*"))).toBe("a==bc==d*"); + }); + + it("nests same-type highlight marks (`==a ==b== c==`)", () => { + expect(html(renderCellInline("==a ==b== c=="))).toBe("a b c"); + }); + + // Cross-type nesting: a highlight inside a strikethrough, resolved in the one + // shared stack (both are length-2 delimiters that only differ by tag). + it("nests a highlight inside a strikethrough (`~~a ==b== c~~`)", () => { + expect(html(renderCellInline("~~a ==b== c~~"))).toBe("a b c"); + }); + + // Triple run: Lezer rescans from pos+1, so `===x===` opens at [1,3) and closes + // at [5,7), wrapping content `x=` (Highlight span [1,7) — the measured span + // highlight-mark.ts documents). Only the leading `=` (index 0) stays literal. + it("handles a triple-delimiter run like the editor (`===x===`)", () => { + expect(html(renderCellInline("===x==="))).toBe("=x="); + }); + + // The mark wrap does NOT bypass the URL render-gate: an unsafe link nested + // inside `~~…~~` still renders inert (mirrors the emphasis arm's + // `**[bad](javascript:1)**` case). The link leaf carries the safeUrl=null + // verdict; the surrounding del/mark is just a wrapper. + it("keeps the URL gate for an unsafe link inside a mark (`~~[bad](javascript:1)~~`)", () => { + expect(html(renderCellInline("~~[bad](javascript:1)~~"))).toBe( + "[bad](javascript:1)" + ); + }); + + it("keeps a safe link live inside a mark (`==[ok](https://x.test)==`)", () => { + expect(htmlWithoutTooltip(renderCellInline("==[ok](https://x.test)=="))).toBe( + 'ok' + ); + }); + + // Image alt (commonMarkAltText → flattenInlineText) flattens a mark to its + // text content, same as emphasis — used for `` in both the table-cell + // renderer and the shared block-image widget. + it("flattens a mark in an image alt (`![a ~~b~~ c](url)` -> alt=`a b c`)", () => { + const nodes = renderCellInline("![a ~~b~~ c](https://x.test/i.png)"); + expect((nodes[0] as HTMLImageElement).alt).toBe("a b c"); + }); + + it("leaves intraword underscores literal (`a_b_c`, `foo_bar_baz`)", () => { + expect(html(renderCellInline("a_b_c"))).toBe("a_b_c"); + expect(html(renderCellInline("foo_bar_baz"))).toBe("foo_bar_baz"); + }); + + it("renders an escaped delimiter inside emphasis literally (`*a\\*b*`)", () => { + expect(html(renderCellInline("*a\\*b*"))).toBe("a*b"); + }); + + // 6-state openers_bottom regression: a closer that can also open must not + // poison a later close-only closer's opener bound. A 3-state bound yields + // `**aaa*`. Verified via @lezer/markdown. + it("nests `**a*a*a*` as `*aaa` (6-state openers_bottom)", () => { + expect(html(renderCellInline("**a*a*a*"))).toBe("*aaa"); + }); + + // Unicode flanking: `©` is a Symbol (Unicode S), which CommonMark counts as a + // punctuation character. With `©` before the second `*`, that run is not + // right-flanking, so it cannot close → the whole thing stays literal. (A + // `\p{P}`-only classifier would wrongly emit `ac`.) Verified via + // @lezer/markdown. + it("treats a Unicode symbol as punctuation for flanking (`a*b©*c` stays literal)", () => { + expect(html(renderCellInline("a*b©*c"))).toBe("a*b©*c"); + }); + + // Deferred (C6c-proper, not a regression): emphasis inside a link label is + // NOT parsed — the label renders as plain text. Links bind tighter than + // emphasis and the tokenizer resolves them atomically. Pins the boundary so + // the deferral is intentional. CommonMark would emit `a b c`. + it("does NOT parse emphasis inside a link label (deferred to C6c-proper)", () => { + expect(htmlWithoutTooltip(renderCellInline("[a *b* c](https://x.test)"))).toBe( + 'a *b* c' + ); + }); + + // Astral-plane flanking — exercises the `charBefore` / `charAfter` whole-code- + // point path that the BMP `a*b©*c` case does not. Classification is by Unicode + // code point category: 💲 (U+1F4B2) is a Symbol (S) → punctuation; 𐀀 (U+10000) + // is a Letter (Lo) → not punctuation. Expectations follow the CommonMark spec + // and the reference markdown-it brute-force (Codex review). NOTE: @lezer/markdown + // is NOT the oracle for these — it classifies astral chars on UTF-16 units and + // gets them wrong (`a*b💲*c` → `ab💲c`), which is exactly the lone- + // surrogate hazard that `charBefore`'s pair handling avoids. + it("treats an astral symbol as punctuation before a closer (`a*b💲*c` literal)", () => { + expect(html(renderCellInline("a*b💲*c"))).toBe("a*b💲*c"); + }); + + it("treats an astral symbol as punctuation after an opener (`a*💲b*c` literal)", () => { + expect(html(renderCellInline("a*💲b*c"))).toBe("a*💲b*c"); + }); + + it("treats an astral letter as non-punctuation (`a*𐀀b*c` → em)", () => { + expect(html(renderCellInline("a*𐀀b*c"))).toBe("a𐀀bc"); + }); + + it("renders a pathologically deep-emphasis cell without crashing", () => { + // ~N/2-deep emphasis nesting — the seed-time stack-overflow vector. The + // walker must fall back to inert literal source past the nesting cap + // instead of overflowing while walking the (bounded-build) tree. + const N = 40000; + const deep = `${"*".repeat(N)}a${"*".repeat(N)}`; + let nodes: Node[] = []; + expect(() => { + nodes = renderCellInline(deep); + }).not.toThrow(); + const text = nodes.map((n) => n.textContent ?? "").join(""); + // Content survives (the literal `a` is preserved past the cap)... + expect(text).toContain("a"); + // ...and the inert-source fallback actually fired: literal `*` delimiters + // leak into the text (a vacuous always-empty render would not contain them). + expect(text).toContain("*"); + // Non-vacuity vs the defense-in-depth try/catch: the WALKER cap emits only + // the emphasis span at depth 100 (the outer ~2×cap delimiters are unwrapped + // first), so the text is strictly shorter than the raw input. The try/catch + // fallback would instead return the FULL raw string as one text node — this + // pins that the cap path ran, not that an overflow was silently caught. + expect(text.length).toBeLessThan(deep.length); + }); +}); diff --git a/test/webview/table/cm-table-cell-render-map.test.ts b/test/webview/table/cm-table-cell-render-map.test.ts new file mode 100644 index 00000000..a7fa2628 --- /dev/null +++ b/test/webview/table/cm-table-cell-render-map.test.ts @@ -0,0 +1,318 @@ +// @vitest-environment happy-dom +// The source map `renderCellInto` PUBLISHES — one of three files on this map and +// the only one pinning what the renderer puts INTO it. (How the map is read back +// is cm-table-cell-source-map.test.ts, which pins `sourceOffsetAt`; what happens +// when a map cannot be trusted is cm-table-cell-map-failclosed.test.ts.) +// Three depths, in order. First: does the map tile its own render — structural +// invariants that hold for ANY input, so a new construct whose walker arm forgets +// its run fails them without anyone remembering to add a case. Second: WHICH +// source each run claims, which those invariants cannot see — every case there +// would satisfy them while mapping a drag onto the wrong bytes. Third: +// registration, including the identity map a throwing renderer must leave behind +// so a reused cell never describes what it rendered last. +import { describe, expect, it, vi } from "vitest"; + +import { MAX_INLINE_NESTING_DEPTH } from "../../../src/webview/cm/inline/inline-ir.js"; +import { renderCellInto } from "../../../src/webview/cm/table/cell-render.js"; +import type { + CellSourceMap, + CellSourceRun, +} from "../../../src/webview/cm/table/cell-source-map.js"; +import { getCellSourceMap } from "../../../src/webview/cm/table/cell-source-map.js"; + +// The source map cell-point.ts consumes: every rendered character run paired +// with the source characters it came from, plus the markup its construct owns. +// This block is the AUTOMATIC tripwire that replaced the old +// "renderCellInline never grows the rendered text" describe. +// +// That describe existed because cell-point.ts decided "offsets map 1:1" from +// LENGTH EQUALITY alone, which was only sound while no construct rendered +// longer than its source — an unwritten contract guarded by a hand-maintained +// CASES list, so a construct with no sample could break it silently. The +// invariants below are structural: they hold for ANY input, so a new construct +// whose walker arm forgets its run (or claims the wrong source) fails them +// without anyone remembering to add a case. The corpus is kept only to give +// the tripwire a broad supply of shapes. +describe("cell source map invariants", () => { + /** Render through the production entry point — `renderCellInto` is what + * registers the map, and going through it is what keeps these invariants + * pinned to the path the widget actually takes. */ + function mapFor(raw: string, resourceBase = ""): { cell: HTMLElement; map: CellSourceMap } { + const cell = document.createElement("td"); + renderCellInto(cell, raw, resourceBase); + const map = getCellSourceMap(cell); + expect(map, `no map registered for ${JSON.stringify(raw)}`).not.toBeNull(); + return { cell, map: map as CellSourceMap }; + } + + function checkInvariants(raw: string, cell: HTMLElement, map: CellSourceMap): void { + const where = JSON.stringify(raw); + // The two halves of cell-point.ts's staleness check must be satisfiable at + // all: a map whose sourceLength or renderedText disagreed with what it was + // built from would make EVERY drag on that cell fall back. + expect(map.sourceLength, where).toBe(raw.length); + expect(map.renderedText, where).toBe(cell.textContent ?? ""); + let rendered = 0; + let prevTo = 0; + let prevOuterTo = 0; + for (const run of map.runs) { + // The run's source really is what rendered, character for character — + // the claim the whole mapping rests on. `rendered` is the running sum of + // the preceding runs' lengths, which is the map's ONLY notion of a run's + // rendered position and is how `sourceOffsetAt` derives it too (why no + // run stores it: the `CellSourceRun` doc in cell-source-map.ts). + const runLength = run.to - run.from; + expect(runLength, where).toBeGreaterThan(0); + expect(raw.slice(run.from, run.to), where).toBe( + map.renderedText.slice(rendered, rendered + runLength) + ); + // Source order, non-overlapping: two runs claiming the same byte would + // make the mapping ambiguous in the other direction. + expect(run.from, where).toBeGreaterThanOrEqual(prevTo); + // The outer span contains the run and stays inside the cell... + expect(run.outerFrom, where).toBeLessThanOrEqual(run.from); + expect(run.outerTo, where).toBeGreaterThanOrEqual(run.to); + expect(run.outerFrom, where).toBeGreaterThanOrEqual(prevOuterTo); + expect(run.outerTo, where).toBeLessThanOrEqual(raw.length); + rendered += runLength; + prevTo = run.to; + prevOuterTo = run.outerTo; + } + // ...and together the runs tile the whole rendered text, which is what lets + // `sourceOffsetAt` treat "not found" as unreachable. + expect(rendered, where).toBe(map.renderedText.length); + } + + const CASES = [ + "plain text", + "**bold**", + "_em_", + "~~del~~", + "==mark==", + "`code`", + "[label](https://example.com)", + "[label](./relative.md)", + "[label](javascript:alert(1))", + "", + "![alt](https://example.com/x.png)", + "![alt](./local.png)", + "\\| escaped pipe", + "\\*not em\\*", + "& entity", + "<>", + "😀", + "a © b", + "***nested bold em***", + "**[link](https://example.com)**", + "text with double spaces", + "trailing backslash \\", + "\u{1f600} emoji", + "raw html", + "", + "http://bare.example.com", + ]; + for (const src of CASES) { + it(`maps ${JSON.stringify(src)}`, () => { + const { cell, map } = mapFor(src, "https://base.example/dir/"); + checkInvariants(src, cell, map); + }); + } + + // The single-construct cases above say nothing about COMPOSITION: a walker + // arm that leaks a pending opener or a stale skip flag only misbehaves when + // one construct follows another. Fixed seed so a failure is reproducible + // from the printed source string alone. + it("holds for random compositions of every construct", () => { + const ATOMS = [ + "a", + " ", + "**b**", + "_i_", + "~~d~~", + "==m==", + "`c`", + "\\|", + "\\*", + "&", + "😀", + "[l](https://e.test)", + "![a](./i.png)", + "", + "x", + "\u{1f600}", + ]; + // High bits only: this LCG's LOW bits have a very short period, so + // `seed % n` degenerates (measured: every draw hit the first few atoms, max + // composition length 8, links and images NEVER generated). `>>> 16` gives a + // corpus that actually uses all 16 atoms. + let seed = 1; + const next = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return (seed >>> 16) % 32768; + }; + for (let i = 0; i < 500; i++) { + let src = ""; + for (let n = next() % 6; n > 0; n--) { + src += ATOMS[next() % ATOMS.length]; + } + const { cell, map } = mapFor(src, "https://base.example/dir/"); + checkInvariants(src, cell, map); + } + }); +}); + +// Which SOURCE a (structurally valid) run claims is invisible to the invariants +// above — every case here would satisfy them while mapping a drag onto the +// wrong bytes. One named case per walker rule. +// +// ⚠ The fixture shapes are verified against the real tokenizer, not guessed. +// A CommonMark closer preceded by PUNCTUATION is right-flanking only when it is +// also followed by whitespace, punctuation or end-of-input — so `**a![i](p)**b` +// (closer preceded by `)`, followed by `b`) forms NO emphasis at all and never +// reaches the wrapper arm. (`**a**b` DOES form emphasis: its closer is preceded +// by an alphanumeric, so what follows is irrelevant.) And every live-image +// fixture uses an ABSOLUTE `https:` src because `resolveAgainstBase` returns +// null for a relative src with an empty base, which renders the image INERT — +// a run-emitting path that would silently test the wrong arm. Do not +// paraphrase them. +describe("cell source map — walker rules", () => { + const IMG = "![i](https://x.test/a.png)"; + + function runsOf(raw: string, resourceBase = ""): readonly CellSourceRun[] { + const cell = document.createElement("td"); + renderCellInto(cell, raw, resourceBase); + return (getCellSourceMap(cell) as CellSourceMap).runs; + } + + // Rule 1 (set-if-empty): an already-pending OUTER opener wins. Overwriting + // would orphan the outer delimiters, and a selection of `x` would no longer + // round-trip to the same rendered content. + it.each([ + ["***x***"], + ["**_b_**"], + ])("attributes BOTH delimiter pairs of %s to its single run", (src) => { + expect(runsOf(src)).toEqual([{ from: 3, to: 4, outerFrom: 0, outerTo: 7 }]); + }); + + // Rule 4: a wrapper whose text is followed by an invisible construct must NOT + // extend its closers over it — that would swallow the image into the left + // run and make a boundary straddling it look exact. + it("does not extend outerTo over trailing skipped source", () => { + expect(runsOf(`**a${IMG}**`)).toEqual([{ from: 2, to: 3, outerFrom: 0, outerTo: 3 }]); + }); + + // Rule 5: a wrapper that rendered nothing must not lend its delimiters to a + // later, unrelated run — the " a" run keeps its OWN outerFrom. + it("does not attribute an empty wrapper's delimiters to the next run", () => { + const src = `*${IMG}* a`; + expect(runsOf(src)).toEqual([ + { + from: src.length - 2, + to: src.length, + outerFrom: src.length - 2, + outerTo: src.length, + }, + ]); + }); + + // Zero-length guard: a live link with an empty label renders ``. Two + // runs at the same rendered index would make the boundary lookup ambiguous, + // so it emits none — and records the skip, which the following run's own + // outerFrom is what proves. + it("emits no run for a zero-length live link, and marks the skip", () => { + const empty = "[](https://example.com)"; + expect(runsOf(empty)).toEqual([]); + expect(runsOf(`${empty}a`)).toEqual([ + { + from: empty.length, + to: empty.length + 1, + outerFrom: empty.length, + outerTo: empty.length + 1, + }, + ]); + }); + + it("emits a whole-span run for an inert link (its source renders verbatim)", () => { + const inert = "[bad](javascript:1)"; + expect(runsOf(inert)).toEqual([ + { from: 0, to: inert.length, outerFrom: 0, outerTo: inert.length }, + ]); + }); + + // Past MAX_INLINE_NESTING_DEPTH the walker stops recursing and renders the + // literal source, so the run it emits is the whole span — same arm as an + // inert construct. Without its own emitRun the deep span would render text + // no run described, and every boundary after it would be off. + it("emits a whole-span run for the past-depth-cap emphasis literal", () => { + // Two delimiters per nesting level (each `**` pair is one ), so + // 2 × (cap + 1) is what actually reaches depth 100 — a bare cap + 1 stops + // at ~51 levels and never fires the arm. The literal that survives is the + // innermost `**x**`, rendered VERBATIM, which is why it needs a run of its + // own: without one it would emit text no run described and every later + // boundary would be off by its length. + const pad = "*".repeat(2 * (MAX_INLINE_NESTING_DEPTH + 1)); + const src = `${pad}x${pad}`; + const literal = "**x**"; + const from = pad.length - 2; + expect(runsOf(src)).toEqual([ + { + from, + to: from + literal.length, + // The outer wrappers each rendered text, so their closers accumulate + // outward all the way to the end of the cell. + outerFrom: 0, + outerTo: src.length, + }, + ]); + expect(src.slice(from, from + literal.length)).toBe(literal); + }); +}); + +describe("renderCellInto", () => { + it("registers a map whose renderedText is the cell's own textContent", () => { + const cell = document.createElement("td"); + renderCellInto(cell, "**bold**"); + expect(cell.innerHTML).toBe("bold"); + expect(getCellSourceMap(cell)?.renderedText).toBe(cell.textContent); + }); + + it("replaces the previous map on re-render (a reused patchRow cell)", () => { + const cell = document.createElement("td"); + renderCellInto(cell, "**bold**"); + renderCellInto(cell, "hi"); + expect(cell.textContent).toBe("hi"); + expect(getCellSourceMap(cell)).toEqual({ + runs: [{ from: 0, to: 2, outerFrom: 0, outerTo: 2 }], + sourceLength: 2, + renderedText: "hi", + }); + }); + + // Defense in depth: an unforeseen throw falls back to inert source text, and + // the map must fall back WITH it. Registering nothing would leave a reused + // cell describing whatever it rendered last — a stale map that passes the + // length half of the staleness check whenever the two sources are the same + // length. + it("registers the identity map when the renderer throws", () => { + const cell = document.createElement("td"); + // The fallback logs BY DESIGN (cell-render.ts's catch), so silence it here + // rather than leave a maintainer wondering whether the line is a symptom. + // It is asserted where it is the subject: cm-table-cell-map-failclosed.ts. + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const spy = vi.spyOn(document, "createElement").mockImplementation(() => { + throw new Error("renderer exploded"); + }); + try { + renderCellInto(cell, "**bold**"); + } finally { + spy.mockRestore(); + errSpy.mockRestore(); + } + expect(cell.textContent).toBe("**bold**"); + expect(getCellSourceMap(cell)).toEqual({ + runs: [{ from: 0, to: 8, outerFrom: 0, outerTo: 8 }], + sourceLength: 8, + renderedText: "**bold**", + }); + }); +}); diff --git a/test/webview/table/cm-table-cell-render-text.test.ts b/test/webview/table/cm-table-cell-render-text.test.ts new file mode 100644 index 00000000..7bed27c1 --- /dev/null +++ b/test/webview/table/cm-table-cell-render-text.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +// What a cell renders when nothing special is going on: plain text, inline code +// spans, backslash escapes, HTML-escaping of raw `<`/`>`/`&`, and mixed content +// in source order. +// The second describe pins those same renders through the one property innerHTML +// CANNOT see — that adjacent text, escape, inert-source and leftover-delimiter +// runs collapse into ONE text node. `a\|b` appears in both: above as the markup +// `a|b`, below as a single text node. A renderer that emitted three nodes whose +// text happened to concatenate to `a|b` would satisfy the first and fail the +// second, which is why the two live together. +// Fixtures: helpers/cell-render-fixtures.ts. +import { describe, expect, it } from "vitest"; + +import { renderCellInline } from "../../../src/webview/cm/table/cell-render.js"; +import { html, htmlWithoutTooltip } from "./helpers/cell-render-fixtures.js"; + +describe("renderCellInline — text, code spans and escapes", () => { + it("renders plain text as a single text node", () => { + const nodes = renderCellInline("hello"); + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); + expect(nodes[0].textContent).toBe("hello"); + }); + + it("returns an empty array for an empty string", () => { + expect(renderCellInline("")).toEqual([]); + }); + + it("renders inline `code` as ", () => { + const nodes = renderCellInline("use `git diff`"); + expect(html(nodes)).toBe("use git diff"); + }); + + // CommonMark §6.1: a multi-backtick opener with no matching closing run + // renders literally. The C6b scope is single-backtick spans only; the + // pre-fix code greedily paired the first two backticks of `` `` `` and + // emitted an empty ``. Multi-backtick code spans + CommonMark + // code normalization are deferred out of C6c scope — multi-backtick runs + // fall through to literal text indefinitely until that scope lands. + it("renders a double-backtick `` `` `` sequence as literal text (no empty )", () => { + expect(html(renderCellInline("``"))).toBe("``"); + expect(html(renderCellInline("a `` b"))).toBe("a `` b"); + }); + + it("decodes escaped pipe `\\|` to a literal `|` in text", () => { + const nodes = renderCellInline("a\\|b"); + expect(html(nodes)).toBe("a|b"); + }); + + it("HTML-escapes raw `<` / `>` / `&` in plain text", () => { + const nodes = renderCellInline("a < b & c > d"); + expect(html(nodes)).toBe("a < b & c > d"); + }); + + it("renders mixed content in source order", () => { + const nodes = renderCellInline("pre [link](https://e.test) mid `code` end"); + expect(htmlWithoutTooltip(nodes)).toBe( + 'pre link mid code end' + ); + }); +}); + +// renderReadonly merging is the part innerHTML CANNOT see — adjacent text / +// escape / inert-source / leftover-delimiter runs must collapse to ONE text +// node. Pin it by node count, not innerHTML (Codex plan review Conf 92). +describe("renderReadonly text-node topology (merging is not vacuous)", () => { + it("merges an escape into surrounding text (`a\\|b` -> one text node `a|b`)", () => { + const nodes = renderCellInline("a\\|b"); + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); + expect(nodes[0].textContent).toBe("a|b"); + }); + it("merges an inert unsafe construct into surrounding text (one node)", () => { + const nodes = renderCellInline("x[bad](javascript:1)y"); + expect(nodes).toHaveLength(1); + expect(nodes[0].textContent).toBe("x[bad](javascript:1)y"); + }); + it("merges unmatched delimiters into text (`x**unclosed` -> one node)", () => { + const nodes = renderCellInline("x**unclosed"); + expect(nodes).toHaveLength(1); + expect(nodes[0].textContent).toBe("x**unclosed"); + }); + it("merges an escaped pipe inside emphasis into one text child of ", () => { + const nodes = renderCellInline("*a\\|b*"); + expect(nodes).toHaveLength(1); + expect((nodes[0] as Element).tagName).toBe("EM"); + expect(nodes[0].childNodes).toHaveLength(1); + expect(nodes[0].textContent).toBe("a|b"); + }); +}); diff --git a/test/webview/table/cm-table-cell-render-urls.test.ts b/test/webview/table/cm-table-cell-render-urls.test.ts new file mode 100644 index 00000000..a0b6049c --- /dev/null +++ b/test/webview/table/cm-table-cell-render-urls.test.ts @@ -0,0 +1,315 @@ +// @vitest-environment happy-dom +// The destination gate: which URLs the table-cell renderer turns into a live +// `` / `` / autolink, and what it renders instead when it +// refuses. Four arms of one contract — the scheme allowlist (with the entity and +// backslash bypasses that made it necessary), CommonMark destination parsing, +// the MAX_HREF_LENGTH render cap, and relative-image directory containment. +// Refusal always takes the same shape, the construct's own source rendered inert +// as text, which is why so many expectations here are the input string back. +// The cap lives here rather than with the click routing because it decides +// whether an anchor is created at all: an over-cap URL never becomes a live +// ``, so no native gesture (Open Link, middle-click, drag-to-address-bar) can +// reach a URL the host's open-external sink would reject. The two at-cap rows +// observe a click only as proof the anchor really did go live. +// What a link does once it IS live is cm-table-cell-render-clicks.test.ts; how +// the delimiters around it pair is cm-table-cell-render-emphasis.test.ts. +// Fixtures: helpers/cell-render-fixtures.ts. +import { describe, expect, it } from "vitest"; + +import { MAX_HREF_LENGTH } from "../../../src/shared/protocol.js"; +import { renderCellInline } from "../../../src/webview/cm/table/cell-render.js"; +import { html, htmlWithoutTooltip } from "./helpers/cell-render-fixtures.js"; + +describe("renderCellInline — the destination gate", () => { + it("renders an inline link [text](url) as when URL is allowed", () => { + const nodes = renderCellInline("see [docs](https://example.com)"); + // Strip `title="…"` before comparing — the discoverability tooltip + // resolves "Cmd" vs "Ctrl" at module load via `navigator.platform`, + // so pinning it inline makes the snapshot platform-dependent. The + // dedicated tooltip test (clicks suite) uses an environment-safe regex; the + // structural snapshot should be platform-agnostic. + expect(htmlWithoutTooltip(nodes)).toBe( + 'see docs' + ); + }); + + it("renders an unsafe inline link as inert text", () => { + const nodes = renderCellInline("[bad](javascript:alert(1))"); + expect(html(nodes)).toBe("[bad](javascript:alert(1))"); + }); + + // CommonMark §2.4: only ASCII punctuation is backslash-escapable. In + // `[a](x\ y)` the `\ ` is a literal backslash, so the unescaped space + // terminates the bare destination and this is NOT a link — matching the + // Lezer write-gate parse. The escaped-punctuation case (`\)`) must still + // suppress the paren so a genuine escape keeps the link live. + it("renders `[a](x\\ y)` as literal text (`\\ ` is not an escape)", () => { + expect(html(renderCellInline("[a](x\\ y)"))).toBe("[a](x\\ y)"); + }); + + it("keeps `[a](x\\)y)` a live link (punctuation escape suppresses the paren)", () => { + expect(htmlWithoutTooltip(renderCellInline("[a](x\\)y)"))).toBe( + 'a' + ); + }); + + // CommonMark backslash + HTML-entity bypass. Without decoding the + // destination before the allowlist gate, `javascript:…` + // and `javascript\:…` look schemeless to the regex in `isAllowedUrl`, + // get classified as "relative", and ship as a live `` that the + // browser then resolves to `javascript:…` → XSS. + it("blocks `[bad](javascript:alert(1))` (HTML-entity scheme bypass)", () => { + const nodes = renderCellInline("[bad](javascript:alert(1))"); + expect(html(nodes)).toBe("[bad](javascript&#58;alert(1))"); + }); + + it("blocks `[bad](javascript\\:alert(1))` (backslash-escape scheme bypass)", () => { + const nodes = renderCellInline("[bad](javascript\\:alert(1))"); + expect(html(nodes)).toBe("[bad](javascript\\:alert(1))"); + }); + + it("blocks `[bad](javascript:alert(1))` (named-entity scheme bypass)", () => { + const nodes = renderCellInline("[bad](javascript:alert(1))"); + expect(html(nodes)).toBe("[bad](javascript&colon;alert(1))"); + }); + + it("renders an inline image ![alt](url) as with alt", () => { + const nodes = renderCellInline("![logo](https://x.test/a.png)"); + expect(html(nodes)).toBe('logo'); + }); + + it("CommonMark-normalizes an image alt (![*em*](url) -> alt=em)", () => { + const nodes = renderCellInline("![*em*](https://x.test/a.png)"); + expect(html(nodes)).toBe('em'); + }); + + it("decodes an entity in an image alt (![a&b](url) -> alt=a&b)", () => { + const nodes = renderCellInline("![a&b](https://x.test/a.png)"); + // innerHTML re-encodes & in the attribute, so assert via the DOM node. + expect((nodes[0] as HTMLImageElement).alt).toBe("a&b"); + }); + + it("renders an unsafe inline image as inert text", () => { + const nodes = renderCellInline("![x](javascript:1)"); + expect(html(nodes)).toBe("![x](javascript:1)"); + }); + + it("blocks `![x](javascript:1)` image (HTML-entity scheme bypass)", () => { + const nodes = renderCellInline("![x](javascript:1)"); + expect(html(nodes)).toBe("![x](javascript&#58;1)"); + }); + + it("does not cap image src at MAX_HREF_LENGTH (images are exempt — no open-external round-trip)", () => { + const longUrl = `https://x.test/${"a".repeat(9000)}.png`; // > MAX_HREF_LENGTH + const nodes = renderCellInline(`![x](${longUrl})`); + const [img] = nodes as HTMLImageElement[]; + expect(img).toBeInstanceOf(HTMLImageElement); + expect(img.src).toBe(longUrl); + }); + + // ── Consolidated table-cell URL-gate semantics (shared decode→gate) ───────── + // After routing through the shared renderSafeMarkdownDestination, these inputs + // are gated identically to the block-image widget + the host write-gate. The + // first four were LIVE / under the old local decoder (which left the + // encoded form literal / required a trailing `;` / was case-sensitive); the + // shared canonical decoder resolves or NUL-substitutes them → blocked. + it("blocks `[bad](javascript&unknownentity;:1)` (unknown-entity bypass → NUL)", () => { + expect(html(renderCellInline("[bad](javascript&unknownentity;:1)"))).toBe( + "[bad](javascript&unknownentity;:1)" + ); + }); + + it("blocks `[bad](javascript:alert(1))` (semicolonless numeric ref decodes to `:`)", () => { + expect(html(renderCellInline("[bad](javascript:alert(1))"))).toBe( + "[bad](javascript&#58alert(1))" + ); + }); + + it("blocks `[bad](javascript&COLON;alert(1))` (uppercase named ref, case-insensitive)", () => { + expect(html(renderCellInline("[bad](javascript&COLON;alert(1))"))).toBe( + "[bad](javascript&COLON;alert(1))" + ); + }); + + it("blocks `[bad](java&tab;script:1)` (control entity decodes to TAB → C0 reject)", () => { + expect(html(renderCellInline("[bad](java&tab;script:1)"))).toBe("[bad](java&tab;script:1)"); + }); + + // Benign URLs still render live — the named-entity arm requires a trailing `;`, + // so plain query params survive, and `&` decodes to `&` and stays safe. + it("keeps a plain multi-param query link live (`[x](https://x.test/?a=1&b=2)`)", () => { + expect(htmlWithoutTooltip(renderCellInline("[x](https://x.test/?a=1&b=2)"))).toBe( + 'x' + ); + }); + + it("keeps a `&`-bearing query link live (`[x](https://x.test/?q=a&b)`)", () => { + expect(htmlWithoutTooltip(renderCellInline("[x](https://x.test/?q=a&b)"))).toBe( + 'x' + ); + }); + + // OVER-BLOCK POLICY (Codex Conf 95): a safe-scheme URL carrying a non-curated + // semicolon-terminated named entity (`©`) is undecodable → NUL → blocked. + // This was a LIVE link under the old local decoder; the consolidation makes + // table-cell render match the write-gate (non-persistable) + block-image gate. + it("blocks `[x](https://x.test/?q=a©b)` (non-curated entity over-block policy)", () => { + expect(html(renderCellInline("[x](https://x.test/?q=a©b)"))).toBe( + "[x](https://x.test/?q=a&copy;b)" + ); + }); + + it("renders an autolink as when allowed", () => { + const nodes = renderCellInline("see "); + expect(htmlWithoutTooltip(nodes)).toBe( + 'see https://example.com' + ); + }); + + it("leaves an unsafe autolink as inert text", () => { + const nodes = renderCellInline(""); + expect(html(nodes)).toBe("<javascript:alert(1)>"); + }); + + // error-handler re-review Conf 82 — balanced parens in URLs (CommonMark §6.6). + // Without depth-aware parsing, Wikipedia / MDN URLs containing `(...)` would + // truncate at the first `)` and ship a broken href. + it("preserves balanced parens in a URL (CommonMark §6.6)", () => { + const nodes = renderCellInline( + "[Rust](https://en.wikipedia.org/wiki/Rust_(programming_language))" + ); + expect(htmlWithoutTooltip(nodes)).toBe( + 'Rust' + ); + }); + + it("preserves a backslash-escaped `)` inside the URL", () => { + const nodes = renderCellInline("[x](https://e.test/a\\)b)"); + // The decoded URL is `https://e.test/a)b`. allowlist passes (https scheme). + expect(htmlWithoutTooltip(nodes)).toBe( + 'x' + ); + }); + + it("rejects an unescaped `<` or `>` inside the URL (CommonMark §6.3)", () => { + // `[x](foo`. Falls back to literal text. + expect(html(renderCellInline("[x](foobar)"))).toBe("[x](foo>bar)"); + }); + + // Over-cap containment (render-layer MAX_HREF_LENGTH gate). An allowlist-safe + // but over-length URL is capped at RENDER time — it never becomes a live + // `` — so NO native gesture (right-click "Open Link", middle-click, + // drag-to-address-bar) can open a URL the host `open-external` sink would + // reject. This is the sandbox-independent close for the context-menu bypass: + // whatever the native menu can reach is byte-identical to what the host sink + // would open, so `contextmenu` can stay un-suppressed (a11y). Rendered inert, + // it merges into surrounding text like any non-allowlisted URL. + it("renders an over-length allowlist-safe link inert (no live — unreachable by native Open Link)", () => { + const longUrl = `https://example.com/${"a".repeat(9000)}`; // > MAX_HREF_LENGTH (8192) + const src = `[x](${longUrl})`; + const nodes = renderCellInline(src); + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); + expect(nodes[0].textContent).toBe(src); // inert source slice, no anchor + }); + + it("absolute href at exactly MAX_HREF_LENGTH renders live and modifier-click is NOT preventDefault'd (at-cap boundary)", () => { + const prefix = "https://x.example.com/"; + const atCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length)}`; + expect(atCap.length).toBe(MAX_HREF_LENGTH); // guard against miscalc + const [a] = renderCellInline(`[x](${atCap})`) as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); // at cap → still a live link + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); + a.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); // within cap → routes via root handler + } + }); + + it("absolute href one over MAX_HREF_LENGTH renders inert (just-over-cap boundary)", () => { + const prefix = "https://x.example.com/"; + const overCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length + 1)}`; + expect(overCap.length).toBe(MAX_HREF_LENGTH + 1); + const src = `[x](${overCap})`; + const nodes = renderCellInline(src); + // Render-layer cap: one byte over → no live , inert source text. + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); + expect(nodes[0].textContent).toBe(src); + }); + + it("renders an over-length allowlist-safe autolink inert (parity with inline links)", () => { + const src = ``; + const nodes = renderCellInline(src); + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); + // Autolink inert path HTML-escapes the angle brackets (matches the unsafe + // autolink case above); assert via textContent to stay escaping-agnostic. + expect(nodes[0].textContent).toBe(src); + }); + + it("autolink href at exactly MAX_HREF_LENGTH renders live (at-cap boundary, autolink arm)", () => { + const prefix = "https://x.example.com/"; + const atCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length)}`; + expect(atCap.length).toBe(MAX_HREF_LENGTH); + const [a] = renderCellInline(`<${atCap}>`) as HTMLAnchorElement[]; + expect(a).toBeInstanceOf(HTMLAnchorElement); + }); + + it("autolink href one over MAX_HREF_LENGTH renders inert (just-over-cap boundary, autolink arm)", () => { + const prefix = "https://x.example.com/"; + const overCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length + 1)}`; + expect(overCap.length).toBe(MAX_HREF_LENGTH + 1); + const src = `<${overCap}>`; + const nodes = renderCellInline(src); + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); + }); + + // ── Relative-image containment (resolveAgainstBase parity with the block- + // image widget) ───────────────────────────────────────────────────────────── + // A schemeless relative destination passes the scheme allowlist, but the + // must NOT ship raw: it resolves against the document's resource + // base and passes resolveTrustedResourceUrl's directory containment, exactly + // like image/image-field.ts. Without a base (default ""), fail closed. + describe("relative image containment", () => { + const BASE = "https://csp/ws/notes/a.md"; + + it("resolves a sibling ./img.png against the resource base", () => { + const nodes = renderCellInline("![x](./img.png)", BASE); + expect(html(nodes)).toBe('x'); + }); + + it("renders ../secret.png as inert text (directory escape)", () => { + const nodes = renderCellInline("![x](../secret.png)", BASE); + expect(html(nodes)).toBe("![x](../secret.png)"); + }); + + it("renders ..%2fsecret.png as inert text (encoded dot-segment smuggle)", () => { + const nodes = renderCellInline("![x](..%2fsecret.png)", BASE); + expect(html(nodes)).toBe("![x](..%2fsecret.png)"); + }); + + it("renders a relative image as inert text when no base is provided", () => { + const nodes = renderCellInline("![x](./img.png)"); + expect(html(nodes)).toBe("![x](./img.png)"); + }); + + it("passes an absolute https image through unchanged (base present)", () => { + const nodes = renderCellInline("![x](https://x.test/a.png)", BASE); + expect(html(nodes)).toBe('x'); + }); + + it("renders a fragment-only image destination as inert text", () => { + const nodes = renderCellInline("![x](#frag)", BASE); + expect(html(nodes)).toBe("![x](#frag)"); + }); + + it("threads the base through emphasis recursion (*![x](./img.png)*)", () => { + const nodes = renderCellInline("*![x](./img.png)*", BASE); + expect(html(nodes)).toBe('x'); + }); + }); +}); diff --git a/test/webview/table/cm-table-cell-render.test.ts b/test/webview/table/cm-table-cell-render.test.ts deleted file mode 100644 index f108127d..00000000 --- a/test/webview/table/cm-table-cell-render.test.ts +++ /dev/null @@ -1,1314 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi } from "vitest"; -import { MAX_HREF_LENGTH } from "../../../src/shared/protocol.js"; -import type { Resolved, Span } from "../../../src/webview/cm/inline/inline-emphasis.js"; -import type { CellLeaf } from "../../../src/webview/cm/inline/inline-ir.js"; -import { - MAX_INLINE_NESTING_DEPTH, - parseCellInline, -} from "../../../src/webview/cm/inline/inline-ir.js"; -import { renderCellInline, renderCellInto } from "../../../src/webview/cm/table/cell-render.js"; -import type { - CellSourceMap, - CellSourceRun, -} from "../../../src/webview/cm/table/cell-source-map.js"; -import { getCellSourceMap } from "../../../src/webview/cm/table/cell-source-map.js"; - -function html(nodes: Node[]): string { - const root = document.createElement("div"); - for (const n of nodes) { - root.appendChild(n); - } - return root.innerHTML; -} - -describe("renderCellInline", () => { - it("renders plain text as a single text node", () => { - const nodes = renderCellInline("hello"); - expect(nodes).toHaveLength(1); - expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); - expect(nodes[0].textContent).toBe("hello"); - }); - - it("returns an empty array for an empty string", () => { - expect(renderCellInline("")).toEqual([]); - }); - - it("renders an inline link [text](url) as when URL is allowed", () => { - const nodes = renderCellInline("see [docs](https://example.com)"); - // Strip `title="…"` before comparing — the discoverability tooltip - // resolves "Cmd" vs "Ctrl" at module load via `navigator.platform`, - // so pinning it inline makes the snapshot platform-dependent. The - // dedicated tooltip test below uses an environment-safe regex; the - // structural snapshot should be platform-agnostic. - expect(html(nodes).replace(/ title="[^"]*"/g, "")).toBe( - 'see docs' - ); - }); - - it("renders an unsafe inline link as inert text", () => { - const nodes = renderCellInline("[bad](javascript:alert(1))"); - expect(html(nodes)).toBe("[bad](javascript:alert(1))"); - }); - - // CommonMark §2.4: only ASCII punctuation is backslash-escapable. In - // `[a](x\ y)` the `\ ` is a literal backslash, so the unescaped space - // terminates the bare destination and this is NOT a link — matching the - // Lezer write-gate parse. The escaped-punctuation case (`\)`) must still - // suppress the paren so a genuine escape keeps the link live. - it("renders `[a](x\\ y)` as literal text (`\\ ` is not an escape)", () => { - expect(html(renderCellInline("[a](x\\ y)"))).toBe("[a](x\\ y)"); - }); - - it("keeps `[a](x\\)y)` a live link (punctuation escape suppresses the paren)", () => { - expect(html(renderCellInline("[a](x\\)y)")).replace(/ title="[^"]*"/g, "")).toBe( - 'a' - ); - }); - - // CommonMark backslash + HTML-entity bypass. Without decoding the - // destination before the allowlist gate, `javascript:…` - // and `javascript\:…` look schemeless to the regex in `isAllowedUrl`, - // get classified as "relative", and ship as a live `` that the - // browser then resolves to `javascript:…` → XSS. - it("blocks `[bad](javascript:alert(1))` (HTML-entity scheme bypass)", () => { - const nodes = renderCellInline("[bad](javascript:alert(1))"); - expect(html(nodes)).toBe("[bad](javascript&#58;alert(1))"); - }); - - it("blocks `[bad](javascript\\:alert(1))` (backslash-escape scheme bypass)", () => { - const nodes = renderCellInline("[bad](javascript\\:alert(1))"); - expect(html(nodes)).toBe("[bad](javascript\\:alert(1))"); - }); - - it("blocks `[bad](javascript:alert(1))` (named-entity scheme bypass)", () => { - const nodes = renderCellInline("[bad](javascript:alert(1))"); - expect(html(nodes)).toBe("[bad](javascript&colon;alert(1))"); - }); - - it("renders an inline image ![alt](url) as with alt", () => { - const nodes = renderCellInline("![logo](https://x.test/a.png)"); - expect(html(nodes)).toBe('logo'); - }); - - it("CommonMark-normalizes an image alt (![*em*](url) -> alt=em)", () => { - const nodes = renderCellInline("![*em*](https://x.test/a.png)"); - expect(html(nodes)).toBe('em'); - }); - - it("decodes an entity in an image alt (![a&b](url) -> alt=a&b)", () => { - const nodes = renderCellInline("![a&b](https://x.test/a.png)"); - // innerHTML re-encodes & in the attribute, so assert via the DOM node. - expect((nodes[0] as HTMLImageElement).alt).toBe("a&b"); - }); - - it("renders an unsafe inline image as inert text", () => { - const nodes = renderCellInline("![x](javascript:1)"); - expect(html(nodes)).toBe("![x](javascript:1)"); - }); - - it("blocks `![x](javascript:1)` image (HTML-entity scheme bypass)", () => { - const nodes = renderCellInline("![x](javascript:1)"); - expect(html(nodes)).toBe("![x](javascript&#58;1)"); - }); - - it("does not cap image src at MAX_HREF_LENGTH (images are exempt — no open-external round-trip)", () => { - const longUrl = `https://x.test/${"a".repeat(9000)}.png`; // > MAX_HREF_LENGTH - const nodes = renderCellInline(`![x](${longUrl})`); - const [img] = nodes as HTMLImageElement[]; - expect(img).toBeInstanceOf(HTMLImageElement); - expect(img.src).toBe(longUrl); - }); - - // ── Consolidated table-cell URL-gate semantics (shared decode→gate) ───────── - // After routing through the shared renderSafeMarkdownDestination, these inputs - // are gated identically to the block-image widget + the host write-gate. The - // first four were LIVE / under the old local decoder (which left the - // encoded form literal / required a trailing `;` / was case-sensitive); the - // shared canonical decoder resolves or NUL-substitutes them → blocked. - it("blocks `[bad](javascript&unknownentity;:1)` (unknown-entity bypass → NUL)", () => { - expect(html(renderCellInline("[bad](javascript&unknownentity;:1)"))).toBe( - "[bad](javascript&unknownentity;:1)" - ); - }); - - it("blocks `[bad](javascript:alert(1))` (semicolonless numeric ref decodes to `:`)", () => { - expect(html(renderCellInline("[bad](javascript:alert(1))"))).toBe( - "[bad](javascript&#58alert(1))" - ); - }); - - it("blocks `[bad](javascript&COLON;alert(1))` (uppercase named ref, case-insensitive)", () => { - expect(html(renderCellInline("[bad](javascript&COLON;alert(1))"))).toBe( - "[bad](javascript&COLON;alert(1))" - ); - }); - - it("blocks `[bad](java&tab;script:1)` (control entity decodes to TAB → C0 reject)", () => { - expect(html(renderCellInline("[bad](java&tab;script:1)"))).toBe("[bad](java&tab;script:1)"); - }); - - // Benign URLs still render live — the named-entity arm requires a trailing `;`, - // so plain query params survive, and `&` decodes to `&` and stays safe. - it("keeps a plain multi-param query link live (`[x](https://x.test/?a=1&b=2)`)", () => { - expect( - html(renderCellInline("[x](https://x.test/?a=1&b=2)")).replace(/ title="[^"]*"/g, "") - ).toBe('x'); - }); - - it("keeps a `&`-bearing query link live (`[x](https://x.test/?q=a&b)`)", () => { - expect( - html(renderCellInline("[x](https://x.test/?q=a&b)")).replace(/ title="[^"]*"/g, "") - ).toBe('x'); - }); - - // OVER-BLOCK POLICY (Codex Conf 95): a safe-scheme URL carrying a non-curated - // semicolon-terminated named entity (`©`) is undecodable → NUL → blocked. - // This was a LIVE link under the old local decoder; the consolidation makes - // table-cell render match the write-gate (non-persistable) + block-image gate. - it("blocks `[x](https://x.test/?q=a©b)` (non-curated entity over-block policy)", () => { - expect(html(renderCellInline("[x](https://x.test/?q=a©b)"))).toBe( - "[x](https://x.test/?q=a&copy;b)" - ); - }); - - it("renders an autolink as when allowed", () => { - const nodes = renderCellInline("see "); - expect(html(nodes).replace(/ title="[^"]*"/g, "")).toBe( - 'see https://example.com' - ); - }); - - it("leaves an unsafe autolink as inert text", () => { - const nodes = renderCellInline(""); - expect(html(nodes)).toBe("<javascript:alert(1)>"); - }); - - it("renders inline `code` as ", () => { - const nodes = renderCellInline("use `git diff`"); - expect(html(nodes)).toBe("use git diff"); - }); - - // CommonMark §6.1: a multi-backtick opener with no matching closing run - // renders literally. The C6b scope is single-backtick spans only; the - // pre-fix code greedily paired the first two backticks of `` `` `` and - // emitted an empty ``. Multi-backtick code spans + CommonMark - // code normalization are deferred out of C6c scope — multi-backtick runs - // fall through to literal text indefinitely until that scope lands. - it("renders a double-backtick `` `` `` sequence as literal text (no empty )", () => { - expect(html(renderCellInline("``"))).toBe("``"); - expect(html(renderCellInline("a `` b"))).toBe("a `` b"); - }); - - it("decodes escaped pipe `\\|` to a literal `|` in text", () => { - const nodes = renderCellInline("a\\|b"); - expect(html(nodes)).toBe("a|b"); - }); - - it("HTML-escapes raw `<` / `>` / `&` in plain text", () => { - const nodes = renderCellInline("a < b & c > d"); - expect(html(nodes)).toBe("a < b & c > d"); - }); - - // Basic paired emphasis renders live. (Full CommonMark §6.4 — nesting, - // `_underscore_`, and delimiter-run flanking — is pinned by the dedicated - // cases further down.) The C4a orchestrator's reveal spans are still dropped - // because the table's range is in the exclusion facet. - it("renders `**bold**` as a live ", () => { - expect(html(renderCellInline("**bold**"))).toBe("bold"); - }); - - it("renders `*em*` as a live ", () => { - expect(html(renderCellInline("*em*"))).toBe("em"); - }); - - // The inner walk runs with emphasis disabled, but link / image / autolink / - // code parsing — and therefore the URL-safety gate — still apply. An - // unsafe URL inside emphasis MUST still be rendered inert (no live ``). - it("routes an unsafe URL inside emphasis through renderSafeUrl (`**[bad](javascript:1)**`)", () => { - expect(html(renderCellInline("**[bad](javascript:1)**"))).toBe( - "[bad](javascript:1)" - ); - }); - - it("leaves unpaired emphasis delimiters as literal text", () => { - expect(html(renderCellInline("**unclosed"))).toBe("**unclosed"); - expect(html(renderCellInline("*also unclosed"))).toBe("*also unclosed"); - }); - - // Full delimiter stack: a `**` opener with only a single `*` closer consumes - // one delimiter from each, leaving one literal `*` before a live . - // Verified via @lezer/markdown. - it("renders `**a*` as `*a` (leftover opener delimiter)", () => { - expect(html(renderCellInline("**a*"))).toBe("*a"); - }); - - // Task #2: positive pin that a safe link inside emphasis renders correctly. - // Strip the platform-specific `title` (Cmd vs Ctrl) so the assertion stays - // environment-agnostic — the title contract is pinned in its own test. - it("renders a safe link inside emphasis (`*[ok](https://x.test)*`)", () => { - expect(html(renderCellInline("*[ok](https://x.test)*")).replace(/ title="[^"]*"/g, "")).toBe( - 'ok' - ); - }); - - // Task #3: empty-emphasis boundary — `****` must not produce an empty - // `` (the `close > i + 2` guard rejects a close that is - // immediately adjacent to the opener, e.g. `****` where close == i + 2). - it("renders `****` as literal text (empty strong prevented by close > i + 2 guard)", () => { - expect(html(renderCellInline("****"))).toBe("****"); - }); - - it("renders bare `**` as literal text (no close)", () => { - expect(html(renderCellInline("**"))).toBe("**"); - }); - - // Task #5: CommonMark §6.2 flanking rule — whitespace immediately after - // the opener or before the closer disqualifies the delimiter run. - it("renders `* em *` as literal text (opener-after-whitespace, CommonMark flanking rule)", () => { - expect(html(renderCellInline("* em *"))).toBe("* em *"); - }); - - it("renders `**bold **` as literal text (closer-before-whitespace, CommonMark flanking rule)", () => { - expect(html(renderCellInline("**bold **"))).toBe("**bold **"); - }); - - // Task #6: CommonMark §6.1 backslash escape for `*` suppresses emphasis. - it("renders `\\*not em\\*` as literal `*not em*` (backslash escape suppresses em)", () => { - expect(html(renderCellInline("\\*not em\\*"))).toBe("*not em*"); - }); - - // Full CommonMark §6.1/§6.4: `\*` escapes the first `*` of each pair, leaving - // the second `*` as a live delimiter. The trailing `\*` is an escaped literal - // `*` INSIDE the span; the final bare `*` closes it. Verified via @lezer/markdown. - it("renders `\\**not strong\\**` as `*not strong*` (CommonMark escape + flanking)", () => { - expect(html(renderCellInline("\\**not strong\\**"))).toBe("*not strong*"); - }); - - // CommonMark §6.1 backslash parity: `\\` is itself an escape sequence - // (literal `\`), so `\\*em*` MUST parse as literal `\` followed by a live - // `em`. Without the `\\` guard, the second `\` would mis-fire as - // the start of `\*` and silently suppress the emphasis. - it("renders `\\\\*em*` as literal `\\` plus live (backslash parity)", () => { - expect(html(renderCellInline("\\\\*em*"))).toBe("\\em"); - }); - - it("renders `\\\\**bold**` as literal `\\` plus live ", () => { - expect(html(renderCellInline("\\\\**bold**"))).toBe("\\bold"); - }); - - // Full delimiter stack now nests: outer `**` strong contains an inner `*` em. - // Verified via @lezer/markdown. - it("nests inner emphasis inside outer emphasis (`**a *b* c**`)", () => { - expect(html(renderCellInline("**a *b* c**"))).toBe("a b c"); - }); - - // --- C6c: full CommonMark §6.4 delimiter-stack cases (all verified via - // @lezer/markdown). --- - - it("keeps the inner `**` literal in `*a**b*` (rule of 3)", () => { - expect(html(renderCellInline("*a**b*"))).toBe("a**b"); - }); - - it("renders `**a ** b**` as `a ** b` (whitespace-flanked inner `**` is literal)", () => { - expect(html(renderCellInline("**a ** b**"))).toBe("a ** b"); - }); - - it("splits `***text***` into nested ``", () => { - expect(html(renderCellInline("***text***"))).toBe("text"); - }); - - it("renders `_x_` as live (underscore emphasis)", () => { - expect(html(renderCellInline("_x_"))).toBe("x"); - }); - - it("renders `__b__` as live (underscore strong)", () => { - expect(html(renderCellInline("__b__"))).toBe("b"); - }); - - // Strikethrough (`~~…~~`) + highlight (`==…==`) parity: these render formatted - // everywhere else in the editor, but the table-cell widget used to leak the raw - // delimiters (`| ~~x~~ |` showed the tildes). They are emitted as delimiter runs - // into the SAME stack as `*`/`_` (inline-emphasis.ts), so resolveInline pairs - // them into / wraps that interleave with emphasis exactly as the - // editor's @lezer/markdown parser does. - it("renders `~~x~~` as a live (strikethrough)", () => { - expect(html(renderCellInline("~~x~~"))).toBe("x"); - }); - - it("renders `==x==` as a live (highlight)", () => { - expect(html(renderCellInline("==x=="))).toBe("x"); - }); - - it("nests emphasis inside a mark (`~~*x*~~`, `==**b**==`)", () => { - expect(html(renderCellInline("~~*x*~~"))).toBe("x"); - expect(html(renderCellInline("==**b**=="))).toBe("b"); - }); - - it("renders a mark amid surrounding text (`a ~~b~~ ==c== d`)", () => { - expect(html(renderCellInline("a ~~b~~ ==c== d"))).toBe("a b c d"); - }); - - // Flanking parity with the source parsers: a leading space after the opener - // means it cannot open, so the run stays literal (the editor would not strike - // it either). The `a == b` case is the common false-trigger — an `==` flanked - // by spaces neither opens nor closes. - it("leaves a non-flanking mark literal (`~~ x~~`, `a == b`)", () => { - expect(html(renderCellInline("~~ x~~"))).toBe("~~ x~~"); - expect(html(renderCellInline("a == b"))).toBe("a == b"); - }); - - // An unmatched opener (no closing pair) stays literal — the delimiter run - // survives as its literal characters, merged with adjacent text. - it("leaves an unmatched mark opener literal (`~~x`, `a==b`)", () => { - expect(html(renderCellInline("~~x"))).toBe("~~x"); - expect(html(renderCellInline("a==b"))).toBe("a==b"); - }); - - // A `~~`/`==` inside inline code is inert (code binds tighter, content literal). - it("does not mark inside an inline code span (`` `~~x~~` ``)", () => { - expect(html(renderCellInline("`~~x~~`"))).toBe("~~x~~"); - }); - - // Shared-delimiter-stack interleaving parity. A greedy nearest-closer scan - // would mis-pair these; the delimiter stack reproduces @lezer/markdown exactly. - // `*a~~b*c~~d*`: emphasis wins, the crossing `~~` are left inert. Verified - // against @lezer/markdown + GFM directly (code-quality review). - it("interleaves a mark with emphasis like the editor (`*a~~b*c~~d*`)", () => { - expect(html(renderCellInline("*a~~b*c~~d*"))).toBe("a~~bc~~d*"); - }); - - // Nested same-type marks: outer wraps the inner via the delimiter stack, no - // literal tail left behind (the greedy scan closed the outer at the inner). - it("nests same-type marks (`~~a ~~b~~ c~~`)", () => { - expect(html(renderCellInline("~~a ~~b~~ c~~"))).toBe("a b c"); - }); - - // The SAME interleave/nesting behaviour on the `==` side (highlight is the - // newer, less battle-tested delimiter — pin it independently so a future edit - // that broke only the `=` slot / `mark` tag branch cannot pass on `~~` alone). - it("interleaves `==` with emphasis like the editor (`*a==b*c==d*`)", () => { - expect(html(renderCellInline("*a==b*c==d*"))).toBe("a==bc==d*"); - }); - - it("nests same-type highlight marks (`==a ==b== c==`)", () => { - expect(html(renderCellInline("==a ==b== c=="))).toBe("a b c"); - }); - - // Cross-type nesting: a highlight inside a strikethrough, resolved in the one - // shared stack (both are length-2 delimiters that only differ by tag). - it("nests a highlight inside a strikethrough (`~~a ==b== c~~`)", () => { - expect(html(renderCellInline("~~a ==b== c~~"))).toBe("a b c"); - }); - - // Triple run: Lezer rescans from pos+1, so `===x===` opens at [1,3) and closes - // at [5,7), wrapping content `x=` (Highlight span [1,7) — the measured span - // highlight-mark.ts documents). Only the leading `=` (index 0) stays literal. - it("handles a triple-delimiter run like the editor (`===x===`)", () => { - expect(html(renderCellInline("===x==="))).toBe("=x="); - }); - - // The mark wrap does NOT bypass the URL render-gate: an unsafe link nested - // inside `~~…~~` still renders inert (mirrors the emphasis arm's - // `**[bad](javascript:1)**` case). The link leaf carries the safeUrl=null - // verdict; the surrounding del/mark is just a wrapper. - it("keeps the URL gate for an unsafe link inside a mark (`~~[bad](javascript:1)~~`)", () => { - expect(html(renderCellInline("~~[bad](javascript:1)~~"))).toBe( - "[bad](javascript:1)" - ); - }); - - it("keeps a safe link live inside a mark (`==[ok](https://x.test)==`)", () => { - expect(html(renderCellInline("==[ok](https://x.test)==")).replace(/ title="[^"]*"/g, "")).toBe( - 'ok' - ); - }); - - // Image alt (commonMarkAltText → flattenInlineText) flattens a mark to its - // text content, same as emphasis — used for `` in both the table-cell - // renderer and the shared block-image widget. - it("flattens a mark in an image alt (`![a ~~b~~ c](url)` -> alt=`a b c`)", () => { - const nodes = renderCellInline("![a ~~b~~ c](https://x.test/i.png)"); - expect((nodes[0] as HTMLImageElement).alt).toBe("a b c"); - }); - - it("leaves intraword underscores literal (`a_b_c`, `foo_bar_baz`)", () => { - expect(html(renderCellInline("a_b_c"))).toBe("a_b_c"); - expect(html(renderCellInline("foo_bar_baz"))).toBe("foo_bar_baz"); - }); - - it("renders an escaped delimiter inside emphasis literally (`*a\\*b*`)", () => { - expect(html(renderCellInline("*a\\*b*"))).toBe("a*b"); - }); - - // 6-state openers_bottom regression: a closer that can also open must not - // poison a later close-only closer's opener bound. A 3-state bound yields - // `**aaa*`. Verified via @lezer/markdown. - it("nests `**a*a*a*` as `*aaa` (6-state openers_bottom)", () => { - expect(html(renderCellInline("**a*a*a*"))).toBe("*aaa"); - }); - - // Unicode flanking: `©` is a Symbol (Unicode S), which CommonMark counts as a - // punctuation character. With `©` before the second `*`, that run is not - // right-flanking, so it cannot close → the whole thing stays literal. (A - // `\p{P}`-only classifier would wrongly emit `ac`.) Verified via - // @lezer/markdown. - it("treats a Unicode symbol as punctuation for flanking (`a*b©*c` stays literal)", () => { - expect(html(renderCellInline("a*b©*c"))).toBe("a*b©*c"); - }); - - // Deferred (C6c-proper, not a regression): emphasis inside a link label is - // NOT parsed — the label renders as plain text. Links bind tighter than - // emphasis and the tokenizer resolves them atomically. Pins the boundary so - // the deferral is intentional. CommonMark would emit `a b c`. - it("does NOT parse emphasis inside a link label (deferred to C6c-proper)", () => { - expect(html(renderCellInline("[a *b* c](https://x.test)")).replace(/ title="[^"]*"/g, "")).toBe( - 'a *b* c' - ); - }); - - // Astral-plane flanking — exercises the `charBefore` / `charAfter` whole-code- - // point path that the BMP `a*b©*c` case does not. Classification is by Unicode - // code point category: 💲 (U+1F4B2) is a Symbol (S) → punctuation; 𐀀 (U+10000) - // is a Letter (Lo) → not punctuation. Expectations follow the CommonMark spec - // and the reference markdown-it brute-force (Codex review). NOTE: @lezer/markdown - // is NOT the oracle for these — it classifies astral chars on UTF-16 units and - // gets them wrong (`a*b💲*c` → `ab💲c`), which is exactly the lone- - // surrogate hazard that `charBefore`'s pair handling avoids. - it("treats an astral symbol as punctuation before a closer (`a*b💲*c` literal)", () => { - expect(html(renderCellInline("a*b💲*c"))).toBe("a*b💲*c"); - }); - - it("treats an astral symbol as punctuation after an opener (`a*💲b*c` literal)", () => { - expect(html(renderCellInline("a*💲b*c"))).toBe("a*💲b*c"); - }); - - it("treats an astral letter as non-punctuation (`a*𐀀b*c` → em)", () => { - expect(html(renderCellInline("a*𐀀b*c"))).toBe("a𐀀bc"); - }); - - it("renders mixed content in source order", () => { - const nodes = renderCellInline("pre [link](https://e.test) mid `code` end"); - expect(html(nodes).replace(/ title="[^"]*"/g, "")).toBe( - 'pre link mid code end' - ); - }); - - // error-handler re-review Conf 82 — balanced parens in URLs (CommonMark §6.6). - // Without depth-aware parsing, Wikipedia / MDN URLs containing `(...)` would - // truncate at the first `)` and ship a broken href. - it("preserves balanced parens in a URL (CommonMark §6.6)", () => { - const nodes = renderCellInline( - "[Rust](https://en.wikipedia.org/wiki/Rust_(programming_language))" - ); - expect(html(nodes).replace(/ title="[^"]*"/g, "")).toBe( - 'Rust' - ); - }); - - it("preserves a backslash-escaped `)` inside the URL", () => { - const nodes = renderCellInline("[x](https://e.test/a\\)b)"); - // The decoded URL is `https://e.test/a)b`. allowlist passes (https scheme). - expect(html(nodes).replace(/ title="[^"]*"/g, "")).toBe( - 'x' - ); - }); - - it("rejects an unescaped `<` or `>` inside the URL (CommonMark §6.3)", () => { - // `[x](foo`. Falls back to literal text. - expect(html(renderCellInline("[x](foobar)"))).toBe("[x](foo>bar)"); - }); - - // C6b smoke #5 — plain click on a widget-internal link must NOT navigate to - // the browser (that bypasses caret-reveal and locks the user out of editing - // the link source). Modifier-click is the documented escape hatch matching - // VS Code Markdown preview / Go-to-Definition convention. - it("inline-link plain click is preventDefault'd (so the widget's caret-dispatch path takes over)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - const event = new MouseEvent("click", { bubbles: true, cancelable: true }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - }); - - it("inline-link Cmd/Ctrl-click falls through to default navigation (falls through to the widget root handler, which routes through the host open-external gate)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - } - }); - - // `isAllowedUrl` returns true for any schemeless string - // (relative paths / fragments fall through to the "safe" branch), so - // `./doc.md` and `#section` ship as live . Browser behaviour - // for modifier-click on a relative href inside the VS Code webview - // iframe is undefined. Pin modifier-click to preventDefault for - // non-absolute hrefs so the user lands on the widget's caret-dispatch - // path instead. - it("relative-URL modifier-click is preventDefault'd (no undefined webview navigation)", () => { - const [a] = renderCellInline("[local](./doc.md)") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - } - }); - - it("fragment-URL modifier-click is preventDefault'd", () => { - const [a] = renderCellInline("[section](#intro)") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - } - }); - - // Pin the positive case so the absolute-scheme allowlist doesn't tighten - // too far in a future refactor — mailto: must keep the external escape - // hatch alongside https / http. Iterate both modifiers so a regression - // that tightens the guard to `metaKey only` (or `ctrlKey only`) trips. - it("mailto: modifier-click falls through to default navigation (falls through to the widget root handler, which routes through the host open-external gate)", () => { - const [a] = renderCellInline("[mail](mailto:a@b.test)") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - } - }); - - // Over-cap containment (render-layer MAX_HREF_LENGTH gate). An allowlist-safe - // but over-length URL is capped at RENDER time — it never becomes a live - // `` — so NO native gesture (right-click "Open Link", middle-click, - // drag-to-address-bar) can open a URL the host `open-external` sink would - // reject. This is the sandbox-independent close for the context-menu bypass: - // whatever the native menu can reach is byte-identical to what the host sink - // would open, so `contextmenu` can stay un-suppressed (a11y). Rendered inert, - // it merges into surrounding text like any non-allowlisted URL. - it("renders an over-length allowlist-safe link inert (no live — unreachable by native Open Link)", () => { - const longUrl = `https://example.com/${"a".repeat(9000)}`; // > MAX_HREF_LENGTH (8192) - const src = `[x](${longUrl})`; - const nodes = renderCellInline(src); - expect(nodes).toHaveLength(1); - expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); - expect(nodes[0].textContent).toBe(src); // inert source slice, no anchor - }); - - it("absolute href at exactly MAX_HREF_LENGTH renders live and modifier-click is NOT preventDefault'd (at-cap boundary)", () => { - const prefix = "https://x.example.com/"; - const atCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length)}`; - expect(atCap.length).toBe(MAX_HREF_LENGTH); // guard against miscalc - const [a] = renderCellInline(`[x](${atCap})`) as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); // at cap → still a live link - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); // within cap → routes via root handler - } - }); - - it("absolute href one over MAX_HREF_LENGTH renders inert (just-over-cap boundary)", () => { - const prefix = "https://x.example.com/"; - const overCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length + 1)}`; - expect(overCap.length).toBe(MAX_HREF_LENGTH + 1); - const src = `[x](${overCap})`; - const nodes = renderCellInline(src); - // Render-layer cap: one byte over → no live , inert source text. - expect(nodes).toHaveLength(1); - expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); - expect(nodes[0].textContent).toBe(src); - }); - - it("renders an over-length allowlist-safe autolink inert (parity with inline links)", () => { - const src = ``; - const nodes = renderCellInline(src); - expect(nodes).toHaveLength(1); - expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); - // Autolink inert path HTML-escapes the angle brackets (matches the unsafe - // autolink case above); assert via textContent to stay escaping-agnostic. - expect(nodes[0].textContent).toBe(src); - }); - - it("autolink href at exactly MAX_HREF_LENGTH renders live (at-cap boundary, autolink arm)", () => { - const prefix = "https://x.example.com/"; - const atCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length)}`; - expect(atCap.length).toBe(MAX_HREF_LENGTH); - const [a] = renderCellInline(`<${atCap}>`) as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - }); - - it("autolink href one over MAX_HREF_LENGTH renders inert (just-over-cap boundary, autolink arm)", () => { - const prefix = "https://x.example.com/"; - const overCap = `${prefix}${"a".repeat(MAX_HREF_LENGTH - prefix.length + 1)}`; - expect(overCap.length).toBe(MAX_HREF_LENGTH + 1); - const src = `<${overCap}>`; - const nodes = renderCellInline(src); - expect(nodes).toHaveLength(1); - expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); - }); - - // Button-1 (middle-click) rides `auxclick` + the browser's native "open in - // new tab" default — it does NOT fire `click` (per the UI Events spec, `click` - // is primary-button-only), so the click-only guard never runs and the open - // would skip the host `open-external` re-validation + MAX_HREF_LENGTH cap. - // Middle-click-to-open is not a supported gesture (the vetted escape hatch is - // Cmd/Ctrl+left-click), so every `auxclick` — even on an otherwise-openable - // absolute href — must preventDefault. - it("absolute-href middle-click (auxclick) is preventDefault'd (closes the open-external choke-point bypass)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - const event = new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - }); - - it("middle-click (auxclick) with a modifier is also preventDefault'd (aux buttons have no escape hatch)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("auxclick", { - bubbles: true, - cancelable: true, - button: 1, - ...modifier, - }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - } - }); - - // The guard is intentionally button-agnostic — `auxclick` fires for any - // non-primary button (back/forward too), so a future narrowing to - // `event.button === 1` would silently reopen it for those. Pin a non-middle - // aux button (4 = forward) so such a narrowing trips. - it("non-middle auxclick (side button) is also preventDefault'd (button-agnostic guard)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - const event = new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 4 }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - }); - - it("autolink middle-click (auxclick) is preventDefault'd (same gate as inline links)", () => { - const [a] = renderCellInline("") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - const event = new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - }); - - it("autolink plain click is preventDefault'd (same gate as inline links)", () => { - const [a] = renderCellInline("") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - const event = new MouseEvent("click", { bubbles: true, cancelable: true }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - }); - - it("does not attach a contextmenu handler on a live link (keyboard-invoked menu / Shift+F10 still works)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - }); - - it("does not attach a contextmenu handler on a live autolink", () => { - const [a] = renderCellInline("") as HTMLAnchorElement[]; - const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - }); - - // Autolink positive case — parallel to the inline-link Cmd/Ctrl test above. - // Pins the autolink branch directly so a refactor that drops `attachLinkClickGuard` - // from the autolink path trips here. - it("autolink Cmd/Ctrl-click falls through to default navigation (absolute scheme — external open)", () => { - const [a] = renderCellInline("") as HTMLAnchorElement[]; - expect(a).toBeInstanceOf(HTMLAnchorElement); - for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { - const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...modifier }); - a.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - } - }); - - it("emits a discoverability tooltip on links (mentions the modifier key)", () => { - const [a] = renderCellInline("[docs](https://example.com)") as HTMLAnchorElement[]; - expect(a.title).toMatch(/(Cmd|Ctrl)\+click to open/); - }); - - // Parallel pin for autolinks — the existing snapshot tests strip - // `title="…"` before comparing (platform-dependent), so a regression - // that forgot to attach the tooltip to autolinks would slip through. - it("emits a discoverability tooltip on autolinks (mentions the modifier key)", () => { - const [a] = renderCellInline("") as HTMLAnchorElement[]; - expect(a.title).toMatch(/(Cmd|Ctrl)\+click to open/); - }); - - // ── Relative-image containment (resolveAgainstBase parity with the block- - // image widget) ───────────────────────────────────────────────────────────── - // A schemeless relative destination passes the scheme allowlist, but the - // must NOT ship raw: it resolves against the document's resource - // base and passes resolveTrustedResourceUrl's directory containment, exactly - // like image/image-field.ts. Without a base (default ""), fail closed. - describe("relative image containment", () => { - const BASE = "https://csp/ws/notes/a.md"; - - it("resolves a sibling ./img.png against the resource base", () => { - const nodes = renderCellInline("![x](./img.png)", BASE); - expect(html(nodes)).toBe('x'); - }); - - it("renders ../secret.png as inert text (directory escape)", () => { - const nodes = renderCellInline("![x](../secret.png)", BASE); - expect(html(nodes)).toBe("![x](../secret.png)"); - }); - - it("renders ..%2fsecret.png as inert text (encoded dot-segment smuggle)", () => { - const nodes = renderCellInline("![x](..%2fsecret.png)", BASE); - expect(html(nodes)).toBe("![x](..%2fsecret.png)"); - }); - - it("renders a relative image as inert text when no base is provided", () => { - const nodes = renderCellInline("![x](./img.png)"); - expect(html(nodes)).toBe("![x](./img.png)"); - }); - - it("passes an absolute https image through unchanged (base present)", () => { - const nodes = renderCellInline("![x](https://x.test/a.png)", BASE); - expect(html(nodes)).toBe('x'); - }); - - it("renders a fragment-only image destination as inert text", () => { - const nodes = renderCellInline("![x](#frag)", BASE); - expect(html(nodes)).toBe("![x](#frag)"); - }); - - it("threads the base through emphasis recursion (*![x](./img.png)*)", () => { - const nodes = renderCellInline("*![x](./img.png)*", BASE); - expect(html(nodes)).toBe('x'); - }); - }); -}); - -// ── parseCellInline losslessness ───────────────────────────────────────────── - -// Depth-first ordered leaf spans: text spans, leaf outer spans, and for -// emphasis the openDelim span, then children (recursive), then closeDelim. -function leafSpans(ir: Resolved[]): Array<{ from: number; to: number }> { - const out: Array<{ from: number; to: number }> = []; - for (const n of ir) { - if (n.kind === "emphasis") { - out.push(n.openDelim, ...leafSpans(n.children), n.closeDelim); - } else { - out.push(n.span); - } - } - return out; -} - -describe("parseCellInline losslessness", () => { - const corpus = [ - "hello", - "", - "*em*", - "**b**", - "***t***", - "a_b_c", - "*a**b*", - "**a*a*a*", - "x \\| y", - "`code`", - "see [docs](https://example.com)", - "![alt](https://x.test/i.png)", - "", - "[bad](javascript:1)", - "a*b©*c", - "pre **a *b* c** post", - "~~x~~", - "==x==", - "~~*x*~~", - "a ~~b~~ ==c== d", - ]; - for (const raw of corpus) { - it(`partitions ${JSON.stringify(raw)} into ordered leaves that reconstruct the source`, () => { - const spans = leafSpans(parseCellInline(raw)); - // ordered + contiguous + covering - let cursor = 0; - let rebuilt = ""; - for (const s of spans) { - expect(s.from).toBe(cursor); - rebuilt += raw.slice(s.from, s.to); - cursor = s.to; - } - expect(cursor).toBe(raw.length); - expect(rebuilt).toBe(raw); - }); - } - - it("exposes link boundary spans for dimming", () => { - const ir = parseCellInline("[docs](https://x.test)"); - const link = ir[0]; - if (link.kind !== "leaf" || link.leaf.kind !== "link") { - throw new Error("expected link leaf"); - } - expect(link.leaf.safeUrl).toBe("https://x.test"); - expect("[docs](https://x.test)".slice(link.leaf.label.from, link.leaf.label.to)).toBe("docs"); - expect("[docs](https://x.test)".slice(link.leaf.dest.from, link.leaf.dest.to)).toBe( - "https://x.test" - ); - }); - - // Per-construct boundary spans must partition each leaf's OUTER span in source - // order — else PR2 dims the wrong characters while the outer-span partition - // test above still passes (Codex plan review Conf 98). - it("each leaf's boundary spans partition its outer span in order", () => { - const samples: Array<{ raw: string; kind: CellLeaf["kind"] }> = [ - { raw: "a\\|b", kind: "escape" }, - { raw: "`code`", kind: "code" }, - { raw: "see [docs](https://example.com)", kind: "link" }, - { raw: "![alt](https://x.test/i.png)", kind: "image" }, - { raw: "", kind: "autolink" }, - ]; - for (const { raw, kind } of samples) { - const leaves = walkLeaves(parseCellInline(raw)); - // Pin that the construct is emitted as the EXPECTED leaf kind (not folded - // into text) — else the boundary check below is vacuous when the leaf is - // absent (Codex re-review Conf 97). - const matching = leaves.filter((n) => n.leaf.kind === kind); - expect(matching).toHaveLength(1); - let cursor = matching[0].span.from; - for (const p of leafBoundarySpans(matching[0].leaf)) { - expect(p.to).toBeGreaterThanOrEqual(p.from); // reject reversed/overlapping spans (Conf 95) - expect(p.from).toBe(cursor); - cursor = p.to; - } - expect(cursor).toBe(matching[0].span.to); - } - }); - - it("pins text values and emphasis delimiter span/length/char invariants", () => { - const raw = "pre **a *b* c** post"; - for (const n of walkAll(parseCellInline(raw))) { - if (n.kind === "text") { - expect(raw.slice(n.span.from, n.span.to)).toBe(n.value); - } else if (n.kind === "emphasis") { - expect(n.span).toEqual({ from: n.openDelim.from, to: n.closeDelim.to }); - const want = n.tag === "strong" ? 2 : 1; - expect(n.openDelim.to - n.openDelim.from).toBe(want); - expect(n.closeDelim.to - n.closeDelim.from).toBe(want); - const oc = raw.slice(n.openDelim.from, n.openDelim.to); - const cc = raw.slice(n.closeDelim.from, n.closeDelim.to); - expect(new Set(oc).size).toBe(1); // a run of one delimiter char - expect(oc[0]).toBe(cc[0]); - } - } - }); -}); - -// Structure helpers for the boundary/invariant tests. -type LeafNode = Extract, { kind: "leaf" }>; -function walkLeaves(ir: Resolved[]): LeafNode[] { - const out: LeafNode[] = []; - for (const n of ir) { - if (n.kind === "leaf") { - out.push(n); - } else if (n.kind === "emphasis") { - out.push(...walkLeaves(n.children)); - } - } - return out; -} -function walkAll(ir: Resolved[]): Resolved[] { - const out: Resolved[] = []; - for (const n of ir) { - out.push(n); - if (n.kind === "emphasis") { - out.push(...walkAll(n.children)); - } - } - return out; -} -function leafBoundarySpans(leaf: CellLeaf): Span[] { - switch (leaf.kind) { - case "escape": - return [leaf.marker, leaf.char]; - case "code": - return [leaf.openFence, leaf.content, leaf.closeFence]; - case "link": - return [ - leaf.openBracket, - leaf.label, - leaf.closeBracket, - leaf.openParen, - leaf.dest, - leaf.closeParen, - ]; - case "image": - return [ - leaf.bang, - leaf.openBracket, - leaf.alt, - leaf.closeBracket, - leaf.openParen, - leaf.dest, - leaf.closeParen, - ]; - case "autolink": - return [leaf.openAngle, leaf.content, leaf.closeAngle]; - } -} - -// renderReadonly merging is the part innerHTML CANNOT see — adjacent text / -// escape / inert-source / leftover-delimiter runs must collapse to ONE text -// node. Pin it by node count, not innerHTML (Codex plan review Conf 92). -describe("renderReadonly text-node topology (merging is not vacuous)", () => { - it("merges an escape into surrounding text (`a\\|b` -> one text node `a|b`)", () => { - const nodes = renderCellInline("a\\|b"); - expect(nodes).toHaveLength(1); - expect(nodes[0].nodeType).toBe(Node.TEXT_NODE); - expect(nodes[0].textContent).toBe("a|b"); - }); - it("merges an inert unsafe construct into surrounding text (one node)", () => { - const nodes = renderCellInline("x[bad](javascript:1)y"); - expect(nodes).toHaveLength(1); - expect(nodes[0].textContent).toBe("x[bad](javascript:1)y"); - }); - it("merges unmatched delimiters into text (`x**unclosed` -> one node)", () => { - const nodes = renderCellInline("x**unclosed"); - expect(nodes).toHaveLength(1); - expect(nodes[0].textContent).toBe("x**unclosed"); - }); - it("merges an escaped pipe inside emphasis into one text child of ", () => { - const nodes = renderCellInline("*a\\|b*"); - expect(nodes).toHaveLength(1); - expect((nodes[0] as Element).tagName).toBe("EM"); - expect(nodes[0].childNodes).toHaveLength(1); - expect(nodes[0].textContent).toBe("a|b"); - }); - - it("renders a pathologically deep-emphasis cell without crashing", () => { - // ~N/2-deep emphasis nesting — the seed-time stack-overflow vector. The - // walker must fall back to inert literal source past the nesting cap - // instead of overflowing while walking the (bounded-build) tree. - const N = 40000; - const deep = `${"*".repeat(N)}a${"*".repeat(N)}`; - let nodes: Node[] = []; - expect(() => { - nodes = renderCellInline(deep); - }).not.toThrow(); - const text = nodes.map((n) => n.textContent ?? "").join(""); - // Content survives (the literal `a` is preserved past the cap)... - expect(text).toContain("a"); - // ...and the inert-source fallback actually fired: literal `*` delimiters - // leak into the text (a vacuous always-empty render would not contain them). - expect(text).toContain("*"); - // Non-vacuity vs the defense-in-depth try/catch: the WALKER cap emits only - // the emphasis span at depth 100 (the outer ~2×cap delimiters are unwrapped - // first), so the text is strictly shorter than the raw input. The try/catch - // fallback would instead return the FULL raw string as one text node — this - // pins that the cap path ran, not that an overflow was silently caught. - expect(text.length).toBeLessThan(deep.length); - }); -}); - -// The source map cell-point.ts consumes: every rendered character run paired -// with the source characters it came from, plus the markup its construct owns. -// This block is the AUTOMATIC tripwire that replaced the old -// "renderCellInline never grows the rendered text" describe. -// -// That describe existed because cell-point.ts decided "offsets map 1:1" from -// LENGTH EQUALITY alone, which was only sound while no construct rendered -// longer than its source — an unwritten contract guarded by a hand-maintained -// CASES list, so a construct with no sample could break it silently. The -// invariants below are structural: they hold for ANY input, so a new construct -// whose walker arm forgets its run (or claims the wrong source) fails them -// without anyone remembering to add a case. The corpus is kept only to give -// the tripwire a broad supply of shapes. -describe("cell source map invariants", () => { - /** Render through the production entry point — `renderCellInto` is what - * registers the map, and going through it is what keeps these invariants - * pinned to the path the widget actually takes. */ - function mapFor(raw: string, resourceBase = ""): { cell: HTMLElement; map: CellSourceMap } { - const cell = document.createElement("td"); - renderCellInto(cell, raw, resourceBase); - const map = getCellSourceMap(cell); - expect(map, `no map registered for ${JSON.stringify(raw)}`).not.toBeNull(); - return { cell, map: map as CellSourceMap }; - } - - function checkInvariants(raw: string, cell: HTMLElement, map: CellSourceMap): void { - const where = JSON.stringify(raw); - // The two halves of cell-point.ts's staleness check must be satisfiable at - // all: a map whose sourceLength or renderedText disagreed with what it was - // built from would make EVERY drag on that cell fall back. - expect(map.sourceLength, where).toBe(raw.length); - expect(map.renderedText, where).toBe(cell.textContent ?? ""); - let rendered = 0; - let prevTo = 0; - let prevOuterTo = 0; - for (const run of map.runs) { - // The run's source really is what rendered, character for character — - // the claim the whole mapping rests on. `rendered` is the running sum of - // the preceding runs' lengths, which is the map's ONLY notion of a run's - // rendered position and is how `sourceOffsetAt` derives it too (why no - // run stores it: the `CellSourceRun` doc in cell-source-map.ts). - const runLength = run.to - run.from; - expect(runLength, where).toBeGreaterThan(0); - expect(raw.slice(run.from, run.to), where).toBe( - map.renderedText.slice(rendered, rendered + runLength) - ); - // Source order, non-overlapping: two runs claiming the same byte would - // make the mapping ambiguous in the other direction. - expect(run.from, where).toBeGreaterThanOrEqual(prevTo); - // The outer span contains the run and stays inside the cell... - expect(run.outerFrom, where).toBeLessThanOrEqual(run.from); - expect(run.outerTo, where).toBeGreaterThanOrEqual(run.to); - expect(run.outerFrom, where).toBeGreaterThanOrEqual(prevOuterTo); - expect(run.outerTo, where).toBeLessThanOrEqual(raw.length); - rendered += runLength; - prevTo = run.to; - prevOuterTo = run.outerTo; - } - // ...and together the runs tile the whole rendered text, which is what lets - // `sourceOffsetAt` treat "not found" as unreachable. - expect(rendered, where).toBe(map.renderedText.length); - } - - const CASES = [ - "plain text", - "**bold**", - "_em_", - "~~del~~", - "==mark==", - "`code`", - "[label](https://example.com)", - "[label](./relative.md)", - "[label](javascript:alert(1))", - "", - "![alt](https://example.com/x.png)", - "![alt](./local.png)", - "\\| escaped pipe", - "\\*not em\\*", - "& entity", - "<>", - "😀", - "a © b", - "***nested bold em***", - "**[link](https://example.com)**", - "text with double spaces", - "trailing backslash \\", - "\u{1f600} emoji", - "raw html", - "", - "http://bare.example.com", - ]; - for (const src of CASES) { - it(`maps ${JSON.stringify(src)}`, () => { - const { cell, map } = mapFor(src, "https://base.example/dir/"); - checkInvariants(src, cell, map); - }); - } - - // The single-construct cases above say nothing about COMPOSITION: a walker - // arm that leaks a pending opener or a stale skip flag only misbehaves when - // one construct follows another. Fixed seed so a failure is reproducible - // from the printed source string alone. - it("holds for random compositions of every construct", () => { - const ATOMS = [ - "a", - " ", - "**b**", - "_i_", - "~~d~~", - "==m==", - "`c`", - "\\|", - "\\*", - "&", - "😀", - "[l](https://e.test)", - "![a](./i.png)", - "", - "x", - "\u{1f600}", - ]; - // High bits only: this LCG's LOW bits have a very short period, so - // `seed % n` degenerates (measured: every draw hit the first few atoms, max - // composition length 8, links and images NEVER generated). `>>> 16` gives a - // corpus that actually uses all 16 atoms. - let seed = 1; - const next = () => { - seed = (seed * 1103515245 + 12345) % 2147483648; - return (seed >>> 16) % 32768; - }; - for (let i = 0; i < 500; i++) { - let src = ""; - for (let n = next() % 6; n > 0; n--) { - src += ATOMS[next() % ATOMS.length]; - } - const { cell, map } = mapFor(src, "https://base.example/dir/"); - checkInvariants(src, cell, map); - } - }); -}); - -// Which SOURCE a (structurally valid) run claims is invisible to the invariants -// above — every case here would satisfy them while mapping a drag onto the -// wrong bytes. One named case per walker rule. -// -// ⚠ The fixture shapes are verified against the real tokenizer, not guessed. -// A CommonMark closer preceded by PUNCTUATION is right-flanking only when it is -// also followed by whitespace, punctuation or end-of-input — so `**a![i](p)**b` -// (closer preceded by `)`, followed by `b`) forms NO emphasis at all and never -// reaches the wrapper arm. (`**a**b` DOES form emphasis: its closer is preceded -// by an alphanumeric, so what follows is irrelevant.) And every live-image -// fixture uses an ABSOLUTE `https:` src because `resolveAgainstBase` returns -// null for a relative src with an empty base, which renders the image INERT — -// a run-emitting path that would silently test the wrong arm. Do not -// paraphrase them. -describe("cell source map — walker rules", () => { - const IMG = "![i](https://x.test/a.png)"; - - function runsOf(raw: string, resourceBase = ""): readonly CellSourceRun[] { - const cell = document.createElement("td"); - renderCellInto(cell, raw, resourceBase); - return (getCellSourceMap(cell) as CellSourceMap).runs; - } - - // Rule 1 (set-if-empty): an already-pending OUTER opener wins. Overwriting - // would orphan the outer delimiters, and a selection of `x` would no longer - // round-trip to the same rendered content. - it.each([ - ["***x***"], - ["**_b_**"], - ])("attributes BOTH delimiter pairs of %s to its single run", (src) => { - expect(runsOf(src)).toEqual([{ from: 3, to: 4, outerFrom: 0, outerTo: 7 }]); - }); - - // Rule 4: a wrapper whose text is followed by an invisible construct must NOT - // extend its closers over it — that would swallow the image into the left - // run and make a boundary straddling it look exact. - it("does not extend outerTo over trailing skipped source", () => { - expect(runsOf(`**a${IMG}**`)).toEqual([{ from: 2, to: 3, outerFrom: 0, outerTo: 3 }]); - }); - - // Rule 5: a wrapper that rendered nothing must not lend its delimiters to a - // later, unrelated run — the " a" run keeps its OWN outerFrom. - it("does not attribute an empty wrapper's delimiters to the next run", () => { - const src = `*${IMG}* a`; - expect(runsOf(src)).toEqual([ - { - from: src.length - 2, - to: src.length, - outerFrom: src.length - 2, - outerTo: src.length, - }, - ]); - }); - - // Zero-length guard: a live link with an empty label renders ``. Two - // runs at the same rendered index would make the boundary lookup ambiguous, - // so it emits none — and records the skip, which the following run's own - // outerFrom is what proves. - it("emits no run for a zero-length live link, and marks the skip", () => { - const empty = "[](https://example.com)"; - expect(runsOf(empty)).toEqual([]); - expect(runsOf(`${empty}a`)).toEqual([ - { - from: empty.length, - to: empty.length + 1, - outerFrom: empty.length, - outerTo: empty.length + 1, - }, - ]); - }); - - it("emits a whole-span run for an inert link (its source renders verbatim)", () => { - const inert = "[bad](javascript:1)"; - expect(runsOf(inert)).toEqual([ - { from: 0, to: inert.length, outerFrom: 0, outerTo: inert.length }, - ]); - }); - - // Past MAX_INLINE_NESTING_DEPTH the walker stops recursing and renders the - // literal source, so the run it emits is the whole span — same arm as an - // inert construct. Without its own emitRun the deep span would render text - // no run described, and every boundary after it would be off. - it("emits a whole-span run for the past-depth-cap emphasis literal", () => { - // Two delimiters per nesting level (each `**` pair is one ), so - // 2 × (cap + 1) is what actually reaches depth 100 — a bare cap + 1 stops - // at ~51 levels and never fires the arm. The literal that survives is the - // innermost `**x**`, rendered VERBATIM, which is why it needs a run of its - // own: without one it would emit text no run described and every later - // boundary would be off by its length. - const pad = "*".repeat(2 * (MAX_INLINE_NESTING_DEPTH + 1)); - const src = `${pad}x${pad}`; - const literal = "**x**"; - const from = pad.length - 2; - expect(runsOf(src)).toEqual([ - { - from, - to: from + literal.length, - // The outer wrappers each rendered text, so their closers accumulate - // outward all the way to the end of the cell. - outerFrom: 0, - outerTo: src.length, - }, - ]); - expect(src.slice(from, from + literal.length)).toBe(literal); - }); -}); - -describe("renderCellInto", () => { - it("registers a map whose renderedText is the cell's own textContent", () => { - const cell = document.createElement("td"); - renderCellInto(cell, "**bold**"); - expect(cell.innerHTML).toBe("bold"); - expect(getCellSourceMap(cell)?.renderedText).toBe(cell.textContent); - }); - - it("replaces the previous map on re-render (a reused patchRow cell)", () => { - const cell = document.createElement("td"); - renderCellInto(cell, "**bold**"); - renderCellInto(cell, "hi"); - expect(cell.textContent).toBe("hi"); - expect(getCellSourceMap(cell)).toEqual({ - runs: [{ from: 0, to: 2, outerFrom: 0, outerTo: 2 }], - sourceLength: 2, - renderedText: "hi", - }); - }); - - // Defense in depth: an unforeseen throw falls back to inert source text, and - // the map must fall back WITH it. Registering nothing would leave a reused - // cell describing whatever it rendered last — a stale map that passes the - // length half of the staleness check whenever the two sources are the same - // length. - it("registers the identity map when the renderer throws", () => { - const cell = document.createElement("td"); - // The fallback logs BY DESIGN (cell-render.ts's catch), so silence it here - // rather than leave a maintainer wondering whether the line is a symptom. - // It is asserted where it is the subject: cm-table-cell-map-failclosed.ts. - const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const spy = vi.spyOn(document, "createElement").mockImplementation(() => { - throw new Error("renderer exploded"); - }); - try { - renderCellInto(cell, "**bold**"); - } finally { - spy.mockRestore(); - errSpy.mockRestore(); - } - expect(cell.textContent).toBe("**bold**"); - expect(getCellSourceMap(cell)).toEqual({ - runs: [{ from: 0, to: 8, outerFrom: 0, outerTo: 8 }], - sourceLength: 8, - renderedText: "**bold**", - }); - }); -}); From 621c96666f0cd27a8b9fabe7d669e37a632e0545 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Sat, 22 Aug 2026 00:11:14 +1000 Subject: [PATCH 3/4] test(table): correct four factual errors in the carved suites' header prose Review found four claims in the new headers that the tree contradicts: - the fixtures header said render-map never touches innerHTML, but cm-table-cell-render-map.test.ts:275 asserts on it. The conclusion (that suite does not need the shared serialiser) was right, the reason was not. - the urls header said both at-cap rows observe a click. Only the absolute arm does; the autolink arm asserts liveness and stops. The header now records why that modifier-click loop must stay: the clicks suite only ever uses short URLs, so it is the sole pin on at-cap x modifier-click routing. - the inline-ir header justified its happy-dom pragma as how every file in the directory declares its environment. cm-table-fallback-warn.test.ts carries none, and vitest.config.ts sets node globally, so the pragma is a per-file opt-in rather than a declaration. - a cross-file reference dropped the .test segment, so following it landed on nothing. Pre-existing in the pre-split file, corrected here because every other such reference in the six new suites spells the suffix out. --- test/webview/table/cm-table-cell-inline-ir.test.ts | 7 ++++--- test/webview/table/cm-table-cell-render-map.test.ts | 2 +- test/webview/table/cm-table-cell-render-urls.test.ts | 6 ++++-- test/webview/table/helpers/cell-render-fixtures.ts | 6 ++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/test/webview/table/cm-table-cell-inline-ir.test.ts b/test/webview/table/cm-table-cell-inline-ir.test.ts index f9679d36..b9ed983d 100644 --- a/test/webview/table/cm-table-cell-inline-ir.test.ts +++ b/test/webview/table/cm-table-cell-inline-ir.test.ts @@ -8,9 +8,10 @@ // leaf's own boundary spans partitioning ITS outer span (a link's brackets, // label, parens and destination) — the level at which dimming picks characters, // and invisible to the outer check. -// The DOM is never touched here, but the pragma stays: it is how every file in -// this directory declares its environment, and the module under test is one -// import away from the renderer that does. +// The DOM is never touched here, and the global environment is node +// (vitest.config.ts), so the pragma is not load-bearing today. It stays because +// the module under test is one import away from the renderer that IS — the +// first assertion that renders needs no pragma edit to work. import { describe, expect, it } from "vitest"; import type { Resolved, Span } from "../../../src/webview/cm/inline/inline-emphasis.js"; diff --git a/test/webview/table/cm-table-cell-render-map.test.ts b/test/webview/table/cm-table-cell-render-map.test.ts index a7fa2628..194c86fb 100644 --- a/test/webview/table/cm-table-cell-render-map.test.ts +++ b/test/webview/table/cm-table-cell-render-map.test.ts @@ -297,7 +297,7 @@ describe("renderCellInto", () => { const cell = document.createElement("td"); // The fallback logs BY DESIGN (cell-render.ts's catch), so silence it here // rather than leave a maintainer wondering whether the line is a symptom. - // It is asserted where it is the subject: cm-table-cell-map-failclosed.ts. + // It is asserted where it is the subject: cm-table-cell-map-failclosed.test.ts. const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const spy = vi.spyOn(document, "createElement").mockImplementation(() => { throw new Error("renderer exploded"); diff --git a/test/webview/table/cm-table-cell-render-urls.test.ts b/test/webview/table/cm-table-cell-render-urls.test.ts index a0b6049c..50c07b72 100644 --- a/test/webview/table/cm-table-cell-render-urls.test.ts +++ b/test/webview/table/cm-table-cell-render-urls.test.ts @@ -9,8 +9,10 @@ // The cap lives here rather than with the click routing because it decides // whether an anchor is created at all: an over-cap URL never becomes a live // ``, so no native gesture (Open Link, middle-click, drag-to-address-bar) can -// reach a URL the host's open-external sink would reject. The two at-cap rows -// observe a click only as proof the anchor really did go live. +// reach a URL the host's open-external sink would reject. The absolute at-cap row +// keeps its modifier-click loop because the clicks suite only ever uses short +// URLs: it is the sole pin that an at-cap href still routes through the widget +// root handler under BOTH modifiers. The autolink at-cap row asserts liveness only. // What a link does once it IS live is cm-table-cell-render-clicks.test.ts; how // the delimiters around it pair is cm-table-cell-render-emphasis.test.ts. // Fixtures: helpers/cell-render-fixtures.ts. diff --git a/test/webview/table/helpers/cell-render-fixtures.ts b/test/webview/table/helpers/cell-render-fixtures.ts index 4cf1a47c..a909cd42 100644 --- a/test/webview/table/helpers/cell-render-fixtures.ts +++ b/test/webview/table/helpers/cell-render-fixtures.ts @@ -1,7 +1,9 @@ // Fixtures shared by the `cm-table-cell-render-*.test.ts` suites that assert on // rendered markup — urls, emphasis and text. (The clicks suite dispatches events -// against the returned nodes and needs neither; inline-ir and render-map never -// touch innerHTML.) Extracted rather than copied per file because the tooltip +// against the returned nodes and needs neither; inline-ir touches no DOM at all, +// and render-map reads innerHTML off the real cell `renderCellInto` populated +// rather than serialising a detached Node[].) Extracted rather than copied per +// file because the tooltip // strip had 11 occurrences before the split, spread over three files-to-be: one // definition is one place to fix when the tooltip's text changes, eleven is // eleven chances to miss one. Not a test file itself (no `.test.ts` suffix), From eb362f49cc8b372043f6403b3bd3155118ce1eb5 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Sat, 22 Aug 2026 00:16:54 +1000 Subject: [PATCH 4/4] test(table): trim redundant lines from the carved suites' headers The per-file 'Fixtures: helpers/cell-render-fixtures.ts' line repeated what the import directly below it already states, and the fixtures header carried a rhetorical aside that added no information the preceding clause lacked. --- test/webview/table/cm-table-cell-render-emphasis.test.ts | 1 - test/webview/table/cm-table-cell-render-text.test.ts | 1 - test/webview/table/cm-table-cell-render-urls.test.ts | 1 - test/webview/table/helpers/cell-render-fixtures.ts | 9 ++++----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/test/webview/table/cm-table-cell-render-emphasis.test.ts b/test/webview/table/cm-table-cell-render-emphasis.test.ts index 0fbce2bd..349af4d5 100644 --- a/test/webview/table/cm-table-cell-render-emphasis.test.ts +++ b/test/webview/table/cm-table-cell-render-emphasis.test.ts @@ -13,7 +13,6 @@ // The oracle for these expectations is @lezer/markdown, the parser the editor // itself runs — EXCEPT the astral-plane flanking rows, which say in place why // they use the CommonMark spec and markdown-it instead. -// Fixtures: helpers/cell-render-fixtures.ts. import { describe, expect, it } from "vitest"; import { renderCellInline } from "../../../src/webview/cm/table/cell-render.js"; diff --git a/test/webview/table/cm-table-cell-render-text.test.ts b/test/webview/table/cm-table-cell-render-text.test.ts index 7bed27c1..50f6ae29 100644 --- a/test/webview/table/cm-table-cell-render-text.test.ts +++ b/test/webview/table/cm-table-cell-render-text.test.ts @@ -8,7 +8,6 @@ // `a|b`, below as a single text node. A renderer that emitted three nodes whose // text happened to concatenate to `a|b` would satisfy the first and fail the // second, which is why the two live together. -// Fixtures: helpers/cell-render-fixtures.ts. import { describe, expect, it } from "vitest"; import { renderCellInline } from "../../../src/webview/cm/table/cell-render.js"; diff --git a/test/webview/table/cm-table-cell-render-urls.test.ts b/test/webview/table/cm-table-cell-render-urls.test.ts index 50c07b72..e3a21c3d 100644 --- a/test/webview/table/cm-table-cell-render-urls.test.ts +++ b/test/webview/table/cm-table-cell-render-urls.test.ts @@ -15,7 +15,6 @@ // root handler under BOTH modifiers. The autolink at-cap row asserts liveness only. // What a link does once it IS live is cm-table-cell-render-clicks.test.ts; how // the delimiters around it pair is cm-table-cell-render-emphasis.test.ts. -// Fixtures: helpers/cell-render-fixtures.ts. import { describe, expect, it } from "vitest"; import { MAX_HREF_LENGTH } from "../../../src/shared/protocol.js"; diff --git a/test/webview/table/helpers/cell-render-fixtures.ts b/test/webview/table/helpers/cell-render-fixtures.ts index a909cd42..e3e40ad8 100644 --- a/test/webview/table/helpers/cell-render-fixtures.ts +++ b/test/webview/table/helpers/cell-render-fixtures.ts @@ -3,11 +3,10 @@ // against the returned nodes and needs neither; inline-ir touches no DOM at all, // and render-map reads innerHTML off the real cell `renderCellInto` populated // rather than serialising a detached Node[].) Extracted rather than copied per -// file because the tooltip -// strip had 11 occurrences before the split, spread over three files-to-be: one -// definition is one place to fix when the tooltip's text changes, eleven is -// eleven chances to miss one. Not a test file itself (no `.test.ts` suffix), -// mirroring helpers/widget-fixtures.ts. +// file because the tooltip strip had 11 occurrences before the split, spread +// over three files-to-be: one definition is one place to fix when the tooltip's +// text changes. Not a test file itself (no `.test.ts` suffix), mirroring +// helpers/widget-fixtures.ts. /** Serialise rendered cell nodes to markup. *