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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion test/webview/styles-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <del>/<mark>). happy-dom does not apply CSS, so pin the source rule text
// (same idiom as the table-link/code pins above): <del> line-through, <mark>
// reusing the shared --quoll-highlight-bg tint. Non-vacuous — both red if the
Expand Down
187 changes: 187 additions & 0 deletions test/webview/table/cm-table-cell-inline-ir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// @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, 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";
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<CellLeaf>[]): 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)",
"<https://x.test>",
"[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: "<https://x.test>", 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<Resolved<CellLeaf>, { kind: "leaf" }>;
function walkLeaves(ir: Resolved<CellLeaf>[]): 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<CellLeaf>[]): Resolved<CellLeaf>[] {
const out: Resolved<CellLeaf>[] = [];
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];
}
}
179 changes: 179 additions & 0 deletions test/webview/table/cm-table-cell-render-clicks.test.ts
Original file line number Diff line number Diff line change
@@ -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 <a href>. 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("<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("autolink plain click is preventDefault'd (same gate as inline links)", () => {
const [a] = renderCellInline("<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("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("<https://example.com>") 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("<https://example.com>") 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("<https://example.com>") as HTMLAnchorElement[];
expect(a.title).toMatch(/(Cmd|Ctrl)\+click to open/);
});
});
Loading
Loading