From ba12fc441041b28cd10bb7374853afd7ad3774ce Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 22:23:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?core:=20close=20the=20push=E2=86=92display?= =?UTF-8?q?=20validation=20gap=20=E2=80=94=20reject=20anything=20the=20vie?= =?UTF-8?q?wer=20can't=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server 204'd payloads it had no renderer for; the pusher only discovered the breakage as '[unsupported type: X]' / '[unknown component: X]' blocks or silently dropped edges in the browser, with no feedback loop to fix them. Observed live: boards with invented types (pane types "Panel"/"Image"/"pane"), unknown components ("Button", "MapLink", "ListItem"), all stored as successes. validateContent (shared: server 400 + CLI offline fast-fail) now rejects, with path-pointed messages: - unknown top-level/pane types, listing the supported set - component nodes (children AND node-valued props, mirroring the client resolver's isComponentNode heuristic exactly) not in the whitelist, with a did-you-mean hint (ListItem → "List.Item") - flow nodes without unique string ids, edges/parentId referencing missing nodes (React Flow silently drops those edges) - vegalite specs with no view-defining key (nothing to draw) New KNOWN_PUSH_TYPES / KNOWN_COMPONENTS exports are kept in lockstep with the client registry + component resolver by a new parity test. Verified against all 192 live boards and 66 diagram-recipes examples: zero false positives — the only rejects are boards already visibly broken today. --- packages/core/src/validate.ts | 136 ++++++++++++++++++++-- packages/viewer/test/known-parity.test.ts | 18 +++ packages/viewer/test/server.test.ts | 29 +++++ packages/viewer/test/validate.test.ts | 96 ++++++++++++++- 4 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 packages/viewer/test/known-parity.test.ts diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 0194144..7e452d5 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -14,6 +14,57 @@ // since every pushed tree is validated first. No real UI nests anywhere near this deep. export const MAX_DEPTH = 256; +/** + * Every type the viewer client has a renderer for (registry.ts LOADERS). A push with any other + * type used to be stored and 204'd, then surface only as a "[unsupported type: X]" block in the + * browser — the pusher never got the feedback loop to fix it (observed in the wild: invented + * types like "datatabel", pane types like "Panel"/"Image"). The parity test + * (viewer/test/known-parity.test.ts) keeps this list and the client registry in lockstep. + */ +export const KNOWN_PUSH_TYPES: string[] = [ + "text", "ansi", "mermaid", "markdown", "panes", "component", "vegalite", "flow", "calltree", "datatable", +]; +const KNOWN_TYPE_SET = new Set(KNOWN_PUSH_TYPES); + +/** + * The client component-resolver's whitelist (component-resolver.tsx REGISTRY). A node with any + * other `type` renders as a visible "[unknown component: X]" marker — observed in the wild: + * "Button", "MapLink", "ListItem" (for "List.Item"). Kept in lockstep by the parity test. + */ +export const KNOWN_COMPONENTS: string[] = [ + // layout + "Stack", "Group", "Grid", "Grid.Col", "SimpleGrid", "Paper", "Card", "Card.Section", "Box", + "Divider", "Center", "ScrollArea", + // content + "Text", "Title", "Badge", "Pill", "Alert", "List", "List.Item", + "Table", "Table.Thead", "Table.Tbody", "Table.Tr", "Table.Th", "Table.Td", + "Code", "Blockquote", "Anchor", "Image", "ThemeIcon", "Progress", "RingProgress", + "Timeline", "Timeline.Item", "Tabs", "Tabs.List", "Tabs.Tab", "Tabs.Panel", + // more display components + "Flex", "Space", "Fieldset", "Spoiler", "Kbd", "Mark", "Highlight", "Avatar", "Indicator", + "Tooltip", "NumberFormatter", "Notification", + "Accordion", "Accordion.Item", "Accordion.Control", "Accordion.Panel", + // custom nodes (charts, map, interactive checklist, video card, masonry gallery) + "VegaLite", "Map", "Checklist", "Video", "Masonry", +]; +const KNOWN_COMPONENT_SET = new Set(KNOWN_COMPONENTS); +// Canonical name by lowercased, dot-stripped form, for "did you mean" (ListItem → List.Item). +const COMPONENT_BY_LOOSE = new Map(KNOWN_COMPONENTS.map((c) => [c.toLowerCase().replace(/\./g, ""), c])); + +const componentHint = (type: string): string => { + const close = COMPONENT_BY_LOOSE.get(type.toLowerCase().replace(/\./g, "")); + return close ? ` (did you mean "${close}"?)` : ""; +}; + +/** Mirrors the client resolver's isComponentNode: a prop value shaped like this WILL be resolved + * as a component node in the browser (and an unknown type rendered as a visible marker), so the + * validator must walk exactly the same values — no more (data-shaped objects like Vega encodings + * or Table `data` must pass through untouched), no less. */ +const isComponentNodeLike = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) && + typeof (v as Record).type === "string" && + (KNOWN_COMPONENT_SET.has((v as Record).type as string) || "props" in v || "children" in v); + /** Validate a `component` node tree. Returns an error message (with a path) or null. */ export function validateComponentTree(node: unknown, path = "root", depth = 0): string | null { if (depth > MAX_DEPTH) return `${path}: component tree too deeply nested (> ${MAX_DEPTH} levels)`; @@ -28,10 +79,31 @@ export function validateComponentTree(node: unknown, path = "root", depth = 0): const n = node as Record; if (typeof n.type !== "string" || n.type.length === 0) return `${path}: a component node needs a non-empty string "type" (got ${JSON.stringify(n.type)})`; + if (!KNOWN_COMPONENT_SET.has(n.type)) + return ( + `${path}: unknown component "${n.type}"${componentHint(n.type)} — the viewer renders it as a ` + + `visible "[unknown component]" marker. Supported components: ${KNOWN_COMPONENTS.join(", ")}` + ); if (n.props !== undefined && (typeof n.props !== "object" || n.props === null || Array.isArray(n.props))) return `${path}(${n.type}).props must be an object`; - // Only `children` is part of the node tree; `props` values are arbitrary (node-valued props - // like icon/label are resolved by the client). + // Node-valued props (icon/label/leftSection/… given as {type,...} trees, or arrays of them) are + // resolved to elements by the client, so they're validated like children. Anything the client + // treats as plain data (no `type`, or an unknown type with no props/children) is left alone. + if (n.props) { + const props = n.props as Record; + for (const k in props) { + const v = props[k]; + if (isComponentNodeLike(v)) { + const e = validateComponentTree(v, `${path}(${n.type}).props.${k}`, depth + 1); + if (e) return e; + } else if (Array.isArray(v) && v.length > 0 && v.every(isComponentNodeLike)) { + for (let i = 0; i < v.length; i++) { + const e = validateComponentTree(v[i], `${path}(${n.type}).props.${k}[${i}]`, depth + 1); + if (e) return e; + } + } + } + } if (n.children !== undefined) return validateComponentTree(n.children, `${path}/${n.type}`, depth + 1); return null; } @@ -211,9 +283,20 @@ export function mermaidErrors(text: string): string[] { return errors; } +// A Vega-Lite spec draws something only if it has a view-defining key; without one, vega-embed +// throws in the browser ("vega-lite render error") after the server already said 204. +const VEGA_VIEW_KEYS = ["mark", "layer", "facet", "hconcat", "vconcat", "concat", "repeat", "spec"]; + /** Validate `content` for a given push `type`. Returns an error message or null (valid / freeform). * `depth` bounds nested-panes recursion (a pane's content can itself be a `panes` payload). */ export function validateContent(type: string, content: string, depth = 0): string | null { + // The viewer can only display types it has a renderer for — anything else would be stored, + // 204'd, and then break in the browser with no feedback to the pusher. + if (!KNOWN_TYPE_SET.has(type)) + return ( + `unknown type "${type}" — the viewer has no renderer for it and would show a ` + + `"[unsupported type]" block. Supported types: ${KNOWN_PUSH_TYPES.join(", ")}` + ); // mermaid is NOT freeform — validate against the renderable subset (header + supported constructs). if (type === "mermaid") return mermaidErrors(content)[0] ?? null; if (type !== "component" && type !== "panes" && type !== "flow" && type !== "vegalite" && type !== "calltree" && type !== "datatable") @@ -226,22 +309,53 @@ export function validateContent(type: string, content: string, depth = 0): strin return `invalid ${type} JSON: ${(e as Error).message}`; } if (type === "component") return validateComponentTree(v); - if (type === "vegalite") return isObj(v) ? null : "vegalite content must be a Vega-Lite spec object"; + if (type === "vegalite") { + if (!isObj(v)) return "vegalite content must be a Vega-Lite spec object"; + if (!VEGA_VIEW_KEYS.some((k) => v[k] !== undefined)) + return ( + 'vegalite spec has nothing to draw — it needs "mark" (or layer/facet/concat/repeat/spec), ' + + 'e.g. { "mark": "bar", "data": { "values": [...] }, "encoding": {...} }' + ); + return null; + } if (type === "flow") { if (!isObj(v)) return "flow content must be an object { nodes, edges, ... }"; - // `nodes`/`edges` are optional, but when present each entry must be a non-null object: the - // layout/geometry pass dereferences `n.data` / `n.group` / `e.source` and a `null` or primitive - // entry would otherwise throw deep inside the renderer as an opaque HTTP 500 instead of a - // path-pointed 400 here. (Keys beyond that stay lenient — the renderer defaults them safely.) + // `nodes`/`edges` are optional, but when present each entry must be an object with a usable + // identity: React Flow needs a unique string `id` per node, and an edge whose source/target + // doesn't name an existing node is silently dropped from the drawing — the pushed graph looks + // subtly wrong with no feedback. All of these are authoring bugs, so they're path-pointed 400s. + // (Keys beyond id/source/target/parentId stay lenient — the renderer defaults them safely.) + const ids = new Set(); if (v.nodes !== undefined) { if (!Array.isArray(v.nodes)) return "flow.nodes must be an array"; - for (let i = 0; i < v.nodes.length; i++) - if (!isObj(v.nodes[i])) return `flow.nodes[${i}] must be an object`; + for (let i = 0; i < v.nodes.length; i++) { + const n = v.nodes[i]; + if (!isObj(n)) return `flow.nodes[${i}] must be an object`; + if (typeof n.id !== "string" || n.id.length === 0) + return `flow.nodes[${i}].id must be a non-empty string (got ${JSON.stringify(n.id)})`; + if (ids.has(n.id)) return `flow.nodes[${i}].id "${n.id}" is a duplicate — node ids must be unique`; + ids.add(n.id); + } + // Second pass so a parent declared after its child still resolves. + for (let i = 0; i < v.nodes.length; i++) { + const n = v.nodes[i] as Record; + if (n.parentId !== undefined && (typeof n.parentId !== "string" || !ids.has(n.parentId))) + return `flow.nodes[${i}].parentId ${JSON.stringify(n.parentId)} does not match any node id`; + } } if (v.edges !== undefined) { if (!Array.isArray(v.edges)) return "flow.edges must be an array"; - for (let i = 0; i < v.edges.length; i++) - if (!isObj(v.edges[i])) return `flow.edges[${i}] must be an object`; + for (let i = 0; i < v.edges.length; i++) { + const e = v.edges[i]; + if (!isObj(e)) return `flow.edges[${i}] must be an object`; + for (const k of ["source", "target"] as const) { + const ep = e[k]; + if (typeof ep !== "string" || ep.length === 0) + return `flow.edges[${i}].${k} must be a non-empty string (a node id, got ${JSON.stringify(ep)})`; + if (!ids.has(ep)) + return `flow.edges[${i}].${k} "${ep}" does not match any node id — the renderer would silently drop this edge`; + } + } } return null; } diff --git a/packages/viewer/test/known-parity.test.ts b/packages/viewer/test/known-parity.test.ts new file mode 100644 index 0000000..568f50f --- /dev/null +++ b/packages/viewer/test/known-parity.test.ts @@ -0,0 +1,18 @@ +// @vitest-environment happy-dom +// Parity guards: the server-side whitelists in @ivanmkc/termchart-core must exactly match what the +// client can actually render. If a renderer type or a whitelisted component is added/removed on one +// side without the other, pushes would be rejected for renderable content (validator too strict) or +// stored and broken in the UI (validator too loose). These tests fail the build instead. +import { describe, expect, it } from "vitest"; +import { KNOWN_PUSH_TYPES, KNOWN_COMPONENTS } from "@ivanmkc/termchart-core"; +import { KNOWN_TYPES } from "../src/client/registry.js"; +import { componentNames } from "../src/client/renderers/component-resolver.js"; + +describe("core ↔ client whitelist parity", () => { + it("KNOWN_PUSH_TYPES matches the client renderer registry exactly", () => { + expect([...KNOWN_PUSH_TYPES].sort()).toEqual([...KNOWN_TYPES].sort()); + }); + it("KNOWN_COMPONENTS matches the client component registry exactly", () => { + expect([...KNOWN_COMPONENTS].sort()).toEqual([...componentNames()].sort()); + }); +}); diff --git a/packages/viewer/test/server.test.ts b/packages/viewer/test/server.test.ts index 8081224..aa311d0 100644 --- a/packages/viewer/test/server.test.ts +++ b/packages/viewer/test/server.test.ts @@ -181,6 +181,35 @@ describe("viewer server", () => { expect(ok.status).toBe(204); }); + it("rejects an unknown push type with a 400 listing the supported types (was stored, then '[unsupported type]' in the UI)", async () => { + const r = await push({ project: "p", agent: "ut", type: "datatabel", content: "{}" }); + expect(r.status).toBe(400); + const body = await r.text(); + expect(body).toContain('unknown type "datatabel"'); + expect(body).toContain("datatable"); // the supported list names the near-miss + expect((await getJson("state?project=p&agent=ut")).status).toBe(404); // nothing stored + }); + + it("rejects an unknown component name with a path-pointed 400 (was stored, then '[unknown component]' in the UI)", async () => { + const r = await push({ + project: "p", agent: "uc", type: "component", + content: JSON.stringify({ type: "Stack", children: [{ type: "Button", children: "Book" }] }), + }); + expect(r.status).toBe(400); + const body = await r.text(); + expect(body).toContain('unknown component "Button"'); + expect(body).toContain("root/Stack[0]"); + }); + + it("rejects a flow edge referencing a missing node with a 400 (was silently dropped by the renderer)", async () => { + const r = await push({ + project: "p", agent: "fe", type: "flow", + content: JSON.stringify({ nodes: [{ id: "a", data: { label: "A" } }], edges: [{ source: "a", target: "ghost" }] }), + }); + expect(r.status).toBe(400); + expect(await r.text()).toContain('"ghost"'); + }); + it("rejects malformed/unsupported mermaid with a descriptive 400 (was silently stored as 204)", async () => { // Unknown/missing header — the pusher gets an actionable reason instead of a false 204 success. const unknown = await push({ project: "p", agent: "m1", type: "mermaid", content: "this is not a diagram" }); diff --git a/packages/viewer/test/validate.test.ts b/packages/viewer/test/validate.test.ts index d2116e2..54e2018 100644 --- a/packages/viewer/test/validate.test.ts +++ b/packages/viewer/test/validate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateContent, validateComponentTree } from "@ivanmkc/termchart-core"; +import { validateContent, validateComponentTree, KNOWN_PUSH_TYPES, KNOWN_COMPONENTS } from "@ivanmkc/termchart-core"; describe("validateComponentTree", () => { it("passes a well-formed tree", () => { @@ -73,3 +73,97 @@ describe("validateContent", () => { expect(validateContent("component", JSON.stringify(node))).toContain("too deeply nested"); }); }); + +// The viewer can only display types it has a renderer for. A push with an invented type used to be +// stored with a 204 "success" and only surface as "[unsupported type: X]" in the browser — the +// pusher never learned. Now the server (and the CLI fast-fail) reject it with the supported list. +describe("unknown push types", () => { + it("rejects an unknown top-level type, listing what IS supported", () => { + const err = validateContent("datatabel", "{}"); + expect(err).toContain('unknown type "datatabel"'); + for (const t of ["flow", "component", "vegalite", "datatable"]) expect(err).toContain(t); + }); + it("rejects an unknown type nested inside panes (agents invent pane types like Panel/Image)", () => { + const bad = JSON.stringify({ layout: "rows", panes: [{ type: "Panel", content: "{}" }] }); + const err = validateContent("panes", bad); + expect(err).toContain("panes[0]"); + expect(err).toContain('unknown type "Panel"'); + }); + it("exports the full renderable set", () => { + for (const t of ["text", "ansi", "mermaid", "markdown", "panes", "component", "vegalite", "flow", "calltree", "datatable"]) + expect(KNOWN_PUSH_TYPES).toContain(t); + }); +}); + +// The component renderer resolves node types against a fixed whitelist; an unknown name renders as +// a visible "[unknown component: X]" marker. Reject at push time instead, with the path. +describe("component whitelist", () => { + it("rejects an unknown component in the children tree with a path", () => { + const bad = { type: "Stack", children: [{ type: "Button", children: "Book" }] }; + const err = validateContent("component", JSON.stringify(bad)); + expect(err).toContain("root/Stack[0]"); + expect(err).toContain('unknown component "Button"'); + }); + it('suggests the canonical name for a near-miss (ListItem → "List.Item")', () => { + const err = validateComponentTree({ type: "List", children: [{ type: "ListItem", children: "x" }] }); + expect(err).toContain('unknown component "ListItem"'); + expect(err).toContain('"List.Item"'); + }); + it("validates node-valued props exactly like the client resolver treats them", () => { + // a node-like prop with an unknown type would render as an in-UI marker → reject + const bad = { type: "Alert", props: { icon: { type: "Sparkle", props: {} } } }; + expect(validateComponentTree(bad)).toContain('unknown component "Sparkle"'); + // arrays of node-like props are resolved by the client too + const badArr = { type: "Group", props: { items: [{ type: "Badge", children: "a" }, { type: "Chip", children: "b" }] } }; + expect(validateComponentTree(badArr)).toContain('unknown component "Chip"'); + // a known component as a prop node is fine + const ok = { type: "Alert", props: { icon: { type: "ThemeIcon", children: "!" } } }; + expect(validateComponentTree(ok)).toBeNull(); + }); + it("leaves data-shaped prop objects alone (they are NOT component nodes)", () => { + // vega encodings etc. carry a `type` string but no props/children and no whitelisted name — + // the client passes them through as plain data, so the validator must too. + const spec = { type: "VegaLite", props: { spec: { mark: "bar" }, x: { type: "quantitative", field: "n" } } }; + expect(validateComponentTree(spec)).toBeNull(); + const tableData = { type: "Table", props: { data: { head: ["a"], body: [["1"]] } } }; + expect(validateComponentTree(tableData)).toBeNull(); + }); + it("exports the whitelist for client parity", () => { + for (const c of ["Stack", "Table.Td", "Checklist", "Masonry", "VegaLite"]) expect(KNOWN_COMPONENTS).toContain(c); + }); +}); + +// React Flow needs string node ids and edges whose endpoints exist; violations render a broken or +// partly-missing graph with no feedback. Path-pointed rejections instead. +describe("flow structure", () => { + const flow = (v: object) => validateContent("flow", JSON.stringify(v)); + it("requires a non-empty string id on every node", () => { + expect(flow({ nodes: [{ label: "no id" }] })).toContain("flow.nodes[0].id"); + expect(flow({ nodes: [{ id: 7 }] })).toContain("flow.nodes[0].id"); + }); + it("rejects duplicate node ids", () => { + expect(flow({ nodes: [{ id: "a" }, { id: "a" }] })).toContain("duplicate"); + }); + it("requires string edge endpoints that reference existing nodes", () => { + expect(flow({ nodes: [{ id: "a" }], edges: [{ target: "a" }] })).toContain("flow.edges[0].source"); + const dangling = flow({ nodes: [{ id: "a" }], edges: [{ source: "a", target: "ghost" }] }); + expect(dangling).toContain("flow.edges[0].target"); + expect(dangling).toContain('"ghost"'); + }); + it("requires parentId (group membership) to reference an existing node", () => { + expect(flow({ nodes: [{ id: "a", parentId: "zone" }] })).toContain('flow.nodes[0].parentId'); + expect(flow({ nodes: [{ id: "zone", type: "group" }, { id: "a", parentId: "zone" }] })).toBeNull(); + }); +}); + +describe("vegalite composition", () => { + it("rejects a spec with nothing to draw", () => { + const err = validateContent("vegalite", JSON.stringify({ data: { values: [1] } })); + expect(err).toContain("mark"); + }); + it("accepts single-view and composed specs", () => { + expect(validateContent("vegalite", JSON.stringify({ mark: "bar", data: { values: [] } }))).toBeNull(); + expect(validateContent("vegalite", JSON.stringify({ vconcat: [{ mark: "line" }] }))).toBeNull(); + expect(validateContent("vegalite", JSON.stringify({ layer: [{ mark: "area" }] }))).toBeNull(); + }); +}); From 7c43dd80c96da83e3fe4c17fcfc3542af1c658aa Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 23:33:24 +0000 Subject: [PATCH 2/3] test: stress coverage for the push validator + revive the stress e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validator stress tests (hot-path bounds): a 10k-node/20k-edge flow and a wide ~1 MB component tree validate in <2s with errors still caught at the end of the payload; a near-cap (~600 KB content) push round-trips through the real server in <5s (204) and a buried unknown component still returns the path-pointed 400. The stress e2e suite (34 browser cases) had rotted independently of this branch — every sidebar click timed out because it matched on the button's inner text ('stress/') which the scope-proj/scope-agent span split removed (rich.e2e was already migrated to the title selector), and it treated HTTP 200 (stored with geometry findings) as a rejected push. Fixed both, and updated two fixture expectations to the new contract: flow_dangling and component_unknown are now rejected at push time instead of stored-and-broken. Suites: viewer 528, cli 173, rich e2e 62/62, board-sort 12/12, template 17/17, responsive 20/20, overlap 31/31, stress 34/34. --- .../viewer/e2e/stress-cases/manifest.json | 4 +- packages/viewer/e2e/stress.e2e.mjs | 8 +++- packages/viewer/test/server.test.ts | 22 ++++++++++ packages/viewer/test/validate.test.ts | 40 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/viewer/e2e/stress-cases/manifest.json b/packages/viewer/e2e/stress-cases/manifest.json index cd4e480..424d3a2 100644 --- a/packages/viewer/e2e/stress-cases/manifest.json +++ b/packages/viewer/e2e/stress-cases/manifest.json @@ -47,7 +47,7 @@ { "name": "flow_dangling", "type": "flow", - "expect": "render" + "expect": "reject" }, { "name": "flow_selfloop", @@ -72,7 +72,7 @@ { "name": "component_unknown", "type": "component", - "expect": "render" + "expect": "reject" }, { "name": "component_longtext", diff --git a/packages/viewer/e2e/stress.e2e.mjs b/packages/viewer/e2e/stress.e2e.mjs index 71a6a9b..a9f5a8a 100644 --- a/packages/viewer/e2e/stress.e2e.mjs +++ b/packages/viewer/e2e/stress.e2e.mjs @@ -60,14 +60,18 @@ try { rows.push({ ...m, pass, stuck: false, signal: false, errBlock: false, errs: 0, note: pass ? "" : `push=${pushStatus[m.name]}` }); continue; } - if (pushStatus[m.name] !== 204) { + // 204 = stored clean; 200 = stored WITH non-fatal readability findings (geometry lint) — + // both mean the board exists and must render. + if (pushStatus[m.name] !== 204 && pushStatus[m.name] !== 200) { rows.push({ ...m, pass: false, stuck: false, signal: false, errBlock: false, errs: 0, note: `unexpected push reject (${pushStatus[m.name]})` }); continue; } const before = errors.length; let stuck = false, signal = false, errBlock = false, note = ""; try { - await p.locator("#scopes button", { hasText: `stress/${m.name}` }).click(); + // Match on the title attribute (like rich.e2e): the sidebar renders project/agent as + // separate spans, so the button's inner text no longer contains the literal "proj/agent". + await p.locator(`#scopes button[title="stress/${m.name}"]`).click(); await p.waitForFunction( ({ sig, re }) => { const d = document.querySelector("#diagram"); diff --git a/packages/viewer/test/server.test.ts b/packages/viewer/test/server.test.ts index aa311d0..fae609e 100644 --- a/packages/viewer/test/server.test.ts +++ b/packages/viewer/test/server.test.ts @@ -242,6 +242,28 @@ describe("viewer server", () => { expect(await nullEdge.text()).toContain("flow.edges[0] must be an object"); }); + it("stress: validates and stores a near-cap (~900 KB) component push within the request budget", async () => { + // The validator sits on the push hot path — a payload near MAX_BODY must round-trip fast. + // ~600 KB of content — near the cap once JSON-escaped into the push body (content is a string + // field, so every quote costs an extra byte on the wire; the wire body must stay < MAX_BODY). + const children = Array.from({ length: 6_000 }, (_, i) => ({ + type: "Card", + props: { withBorder: true }, + children: [{ type: "Text", children: `card ${i} — filler text to grow the payload toward the 1 MB cap` }], + })); + const content = JSON.stringify({ type: "Stack", children }); + expect(content.length).toBeGreaterThan(500_000); + const t0 = performance.now(); + const ok = await push({ project: "p", agent: "big", type: "component", content }); + expect(ok.status).toBe(204); + expect(performance.now() - t0).toBeLessThan(5000); + // …and one unknown component buried at the end still comes back as a path-pointed 400. + children[5_999] = { type: "Buton", props: { withBorder: true }, children: [] } as never; + const bad = await push({ project: "p", agent: "big", type: "component", content: JSON.stringify({ type: "Stack", children }) }); + expect(bad.status).toBe(400); + expect(await bad.text()).toContain('unknown component "Buton"'); + }); + it("responds 200 ok on /healthz", async () => { const r = await fetch(`${base}/healthz`); expect(r.status).toBe(200); diff --git a/packages/viewer/test/validate.test.ts b/packages/viewer/test/validate.test.ts index 54e2018..49fe20d 100644 --- a/packages/viewer/test/validate.test.ts +++ b/packages/viewer/test/validate.test.ts @@ -156,6 +156,46 @@ describe("flow structure", () => { }); }); +// The validator runs on every push (server hot path) and must stay linear: a payload near the +// 1 MB body cap has to validate in milliseconds, not melt the event loop, and duplicate/ref +// checks must not go quadratic on graph size. +describe("validator stress (payloads near the 1 MB body cap)", () => { + it("validates a 10k-node / 20k-edge flow quickly, and still catches a dangling edge at the end", () => { + const nodes = Array.from({ length: 10_000 }, (_, i) => ({ id: `n${i}`, data: { label: `Node ${i}` } })); + const edges = Array.from({ length: 20_000 }, (_, i) => ({ source: `n${i % 10_000}`, target: `n${(i * 7 + 1) % 10_000}` })); + const t0 = performance.now(); + expect(validateContent("flow", JSON.stringify({ nodes, edges }))).toBeNull(); + const bad = JSON.stringify({ nodes, edges: [...edges, { source: "n0", target: "ghost" }] }); + expect(validateContent("flow", bad)).toContain('"ghost"'); + expect(performance.now() - t0).toBeLessThan(2000); + }); + it("validates a wide ~1 MB component tree quickly (10k siblings incl. node-valued props)", () => { + const children = Array.from({ length: 10_000 }, (_, i) => ({ + type: "Card", + props: { withBorder: true, icon: { type: "ThemeIcon", children: "!" } }, + children: [{ type: "Text", children: `card ${i} — some longer filler text to grow the payload toward the cap` }], + })); + const content = JSON.stringify({ type: "Stack", children }); + expect(content.length).toBeGreaterThan(900_000); // representative of the MAX_BODY ceiling + const t0 = performance.now(); + expect(validateContent("component", content)).toBeNull(); + expect(performance.now() - t0).toBeLessThan(2000); + // one unknown component buried at the end is still caught, path-pointed + children.push({ type: "Buton", props: { withBorder: true, icon: { type: "ThemeIcon", children: "!" } }, children: [] } as never); + expect(validateContent("component", JSON.stringify({ type: "Stack", children }))).toContain('unknown component "Buton"'); + }); + it("validates a 5k-row datatable and a 200-deep pane nesting without blowing the stack", () => { + const rows = Array.from({ length: 5_000 }, (_, i) => [`r${i}`, i, i % 2 === 0]); + expect(validateContent("datatable", JSON.stringify({ columns: ["a", "b", "c"], rows }))).toBeNull(); + // Pane content is a JSON *string*, so each nesting level re-escapes the one below — length + // grows exponentially, which is why MAX_DEPTH can never be reached by a sub-cap payload. + // 10 levels is already a far deeper composition than any real dashboard. + let panes = JSON.stringify({ panes: [{ type: "markdown", content: "leaf" }] }); + for (let i = 0; i < 10; i++) panes = JSON.stringify({ panes: [{ type: "panes", content: panes }] }); + expect(validateContent("panes", panes)).toBeNull(); + }); +}); + describe("vegalite composition", () => { it("rejects a spec with nothing to draw", () => { const err = validateContent("vegalite", JSON.stringify({ data: { values: [1] } })); From 9f675714a771c1075bf81da968f3b430f22bda4b Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Sat, 4 Jul 2026 00:24:36 +0000 Subject: [PATCH 3/3] refactor: single-source the component-node predicate + literal-typed whitelists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify pass over the branch: - isComponentNodeLike now lives ONLY in core and the client resolver imports it (its isComponentNode was a hand-kept copy the parity test couldn't guard); RESOLVE_MAX_DEPTH likewise imports core's MAX_DEPTH instead of mirroring it. - KNOWN_PUSH_TYPES / KNOWN_COMPONENTS are as-const tuples exporting KnownPushType / KnownComponent; the client LOADERS map is keyed by KnownPushType and REGISTRY satisfies Record — list drift is now a compile error at the offending line, with the runtime parity test kept as a JS-level backstop. - Deleted registerComponent (no callers; post-validation any runtime-registered name would be rejected at push before it could render — a dead escape hatch contradicting the closed-world contract). - Hot-path trims: supported-list strings joined once at module scope; the flow parentId second pass skips when no node carries one; edge endpoint checks unrolled via a helper instead of an array-literal loop per edge; node-valued array props validated through the tree walker's own array handling instead of a duplicated index loop. - Test dedup: dropped two spot-check list tests subsumed by known-parity, and the server stress test's timing bound (owned by the unit stress test). Suites: viewer 526, cli 173, rich e2e 62/62 after the refactor. --- packages/core/src/validate.ts | 91 +++++++++++-------- packages/viewer/src/client/registry.ts | 8 +- .../client/renderers/component-resolver.tsx | 35 +++---- packages/viewer/test/server.test.ts | 4 +- packages/viewer/test/validate.test.ts | 9 +- 5 files changed, 75 insertions(+), 72 deletions(-) diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 7e452d5..4bcf1fd 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -21,17 +21,19 @@ export const MAX_DEPTH = 256; * types like "datatabel", pane types like "Panel"/"Image"). The parity test * (viewer/test/known-parity.test.ts) keeps this list and the client registry in lockstep. */ -export const KNOWN_PUSH_TYPES: string[] = [ +export const KNOWN_PUSH_TYPES = [ "text", "ansi", "mermaid", "markdown", "panes", "component", "vegalite", "flow", "calltree", "datatable", -]; -const KNOWN_TYPE_SET = new Set(KNOWN_PUSH_TYPES); +] as const; +export type KnownPushType = (typeof KNOWN_PUSH_TYPES)[number]; +const KNOWN_TYPE_SET = new Set(KNOWN_PUSH_TYPES); +const KNOWN_PUSH_TYPES_LIST = KNOWN_PUSH_TYPES.join(", "); /** * The client component-resolver's whitelist (component-resolver.tsx REGISTRY). A node with any * other `type` renders as a visible "[unknown component: X]" marker — observed in the wild: * "Button", "MapLink", "ListItem" (for "List.Item"). Kept in lockstep by the parity test. */ -export const KNOWN_COMPONENTS: string[] = [ +export const KNOWN_COMPONENTS = [ // layout "Stack", "Group", "Grid", "Grid.Col", "SimpleGrid", "Paper", "Card", "Card.Section", "Box", "Divider", "Center", "ScrollArea", @@ -46,24 +48,29 @@ export const KNOWN_COMPONENTS: string[] = [ "Accordion", "Accordion.Item", "Accordion.Control", "Accordion.Panel", // custom nodes (charts, map, interactive checklist, video card, masonry gallery) "VegaLite", "Map", "Checklist", "Video", "Masonry", -]; -const KNOWN_COMPONENT_SET = new Set(KNOWN_COMPONENTS); +] as const; +export type KnownComponent = (typeof KNOWN_COMPONENTS)[number]; +const KNOWN_COMPONENT_SET = new Set(KNOWN_COMPONENTS); +const KNOWN_COMPONENTS_LIST = KNOWN_COMPONENTS.join(", "); // Canonical name by lowercased, dot-stripped form, for "did you mean" (ListItem → List.Item). -const COMPONENT_BY_LOOSE = new Map(KNOWN_COMPONENTS.map((c) => [c.toLowerCase().replace(/\./g, ""), c])); +const loose = (s: string): string => s.toLowerCase().replace(/\./g, ""); +const COMPONENT_BY_LOOSE = new Map(KNOWN_COMPONENTS.map((c) => [loose(c), c])); const componentHint = (type: string): string => { - const close = COMPONENT_BY_LOOSE.get(type.toLowerCase().replace(/\./g, "")); + const close = COMPONENT_BY_LOOSE.get(loose(type)); return close ? ` (did you mean "${close}"?)` : ""; }; -/** Mirrors the client resolver's isComponentNode: a prop value shaped like this WILL be resolved - * as a component node in the browser (and an unknown type rendered as a visible marker), so the - * validator must walk exactly the same values — no more (data-shaped objects like Vega encodings - * or Table `data` must pass through untouched), no less. */ -const isComponentNodeLike = (v: unknown): v is Record => - typeof v === "object" && v !== null && !Array.isArray(v) && - typeof (v as Record).type === "string" && - (KNOWN_COMPONENT_SET.has((v as Record).type as string) || "props" in v || "children" in v); +/** The single definition of "a value the component renderer treats as a component node" — a prop + * value shaped like this WILL be resolved as a node in the browser (and an unknown type rendered + * as a visible marker), so the validator walks exactly the same values — no more (data-shaped + * objects like Vega encodings or Table `data` pass through untouched), no less. The client + * resolver imports this predicate rather than keeping its own copy, so the two can't drift. */ +export const isComponentNodeLike = (v: unknown): v is Record => { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + const t = (v as Record).type; + return typeof t === "string" && (KNOWN_COMPONENT_SET.has(t) || "props" in v || "children" in v); +}; /** Validate a `component` node tree. Returns an error message (with a path) or null. */ export function validateComponentTree(node: unknown, path = "root", depth = 0): string | null { @@ -82,7 +89,7 @@ export function validateComponentTree(node: unknown, path = "root", depth = 0): if (!KNOWN_COMPONENT_SET.has(n.type)) return ( `${path}: unknown component "${n.type}"${componentHint(n.type)} — the viewer renders it as a ` + - `visible "[unknown component]" marker. Supported components: ${KNOWN_COMPONENTS.join(", ")}` + `visible "[unknown component]" marker. Supported components: ${KNOWN_COMPONENTS_LIST}` ); if (n.props !== undefined && (typeof n.props !== "object" || n.props === null || Array.isArray(n.props))) return `${path}(${n.type}).props must be an object`; @@ -93,14 +100,12 @@ export function validateComponentTree(node: unknown, path = "root", depth = 0): const props = n.props as Record; for (const k in props) { const v = props[k]; - if (isComponentNodeLike(v)) { - const e = validateComponentTree(v, `${path}(${n.type}).props.${k}`, depth + 1); + // The all-or-nothing `every` gate on arrays mirrors the client exactly: a mixed array is + // treated as plain data there, so it must pass through untouched here too. Recursing on the + // whole value (object or array) reuses the tree walker's own array handling for paths/depth. + if (isComponentNodeLike(v) || (Array.isArray(v) && v.length > 0 && v.every(isComponentNodeLike))) { + const e = validateComponentTree(v, `${path}(${n.type}).props.${k}`, depth); if (e) return e; - } else if (Array.isArray(v) && v.length > 0 && v.every(isComponentNodeLike)) { - for (let i = 0; i < v.length; i++) { - const e = validateComponentTree(v[i], `${path}(${n.type}).props.${k}[${i}]`, depth + 1); - if (e) return e; - } } } } @@ -286,6 +291,9 @@ export function mermaidErrors(text: string): string[] { // A Vega-Lite spec draws something only if it has a view-defining key; without one, vega-embed // throws in the browser ("vega-lite render error") after the server already said 204. const VEGA_VIEW_KEYS = ["mark", "layer", "facet", "hconcat", "vconcat", "concat", "repeat", "spec"]; +// The JSON-content types validateContent inspects structurally (everything renderable except the +// freeform text types and mermaid, which is linted as source text above). +const STRUCTURED_TYPES = new Set(["component", "panes", "flow", "vegalite", "calltree", "datatable"]); /** Validate `content` for a given push `type`. Returns an error message or null (valid / freeform). * `depth` bounds nested-panes recursion (a pane's content can itself be a `panes` payload). */ @@ -295,12 +303,11 @@ export function validateContent(type: string, content: string, depth = 0): strin if (!KNOWN_TYPE_SET.has(type)) return ( `unknown type "${type}" — the viewer has no renderer for it and would show a ` + - `"[unsupported type]" block. Supported types: ${KNOWN_PUSH_TYPES.join(", ")}` + `"[unsupported type]" block. Supported types: ${KNOWN_PUSH_TYPES_LIST}` ); // mermaid is NOT freeform — validate against the renderable subset (header + supported constructs). if (type === "mermaid") return mermaidErrors(content)[0] ?? null; - if (type !== "component" && type !== "panes" && type !== "flow" && type !== "vegalite" && type !== "calltree" && type !== "datatable") - return null; + if (!STRUCTURED_TYPES.has(type)) return null; // text/ansi/markdown are genuinely freeform if (depth > MAX_DEPTH) return `panes nested too deeply (> ${MAX_DEPTH} levels)`; let v: unknown; try { @@ -328,6 +335,7 @@ export function validateContent(type: string, content: string, depth = 0): strin const ids = new Set(); if (v.nodes !== undefined) { if (!Array.isArray(v.nodes)) return "flow.nodes must be an array"; + let hasParentIds = false; for (let i = 0; i < v.nodes.length; i++) { const n = v.nodes[i]; if (!isObj(n)) return `flow.nodes[${i}] must be an object`; @@ -335,26 +343,31 @@ export function validateContent(type: string, content: string, depth = 0): strin return `flow.nodes[${i}].id must be a non-empty string (got ${JSON.stringify(n.id)})`; if (ids.has(n.id)) return `flow.nodes[${i}].id "${n.id}" is a duplicate — node ids must be unique`; ids.add(n.id); + if (n.parentId !== undefined) hasParentIds = true; } - // Second pass so a parent declared after its child still resolves. - for (let i = 0; i < v.nodes.length; i++) { - const n = v.nodes[i] as Record; - if (n.parentId !== undefined && (typeof n.parentId !== "string" || !ids.has(n.parentId))) - return `flow.nodes[${i}].parentId ${JSON.stringify(n.parentId)} does not match any node id`; + // Second pass (only when needed) so a parent declared after its child still resolves. + if (hasParentIds) { + for (let i = 0; i < v.nodes.length; i++) { + const n = v.nodes[i] as Record; + if (n.parentId !== undefined && (typeof n.parentId !== "string" || !ids.has(n.parentId))) + return `flow.nodes[${i}].parentId ${JSON.stringify(n.parentId)} does not match any node id`; + } } } if (v.edges !== undefined) { if (!Array.isArray(v.edges)) return "flow.edges must be an array"; + const endpointError = (i: number, k: string, ep: unknown): string | null => { + if (typeof ep !== "string" || ep.length === 0) + return `flow.edges[${i}].${k} must be a non-empty string (a node id, got ${JSON.stringify(ep)})`; + if (!ids.has(ep)) + return `flow.edges[${i}].${k} "${ep}" does not match any node id — the renderer would silently drop this edge`; + return null; + }; for (let i = 0; i < v.edges.length; i++) { const e = v.edges[i]; if (!isObj(e)) return `flow.edges[${i}] must be an object`; - for (const k of ["source", "target"] as const) { - const ep = e[k]; - if (typeof ep !== "string" || ep.length === 0) - return `flow.edges[${i}].${k} must be a non-empty string (a node id, got ${JSON.stringify(ep)})`; - if (!ids.has(ep)) - return `flow.edges[${i}].${k} "${ep}" does not match any node id — the renderer would silently drop this edge`; - } + const err = endpointError(i, "source", e.source) ?? endpointError(i, "target", e.target); + if (err) return err; } } return null; diff --git a/packages/viewer/src/client/registry.ts b/packages/viewer/src/client/registry.ts index 0896209..ff8b457 100644 --- a/packages/viewer/src/client/registry.ts +++ b/packages/viewer/src/client/registry.ts @@ -1,5 +1,6 @@ import type { Mount, MountResult } from "./renderers/types.js"; import type { PatchOp } from "../flow-patch.js"; +import type { KnownPushType } from "@ivanmkc/termchart-core"; import { unsupportedBlock, errorBlock } from "./renderers/errors.js"; export type { Mount, MountResult } from "./renderers/types.js"; @@ -8,8 +9,11 @@ export type { Mount, MountResult } from "./renderers/types.js"; * Lazy renderer loaders — one dynamic import() per type. esbuild code-splits each into * its own chunk, so heavy libraries (mermaid, React/Mantine, vega, React Flow) load only * when a payload of that type first appears. Add a type here + a module under renderers/. + * Keyed by core's KnownPushType (what the server validator accepts), so adding a renderer + * without whitelisting it — or vice versa — is a compile error; the known-parity test + * double-checks the same invariant at the JS level. */ -const LOADERS: Record Promise<{ mount: Mount }>> = { +const LOADERS: Record Promise<{ mount: Mount }>> = { text: () => import("./renderers/text.js"), ansi: () => import("./renderers/text.js"), mermaid: () => import("./renderers/mermaid.js"), @@ -80,7 +84,7 @@ export async function renderInto( teardownTree(el); el.replaceChildren(); - const loader = LOADERS[type]; + const loader = (LOADERS as Record Promise<{ mount: Mount }>>)[type]; if (!loader) { el.innerHTML = unsupportedBlock(type, content); return { selfSizing: false }; diff --git a/packages/viewer/src/client/renderers/component-resolver.tsx b/packages/viewer/src/client/renderers/component-resolver.tsx index 88c7641..58fd2a6 100644 --- a/packages/viewer/src/client/renderers/component-resolver.tsx +++ b/packages/viewer/src/client/renderers/component-resolver.tsx @@ -6,6 +6,7 @@ import { Flex, Space, Fieldset, Spoiler, Kbd, Mark, Highlight, Avatar, Indicator, Tooltip, NumberFormatter, Notification, Accordion, } from "@mantine/core"; +import { isComponentNodeLike, MAX_DEPTH, type KnownComponent } from "@ivanmkc/termchart-core"; import { VegaLite } from "./vega-node.js"; import { Map } from "./map-node.js"; import { Checklist } from "./checklist.js"; @@ -22,9 +23,12 @@ export interface ComponentNode { * Curated whitelist of Mantine components the agent may use. Bounds the chunk and the * surface; compound names (e.g. "Grid.Col") map to subcomponents. Charts come from the * `VegaLite` node (added once the vega renderer exists) and the standalone `vegalite` type. + * `satisfies` ties the key set to core's KNOWN_COMPONENTS (what the server validator accepts), + * so a missing or extra entry on either side is a compile error, not a runtime surprise; the + * known-parity test double-checks the same invariant at the JS level. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -const REGISTRY: Record> = { +const REGISTRY = { // layout Stack, Group, Grid, "Grid.Col": Grid.Col, SimpleGrid, Paper, Card, "Card.Section": Card.Section, Box, Divider, Center, ScrollArea, @@ -50,12 +54,8 @@ const REGISTRY: Record> = { Video, // masonry flow (Pinterest/Flipboard) — auto-arranging multi-column cascade; optional swimlanes Masonry, -}; - -/** Register additional components (e.g. VegaLite once the vega renderer is available). */ -export function registerComponent(name: string, comp: ComponentType): void { - REGISTRY[name] = comp; -} + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} satisfies Record>; /** All registered component names — for tests / introspection. */ export const componentNames = (): string[] => Object.keys(REGISTRY); @@ -63,20 +63,14 @@ export const componentNames = (): string[] => Object.keys(REGISTRY); /** True when a value is itself a component node — e.g. an `icon` / `label` / `leftSection` * prop given as a `{type,...}` tree. Such props must be resolved to elements, not passed to * React as raw objects (which throws "Objects are not valid as a React child"). Data-shaped - * objects (a Vega spec, `style`, Table `data`) have no whitelisted `type` and are left alone. */ -function isComponentNode(v: unknown): v is ComponentNode { - return ( - typeof v === "object" && - v !== null && - !Array.isArray(v) && - typeof (v as ComponentNode).type === "string" && - (REGISTRY[(v as ComponentNode).type] !== undefined || "props" in v || "children" in v) - ); -} + * objects (a Vega spec, `style`, Table `data`) have no whitelisted `type` and are left alone. + * The predicate itself lives in core — the SAME one the push validator walks — so the set of + * values resolved here and the set validated at push time cannot drift. */ +const isComponentNode = (v: unknown): v is ComponentNode => isComponentNodeLike(v); -// Bound recursion (matches the server validator's MAX_DEPTH) so a pathological tree degrades to +// Bound recursion (the server validator's own cap, imported) so a pathological tree degrades to // a marker instead of overflowing the stack. Pushed trees are validated first, so this is a backstop. -const RESOLVE_MAX_DEPTH = 256; +const RESOLVE_MAX_DEPTH = MAX_DEPTH; // URL-valued props whose value must not carry an executable scheme. Pushed content is nominally // token-gated, but a read-only /s// share viewer renders it too, so a `javascript:`/`data:` @@ -133,7 +127,8 @@ export function resolve(node: unknown, key?: string | number, depth = 0): ReactN if (typeof node !== "object") return null; const { type, props, children } = node as ComponentNode; - const Comp = REGISTRY[type]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Comp = (REGISTRY as Record>)[type]; if (!Comp) { return (
{`[unknown component: ${type}]`}
diff --git a/packages/viewer/test/server.test.ts b/packages/viewer/test/server.test.ts index fae609e..843c875 100644 --- a/packages/viewer/test/server.test.ts +++ b/packages/viewer/test/server.test.ts @@ -253,10 +253,8 @@ describe("viewer server", () => { })); const content = JSON.stringify({ type: "Stack", children }); expect(content.length).toBeGreaterThan(500_000); - const t0 = performance.now(); const ok = await push({ project: "p", agent: "big", type: "component", content }); - expect(ok.status).toBe(204); - expect(performance.now() - t0).toBeLessThan(5000); + expect(ok.status).toBe(204); // timing bound lives in the unit stress test, where it's isolated // …and one unknown component buried at the end still comes back as a path-pointed 400. children[5_999] = { type: "Buton", props: { withBorder: true }, children: [] } as never; const bad = await push({ project: "p", agent: "big", type: "component", content: JSON.stringify({ type: "Stack", children }) }); diff --git a/packages/viewer/test/validate.test.ts b/packages/viewer/test/validate.test.ts index 49fe20d..1867a52 100644 --- a/packages/viewer/test/validate.test.ts +++ b/packages/viewer/test/validate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateContent, validateComponentTree, KNOWN_PUSH_TYPES, KNOWN_COMPONENTS } from "@ivanmkc/termchart-core"; +import { validateContent, validateComponentTree } from "@ivanmkc/termchart-core"; describe("validateComponentTree", () => { it("passes a well-formed tree", () => { @@ -89,10 +89,6 @@ describe("unknown push types", () => { expect(err).toContain("panes[0]"); expect(err).toContain('unknown type "Panel"'); }); - it("exports the full renderable set", () => { - for (const t of ["text", "ansi", "mermaid", "markdown", "panes", "component", "vegalite", "flow", "calltree", "datatable"]) - expect(KNOWN_PUSH_TYPES).toContain(t); - }); }); // The component renderer resolves node types against a fixed whitelist; an unknown name renders as @@ -128,9 +124,6 @@ describe("component whitelist", () => { const tableData = { type: "Table", props: { data: { head: ["a"], body: [["1"]] } } }; expect(validateComponentTree(tableData)).toBeNull(); }); - it("exports the whitelist for client parity", () => { - for (const c of ["Stack", "Table.Td", "Checklist", "Masonry", "VegaLite"]) expect(KNOWN_COMPONENTS).toContain(c); - }); }); // React Flow needs string node ids and edges whose endpoints exist; violations render a broken or