@tangle-network/ui 11.51.7 → 11.51.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -0
  3. package/dist/chat/chat-message.js +2 -7
  4. package/dist/chat/user-message.js +3 -5
  5. package/dist/files/file-card.js +1 -1
  6. package/dist/files/file-tree.d.ts +0 -6
  7. package/dist/files/file-tree.js +2 -6
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +2 -2
  10. package/dist/primitives/hover-card.d.ts +22 -13
  11. package/dist/primitives/hover-card.js +149 -40
  12. package/dist/primitives/popover.d.ts +3 -1
  13. package/dist/primitives/popover.js +15 -12
  14. package/dist/primitives/upload-progress.d.ts +0 -6
  15. package/dist/primitives/upload-progress.js +2 -6
  16. package/dist/run/run-group.js +2 -2
  17. package/dist/run/run-item-primitives.js +2 -2
  18. package/dist/run/run-row-shell.js +2 -2
  19. package/dist/utils/format.d.ts +14 -12
  20. package/dist/utils/format.js +50 -30
  21. package/dist/utils.d.ts +2 -2
  22. package/dist/utils.js +2 -2
  23. package/package.json +11 -2
  24. package/src/chat/chat-message.tsx +2 -7
  25. package/src/chat/user-message.tsx +3 -5
  26. package/src/editor/source-markdown-editor.test.tsx +7 -7
  27. package/src/files/file-tree.tsx +2 -7
  28. package/src/primitives/hover-card.tsx +64 -74
  29. package/src/primitives/popover.tsx +3 -3
  30. package/src/primitives/upload-progress.tsx +2 -7
  31. package/src/utils/format.ts +50 -33
  32. package/src/utils/index.ts +1 -1
  33. package/src/editor/document-editor-pane.test.tsx +0 -60
  34. package/src/editor/editor-lazy.test.tsx +0 -102
  35. package/src/editor/editor-peers.test.ts +0 -250
  36. package/src/editor/editor-provider.test.tsx +0 -226
  37. package/src/editor/markdown-document-editor.test.tsx +0 -126
  38. package/src/editor/tiptap-editor.test.tsx +0 -228
  39. package/src/files/file-artifact-pane.test.tsx +0 -301
  40. package/src/files/file-card.test.tsx +0 -62
  41. package/src/files/file-list.test.tsx +0 -81
  42. package/src/files/pdf-loader.test.ts +0 -130
  43. package/src/files/pdf-viewer.test.tsx +0 -245
  44. package/src/files/rich-file-tree.test.tsx +0 -90
  45. package/src/markdown/api-presentation.test.tsx +0 -63
  46. package/src/markdown/code-block.test.tsx +0 -201
  47. package/src/markdown/markdown-table.test.tsx +0 -76
  48. package/src/markdown/markdown.test.tsx +0 -166
  49. package/src/openui/schema.test.tsx +0 -158
  50. package/src/primitives/button.test.tsx +0 -230
  51. package/src/primitives/card.test.tsx +0 -71
  52. package/src/primitives/chip.test.tsx +0 -200
  53. package/src/primitives/date-picker.test.tsx +0 -146
  54. package/src/primitives/dialog.test.tsx +0 -75
  55. package/src/primitives/input.test.tsx +0 -161
  56. package/src/primitives/interaction-states.test.tsx +0 -287
  57. package/src/primitives/pagination-alert.test.tsx +0 -180
  58. package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +0 -165
  59. package/src/primitives/radio-group.test.tsx +0 -26
  60. package/src/primitives/segmented-control.test.tsx +0 -451
  61. package/src/primitives/table.test.tsx +0 -72
  62. package/src/primitives/theme-toggle.test.tsx +0 -168
  63. package/src/primitives/toast.test.tsx +0 -130
  64. package/src/primitives/wizard.test.tsx +0 -106
  65. package/src/run/expandable-content.test.tsx +0 -52
  66. package/src/utils/format.test.ts +0 -58
@@ -1,201 +0,0 @@
1
- import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
- import { renderToString } from "react-dom/server";
3
- import { afterEach, describe, expect, it, vi } from "vitest";
4
- import { CodeBlock, CopyButton } from "./code-block";
5
-
6
- const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
7
-
8
- afterEach(() => {
9
- cleanup();
10
- vi.restoreAllMocks();
11
- if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard);
12
- else delete (navigator as { clipboard?: unknown }).clipboard;
13
- });
14
-
15
- describe("CodeBlock scoped semantic colors", () => {
16
- it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
17
- // Shiki stops tokenizing a line after 500ms and paints the rest in the last
18
- // token's color. The first pass over a cold grammar also compiles its
19
- // patterns, which on a loaded host can take longer than that. Warm the
20
- // grammar first: this test checks the color mapping, not cold-start time.
21
- const warm = render(<CodeBlock code={'const message = "hello";'} language="javascript" />);
22
- await waitFor(() => expect(warm.container.querySelector("[data-highlighted]")).not.toBeNull(), { timeout: 10_000 });
23
- warm.unmount();
24
- const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
25
- // Plain code already carries its line numbers, so highlighting shifts no layout.
26
- expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
27
- // The highlighter loads asynchronously; under a full parallel run it can take longer than waitFor's 1s default.
28
- await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"), { timeout: 10_000 });
29
- expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
30
- expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
31
- expect(container.querySelector("[data-line-number]")).not.toBeNull();
32
- expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
33
- });
34
-
35
- it("inherits a named scope by default without inserting its own mode", () => {
36
- const { container } = render(<section data-theme="arena-light"><CodeBlock data-testid="code" code="const x = 1" language="javascript" /></section>);
37
- const code = screen.getByTestId("code");
38
- expect(code.hasAttribute("data-theme")).toBe(false);
39
- const before = code.innerHTML;
40
- container.querySelector("section")!.setAttribute("data-theme", "arena");
41
- expect(code.innerHTML).toBe(before); // References stay live; the browser fixture checks the computed colors.
42
- });
43
-
44
- it.each([true, false])("preserves explicit light=%s as an opposite nested mode", (light) => {
45
- render(<section data-theme={light ? "dark" : "light"}><CodeBlock data-testid="code" code="x" light={light} /></section>);
46
- expect(screen.getByTestId("code").getAttribute("data-theme")).toBe(light ? "light" : "dark");
47
- });
48
-
49
- it("preserves caller attributes, labels, children and custom scopes", () => {
50
- render(<CodeBlock data-testid="code" data-theme="aubergine" className="caller" code="echo hello" language="bash" label="BASHRC"><button>Copy example</button></CodeBlock>);
51
- expect(screen.getByTestId("code").getAttribute("data-theme")).toBe("aubergine");
52
- expect(screen.getByTestId("code").classList.contains("caller")).toBe(true);
53
- expect(screen.getByText("BASHRC")).toBeTruthy();
54
- expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
55
- });
56
-
57
- it("server-renders readable code with the same semantic references independent of document theme", () => {
58
- const source = <CodeBlock code="const x = 1" language="javascript" />;
59
- document.documentElement.className = "dark";
60
- const dark = renderToString(source);
61
- expect(dark).toContain("const x = 1");
62
- expect(dark).toContain("var(--syntax-foreground, currentColor)");
63
- document.documentElement.className = "light";
64
- expect(renderToString(source)).toBe(dark);
65
- document.documentElement.removeAttribute("class");
66
- });
67
- });
68
-
69
- // Retain the existing header/label/copy-slot contracts alongside theme coverage.
70
- describe("CodeBlock", () => {
71
- it("renders the code through the syntax highlighter", () => {
72
- const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
73
- expect(container.querySelector("code")).not.toBeNull();
74
- });
75
-
76
- it.each(["javascript", "js", "ts", "tsx"])("highlights %s with Shiki into Brand syntax tokens", async (language) => {
77
- const code = "const answer = 42; // the answer";
78
- const { container } = render(<CodeBlock code={code} language={language} />);
79
- await waitFor(() => expect(container.querySelector("[data-highlighted]")).not.toBeNull(), { timeout: 10_000 });
80
- const tokens = Array.from(container.querySelectorAll("pre code span span"));
81
- expect(tokens.length).toBeGreaterThan(3);
82
- expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)");
83
- expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
84
- expect(container.innerHTML).not.toContain("--shiki-");
85
- expect(container.querySelector("pre code")?.textContent).toBe(code);
86
- });
87
-
88
- it.each(["brainfuck", "not-a-language", undefined])("keeps %s as readable plain text", (language) => {
89
- const code = "++[>+++<-]";
90
- const { container } = render(<CodeBlock code={code} language={language} />);
91
- expect(container.querySelector("[data-highlighted]")).toBeNull();
92
- expect(container.querySelector("pre code")?.textContent).toBe(code);
93
- });
94
-
95
- it("preserves whitespace and numbers every line", async () => {
96
- const code = "\tfirst \n second\n";
97
- const { container } = render(<CodeBlock code={code} language="text" showLineNumbers />);
98
- const numbers = Array.from(container.querySelectorAll("[data-line-number]"), (node) => node.textContent);
99
- expect(numbers).toEqual(["1", "2", "3"]);
100
- const text = Array.from(container.querySelectorAll("pre code > span"), (line) =>
101
- Array.from(line.childNodes).filter((node) => !(node instanceof HTMLElement && node.hasAttribute("data-line-number")))
102
- .map((node) => node.textContent).join("")).join("");
103
- expect(text).toBe(code);
104
- });
105
-
106
- it("names the file in the header beside the language", () => {
107
- render(<CodeBlock code={"export {}"} language="ts" filename="src/lib/ctr.ts" />);
108
- expect(screen.getByText("src/lib/ctr.ts")).toBeInTheDocument();
109
- expect(screen.getByText("TypeScript")).toBeInTheDocument();
110
- expect(screen.getByRole("region", { name: "src/lib/ctr.ts code" })).toBeInTheDocument();
111
- });
112
-
113
- it("uses `label` as the header text, overriding `language`", () => {
114
- const { getByText, queryByText } = render(
115
- <CodeBlock code={"x"} language="typescript" label="config.ts" />,
116
- );
117
- expect(getByText("config.ts")).toBeInTheDocument();
118
- expect(queryByText("typescript")).toBeNull();
119
- });
120
-
121
- it("falls back to the language's name for the header when no `label` is given", () => {
122
- const { getByText } = render(<CodeBlock code={"x"} language="python" />);
123
- expect(getByText("Python")).toBeInTheDocument();
124
- });
125
-
126
- it.each(["text", "TXT", "plain", "plaintext"])("shows no %s label on a plain-text fence, only its copy control", (language) => {
127
- const { container, queryByText } = render(
128
- <CodeBlock code={"x"} language={language}>
129
- <CopyButton text="x" />
130
- </CodeBlock>,
131
- );
132
- expect(container.querySelector(".border-b")).toBeNull();
133
- expect(queryByText(language)).toBeNull();
134
- expect(container.querySelector('button[title="Copy to clipboard"]')).not.toBeNull();
135
- });
136
-
137
- it("renders no header when neither label nor language is set", () => {
138
- const { container } = render(<CodeBlock code={"x"} />);
139
- // The header is the only element carrying a bottom border.
140
- expect(container.querySelector(".border-b")).toBeNull();
141
- });
142
-
143
- it("treats an explicit empty label as 'no header', even with a language", () => {
144
- const { container, queryByText } = render(
145
- <CodeBlock code={"x"} language="python" label="" />,
146
- );
147
- expect(container.querySelector(".border-b")).toBeNull();
148
- expect(queryByText("python")).toBeNull();
149
- });
150
-
151
- it("places children in the header when a header is shown", () => {
152
- const { container } = render(
153
- <CodeBlock code={"x"} label="run.ts">
154
- <CopyButton text="x" />
155
- </CodeBlock>,
156
- );
157
- const copyButton = container.querySelector('button[title="Copy to clipboard"]');
158
- expect(copyButton).not.toBeNull();
159
- // Inside the bordered header, not the absolute hover overlay.
160
- expect(copyButton?.closest(".border-b")).not.toBeNull();
161
- expect(copyButton?.closest(".absolute")).toBeNull();
162
- });
163
-
164
- it("moves children to a hover overlay when no header is shown", () => {
165
- const { container } = render(
166
- <CodeBlock code={"x"}>
167
- <CopyButton text="x" />
168
- </CodeBlock>,
169
- );
170
- const copyButton = container.querySelector('button[title="Copy to clipboard"]');
171
- expect(copyButton).not.toBeNull();
172
- expect(copyButton?.closest(".absolute")).not.toBeNull();
173
- expect(copyButton?.closest(".border-b")).toBeNull();
174
- });
175
-
176
- it("names the copy button, keeps it out of forms, and announces the copy", async () => {
177
- const writeText = vi.fn().mockResolvedValue(undefined);
178
- Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
179
- render(<CopyButton text="secret" />);
180
- const button = screen.getByRole("button", { name: "Copy to clipboard" });
181
- expect(button.getAttribute("type")).toBe("button");
182
- const status = screen.getByRole("status");
183
- expect(status.getAttribute("aria-live")).toBe("polite");
184
- expect(status.textContent).toBe("");
185
- fireEvent.click(button);
186
- await waitFor(() => expect(status.textContent).toBe("Copied to clipboard"));
187
- expect(writeText).toHaveBeenCalledWith("secret");
188
- expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
189
- });
190
-
191
- it("announces a failed copy", async () => {
192
- const writeText = vi.fn().mockRejectedValue(new Error("denied"));
193
- Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
194
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
195
- render(<CopyButton text="secret" />);
196
- fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
197
- await waitFor(() => expect(screen.getByRole("status").textContent).toBe("Copy failed"));
198
- expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeTruthy();
199
- warn.mockRestore();
200
- });
201
- });
@@ -1,76 +0,0 @@
1
- import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
2
- import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { Markdown } from "./markdown";
4
- import { columnKinds, numericColumns, tableCsv } from "./markdown-table";
5
-
6
- const TABLE = [
7
- "| Variant | Impressions | CTR % | Spend $ | Note |",
8
- "| --- | --- | --- | --- | --- |",
9
- "| Hero A | 12,400 | 2.50% | $182.40 | Wins on \"reach\", barely |",
10
- "| Hero B | 9,870 | 4.17% | $160.05 | =SUM(A1) |",
11
- ].join("\n");
12
-
13
- afterEach(() => {
14
- cleanup();
15
- vi.restoreAllMocks();
16
- });
17
-
18
- describe("Markdown tables", () => {
19
- it("renders a header row and right-aligns figures in tabular numerals", () => {
20
- render(<Markdown>{TABLE}</Markdown>);
21
- const table = screen.getByRole("table");
22
- const headers = within(table).getAllByRole("columnheader");
23
- expect(headers.map((header) => header.textContent)).toEqual(["Variant", "Impressions", "CTR %", "Spend $", "Note"]);
24
- expect(headers[0].className).toContain("text-left");
25
- expect(headers[1].className).toContain("text-right");
26
- // Inside `.tangle-prose`, a vendored header rule cannot left-align a figure column.
27
- expect(headers[1].className).toContain("[.tangle-prose_&]:text-right!");
28
- const cells = within(table).getAllByRole("cell");
29
- expect(cells[1].className).toContain("text-right");
30
- expect(cells[1].className).toContain("tabular-nums");
31
- expect(cells[0].className).not.toContain("text-right");
32
- expect(cells[0].className).toContain("whitespace-nowrap");
33
- expect(cells[4].className).not.toContain("tabular-nums");
34
- });
35
-
36
- it("scrolls inside a named, focusable region and never uses a tiny font", () => {
37
- render(<Markdown>{TABLE}</Markdown>);
38
- const region = screen.getByRole("region", { name: "Table" });
39
- expect(region.className).toContain("overflow-x-auto");
40
- expect(region.getAttribute("tabindex")).toBe("0");
41
- expect(region.closest(".not-prose")).not.toBeNull();
42
- expect(screen.getByRole("table").className).toContain("text-sm");
43
- // Brand's raw-table prose rules skip the card, so the table never becomes its own scroller.
44
- expect(screen.getByRole("table").hasAttribute("data-markdown-table")).toBe(true);
45
- expect(document.body.innerHTML).not.toContain("text-xs");
46
- });
47
-
48
- it("contains its screen-reader status line, so a table deep in a scrolling panel cannot stretch the page", () => {
49
- render(<Markdown>{TABLE}</Markdown>);
50
- const status = screen.getByRole("status");
51
- expect(status.className).toContain("sr-only");
52
- // The status is absolutely positioned; the card it sits in must be its containing block.
53
- const card = status.closest(".not-prose") as HTMLElement;
54
- expect(card.className.split(" ")).toEqual(expect.arrayContaining(["relative", "overflow-hidden"]));
55
- });
56
-
57
- it("copies the table as CSV with quoting and formula-safe cells", async () => {
58
- const writeText = vi.fn().mockResolvedValue(undefined);
59
- Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
60
- render(<Markdown>{TABLE}</Markdown>);
61
- fireEvent.click(screen.getByRole("button", { name: "Copy CSV" }));
62
- await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
63
- expect(writeText.mock.calls[0][0]).toBe([
64
- "Variant,Impressions,CTR %,Spend $,Note",
65
- "Hero A,\"12,400\",2.50%,$182.40,\"Wins on \"\"reach\"\", barely\"",
66
- "Hero B,\"9,870\",4.17%,$160.05,'=SUM(A1)",
67
- ].join("\n"));
68
- expect(await screen.findByText("Table copied as CSV")).toBeInTheDocument();
69
- });
70
-
71
- it("treats a column as numeric only when every filled cell is a figure", () => {
72
- expect(numericColumns([["a", "1", "", "$2.50"], ["b", "x", "3", "-4%"]])).toEqual([false, false, true, true]);
73
- expect(columnKinds([["Hero A", "1", "A long note that wraps across more than one line"]])).toEqual(["short", "figure", "long"]);
74
- expect(tableCsv([["a,b", " c"]])).toBe('"a,b"," c"');
75
- });
76
- });
@@ -1,166 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { fireEvent, render, screen } from "@testing-library/react";
3
- import { getSanitizedMarkdownHeadingIdFromRawFragment, Markdown } from "./markdown";
4
-
5
- describe("Markdown", () => {
6
- it("uses the tokenized Tangle prose class instead of host typography defaults", () => {
7
- const { container } = render(<Markdown>{"# Heading\n\nBody with `code`."}</Markdown>);
8
- const surface = container.firstElementChild;
9
-
10
- expect(surface).toHaveClass("tangle-prose");
11
- expect(surface).not.toHaveClass("prose");
12
- expect(surface).not.toHaveClass("dark:prose-invert");
13
- });
14
-
15
- it("adds GitHub-style Unicode and duplicate heading IDs before sanitizing them", () => {
16
- const { container } = render(
17
- <Markdown>{"# Duplicate\n\n## Crème brûlée\n\n# Duplicate\n\n# user-content-heading"}</Markdown>,
18
- );
19
-
20
- expect(container.querySelectorAll("h1, h2")).toHaveLength(4);
21
- expect(container.querySelector("h2")).toHaveAttribute("id", "user-content-crème-brûlée");
22
- expect(container.querySelectorAll("h1")[0]).toHaveAttribute("id", "user-content-duplicate");
23
- expect(container.querySelectorAll("h1")[1]).toHaveAttribute("id", "user-content-duplicate-1");
24
- expect(container.querySelectorAll("h1")[2]).toHaveAttribute(
25
- "id",
26
- "user-content-user-content-heading",
27
- );
28
- });
29
-
30
- it("maps raw fragments exactly once to the sanitized target prefix", () => {
31
- expect(getSanitizedMarkdownHeadingIdFromRawFragment("Cr%C3%A8me%20br%C3%BBl%C3%A9e"))
32
- .toBe("user-content-crème-brûlée");
33
- expect(getSanitizedMarkdownHeadingIdFromRawFragment("user-content-heading"))
34
- .toBe("user-content-user-content-heading");
35
- });
36
-
37
- it("maps same-document fragments to sanitized heading IDs", () => {
38
- render(<Markdown>{"[Jump](#Cr%C3%A8me%20br%C3%BBl%C3%A9e)\n\n# Crème brûlée"}</Markdown>);
39
-
40
- expect(screen.getByRole("link", { name: "Jump" })).toHaveAttribute(
41
- "href",
42
- "#user-content-crème-brûlée",
43
- );
44
- });
45
-
46
- it("keeps custom URL transforms behind ReactMarkdown protocol checks", () => {
47
- const urlTransform = vi.fn((url: string) => url);
48
-
49
- render(
50
- <Markdown urlTransform={urlTransform}>
51
- {"[External](https://example.com/docs#install)\n\n![Safe](https://example.com/image.png)\n\n![Unsafe](javascript:alert(1))"}
52
- </Markdown>,
53
- );
54
-
55
- expect(screen.getByRole("link", { name: "External" })).toHaveAttribute(
56
- "href",
57
- "https://example.com/docs#install",
58
- );
59
- expect(screen.getByRole("img", { name: "Safe" })).toHaveAttribute(
60
- "src",
61
- "https://example.com/image.png",
62
- );
63
- expect(screen.getByRole("img", { name: "Image unavailable: Unsafe" })).not.toHaveAttribute("src");
64
- expect(urlTransform).toHaveBeenCalledWith(
65
- "https://example.com/image.png",
66
- "src",
67
- expect.objectContaining({ tagName: "img" }),
68
- );
69
- });
70
-
71
- it("keeps inline code on semantic tokens", () => {
72
- const { container } = render(<Markdown>{"Use `token` values."}</Markdown>);
73
- const inlineCode = container.querySelector("code");
74
-
75
- expect(inlineCode).toHaveClass("border-border");
76
- expect(inlineCode).toHaveClass("bg-muted");
77
- expect(inlineCode).toHaveClass("text-foreground");
78
- });
79
-
80
- it("passes parsed URLs through the typed transform hook", () => {
81
- const urlTransform = vi.fn((url: string, key: string) => (
82
- key === "href" && url === "system/brief.md"
83
- ? "/app/ws-1/vault?file=system%2Fbrief.md"
84
- : url
85
- ));
86
-
87
- render(
88
- <Markdown urlTransform={urlTransform}>
89
- {'[Brief](system/brief.md)'}
90
- </Markdown>,
91
- );
92
-
93
- expect(screen.getByRole("link", { name: "Brief" })).toHaveAttribute(
94
- "href",
95
- "/app/ws-1/vault?file=system%2Fbrief.md",
96
- );
97
- expect(urlTransform).toHaveBeenCalledWith(
98
- "system/brief.md",
99
- "href",
100
- expect.objectContaining({ tagName: "a" }),
101
- );
102
- });
103
-
104
- it("links an image to its full-size source unless a Markdown link already wraps it", () => {
105
- render(
106
- <Markdown>
107
- {"![Ad](https://example.com/ad.png)\n\n[![Badge](https://example.com/badge.svg)](https://example.com/docs)"}
108
- </Markdown>,
109
- );
110
-
111
- const ad = screen.getByRole("img", { name: "Ad" });
112
- expect(ad).toHaveAttribute("src", "https://example.com/ad.png");
113
- expect(ad.closest("a")).toHaveAttribute("href", "https://example.com/ad.png");
114
- expect(ad.closest("a")).toHaveAttribute("target", "_blank");
115
- expect(ad.closest("a")).toHaveAttribute("rel", "noopener noreferrer");
116
-
117
- render(<Markdown>{"![](https://example.com/decorative.png)"}</Markdown>);
118
- expect(screen.getByRole("link", { name: "Open image full size" })).toHaveAttribute(
119
- "href",
120
- "https://example.com/decorative.png",
121
- );
122
-
123
- const badge = screen.getByRole("img", { name: "Badge" });
124
- expect(badge.closest("a")).toHaveAttribute("href", "https://example.com/docs");
125
- expect(badge.closest("a")?.parentElement?.closest("a")).toBeNull();
126
- });
127
-
128
- it("replaces an image that fails to load with a labelled placeholder", () => {
129
- render(<Markdown>{"![Final ad](campaigns/missing.png)"}</Markdown>);
130
-
131
- fireEvent.error(screen.getByRole("img", { name: "Final ad" }));
132
-
133
- expect(screen.queryByRole("img", { name: "Final ad" })).toBeNull();
134
- expect(screen.getByRole("img", { name: "Image unavailable: Final ad" })).toHaveTextContent(
135
- "Image unavailable: Final ad",
136
- );
137
- expect(screen.queryByRole("link")).toBeNull();
138
- });
139
-
140
- it("renders a fenced block's file name and highlights it as that file's language", () => {
141
- render(<Markdown>{'```ts title="src/lib/ctr.ts"\nexport const x = 1;\n```'}</Markdown>);
142
- expect(screen.getByText("src/lib/ctr.ts")).toBeInTheDocument();
143
- expect(screen.getByText("TypeScript")).toBeInTheDocument();
144
- expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument();
145
- });
146
-
147
- it("renders a single-line fence without a language as a block, not inline code", () => {
148
- const { container } = render(<Markdown>{"```\nnpm run build\n```\n\nRun `npm test` too."}</Markdown>);
149
- expect(container.querySelectorAll("pre")).toHaveLength(1);
150
- expect(container.querySelector("pre")?.textContent).toBe("npm run build");
151
- expect(container.querySelector("p code")?.textContent).toBe("npm test");
152
- });
153
-
154
- it("renders links the host classifies as chips that keep their href", () => {
155
- render(
156
- <Markdown linkChip={(href) => href.startsWith("/vault/") ? { kind: "file", label: "REVIEW.md", title: "campaigns/REVIEW.md" } : null}>
157
- {"See [the review](/vault/REVIEW.md) and [docs](https://example.com)."}
158
- </Markdown>,
159
- );
160
- const chip = screen.getByRole("link", { name: "REVIEW.md" });
161
- expect(chip.getAttribute("href")).toBe("/vault/REVIEW.md");
162
- expect(chip.getAttribute("data-link-chip")).toBe("file");
163
- expect(chip.getAttribute("title")).toBe("campaigns/REVIEW.md");
164
- expect(screen.getByRole("link", { name: "docs" }).hasAttribute("data-link-chip")).toBe(false);
165
- });
166
- });
@@ -1,158 +0,0 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { getSanitizedMarkdownHeadingIdFromRawFragment } from "../markdown/markdown";
4
- import { OpenUIArtifactRenderer } from "./openui-artifact-renderer";
5
- import { OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, validateOpenUIJsonArtifact, validateOpenUIJsonNode } from "./schema";
6
-
7
- const validPage = {
8
- type: "card",
9
- title: "Release proof",
10
- badge: { type: "badge", label: "Ready", tone: "success" },
11
- children: [
12
- { type: "heading", text: "Result", level: 2 },
13
- { type: "text", text: "Three checks passed." },
14
- { type: "stack", children: [
15
- { type: "stat", label: "Checks", value: "3" },
16
- { type: "key_value", items: [{ label: "Allowed", value: true }, { label: "Missing", value: null }] },
17
- { type: "code", code: "ok", language: "text" },
18
- { type: "markdown", content: "**Done**" },
19
- { type: "table", columns: [{ key: "name", header: "Name" }], rows: [{ name: "one" }] },
20
- { type: "actions", actions: [{ id: "open", label: "Open" }] },
21
- { type: "separator" },
22
- { type: "grid", children: [{ type: "badge", label: "Nested" }] },
23
- ] },
24
- ],
25
- };
26
-
27
- type JsonSchema = Record<string, unknown>;
28
-
29
- /**
30
- * Model tool-schema converters accept a recursive `$ref` only when the loop can
31
- * terminate: it must pass through an optional property or an array that may be
32
- * empty. Return the first `$defs` path that recurses through required,
33
- * nonempty values only.
34
- */
35
- function findRequiredRefLoop(defs: Record<string, JsonSchema>): string | null {
36
- function visit(schema: JsonSchema, path: string, stack: string[]): string | null {
37
- const ref = typeof schema.$ref === "string" ? schema.$ref.replace("#/$defs/", "") : null;
38
- if (ref) {
39
- if (stack.includes(ref)) return path;
40
- return visit(defs[ref]!, `$defs.${ref}`, [...stack, ref]);
41
- }
42
- for (const key of ["oneOf", "anyOf"] as const) {
43
- const options = schema[key];
44
- if (Array.isArray(options)) {
45
- for (const [index, option] of options.entries()) {
46
- const loop = visit(option as JsonSchema, `${path}.${key}.${index}`, stack);
47
- if (loop) return loop;
48
- }
49
- }
50
- }
51
- if (schema.type === "array" && schema.items && Number(schema.minItems ?? 0) > 0) {
52
- return visit(schema.items as JsonSchema, `${path}.items`, stack);
53
- }
54
- const required = Array.isArray(schema.required) ? schema.required as string[] : [];
55
- const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
56
- for (const name of required) {
57
- if (!properties[name]) continue;
58
- const loop = visit(properties[name], `${path}.properties.${name}`, stack);
59
- if (loop) return loop;
60
- }
61
- return null;
62
- }
63
- return visit({ $ref: "#/$defs/node" }, "$", []);
64
- }
65
-
66
- describe("OpenUI JSON contract", () => {
67
- it("advertises every renderer node and accepts nested JSON with primitive cells", () => {
68
- expect(OPENUI_NODE_TYPES).toHaveLength(13);
69
- expect(OPENUI_NODE_JSON_SCHEMA.$defs.node.oneOf).toHaveLength(13);
70
- expect(validateOpenUIJsonNode(validPage)).toMatchObject({ ok: true, value: validPage });
71
- expect(validateOpenUIJsonArtifact([validPage, { type: "text", text: "Afterword" }])).toMatchObject({ ok: true });
72
- });
73
-
74
- it("keeps every recursive node path terminable for model tool-schema converters", () => {
75
- expect(findRequiredRefLoop(OPENUI_NODE_JSON_SCHEMA.$defs as unknown as Record<string, JsonSchema>)).toBeNull();
76
- expect(validateOpenUIJsonNode({ type: "card", title: "Empty", children: [] }))
77
- .toMatchObject({ ok: false, issue: { path: "$.children" } });
78
- });
79
-
80
- it("rejects an unsupported top-level or nested section before it can be dropped", () => {
81
- expect(validateOpenUIJsonNode({ type: "section", children: [{ type: "text", text: "hidden" }] }))
82
- .toMatchObject({ ok: false, issue: { path: "$", message: expect.stringContaining("section") } });
83
- const nested = { type: "card", children: [{ type: "section", children: [{ type: "text", text: "hidden" }] }] };
84
- expect(validateOpenUIJsonNode(nested))
85
- .toMatchObject({ ok: false, issue: { path: "$.children[0]", message: expect.stringContaining("section") } });
86
- expect(validateOpenUIJsonArtifact([{ type: "text", text: "visible" }, nested]))
87
- .toMatchObject({ ok: false, issue: { path: "$[1].children[0]" } });
88
- expect(validateOpenUIJsonArtifact(Array.from({ length: 1001 }, () => ({ type: "text", text: "too many" }))))
89
- .toMatchObject({ ok: false, issue: { path: "$[1000]" } });
90
- });
91
-
92
- it("rejects fields the renderer would ignore and table cells with no column", () => {
93
- expect(validateOpenUIJsonNode({ type: "text", text: "hello", body: "hidden" }).ok).toBe(false);
94
- expect(validateOpenUIJsonNode({ type: "stack", children: [] }).ok).toBe(false);
95
- expect(validateOpenUIJsonNode({ type: "key_value", items: [{ label: "Broken", value: { nested: true } }] }).ok).toBe(false);
96
- expect(validateOpenUIJsonNode({ type: "table", columns: [{ key: "shown", header: "Shown" }], rows: [{ hidden: "lost" }] }))
97
- .toMatchObject({ ok: false, issue: { path: "$.rows[0].hidden" } });
98
- });
99
-
100
- it("rejects arrays passed as a single node while keeping optional card fields", () => {
101
- expect(validateOpenUIJsonNode([{ type: "text", text: "ok" }]))
102
- .toMatchObject({ ok: false, issue: { path: "$" } });
103
- expect(validateOpenUIJsonNode({ type: "card" }).ok).toBe(true);
104
- expect(validateOpenUIJsonNode({ type: "card", title: "A result" }).ok).toBe(true);
105
- });
106
-
107
- it("renders an absent prototype-named table cell safely", () => {
108
- const table = JSON.parse('{"type":"table","columns":[{"key":"__proto__","header":"Value"}],"rows":[{}]}');
109
- expect(validateOpenUIJsonNode(table).ok).toBe(true);
110
- render(<OpenUIArtifactRenderer schema={table} />);
111
- expect(screen.getByText("Value")).toBeTruthy();
112
- });
113
-
114
- it("forwards URL transforms and keeps cross-file fragments aligned with sanitized headings", () => {
115
- const urlTransform = vi.fn((url: string, key: string) => {
116
- if (key !== "href") return url;
117
- const separator = url.indexOf("#");
118
- const fragment = separator >= 0
119
- ? "#" + getSanitizedMarkdownHeadingIdFromRawFragment(url.slice(separator + 1))
120
- : "";
121
- return "/app/ws-1/vault?file=campaigns%2Fpacket.md" + fragment;
122
- });
123
-
124
- render(
125
- <OpenUIArtifactRenderer
126
- schema={{
127
- type: "card",
128
- children: [{
129
- type: "markdown",
130
- content: "[Packet](campaigns/packet.md#Cr%C3%A8me%20br%C3%BBl%C3%A9e)\n\n# Crème brûlée",
131
- }],
132
- }}
133
- urlTransform={urlTransform}
134
- />,
135
- );
136
-
137
- expect(screen.getByRole("link", { name: "Packet" })).toHaveAttribute(
138
- "href",
139
- "/app/ws-1/vault?file=campaigns%2Fpacket.md#user-content-crème-brûlée",
140
- );
141
- expect(screen.getByRole("heading", { name: "Crème brûlée" })).toHaveAttribute(
142
- "id",
143
- "user-content-crème-brûlée",
144
- );
145
- expect(urlTransform).toHaveBeenCalledWith(
146
- "campaigns/packet.md#Cr%C3%A8me%20br%C3%BBl%C3%A9e",
147
- "href",
148
- expect.objectContaining({ tagName: "a" }),
149
- );
150
- });
151
-
152
- it("shows an explicit error for an old unsupported artifact", () => {
153
- const oldArtifact = { type: "card", children: [{ type: "section", children: [{ type: "text", text: "hidden" }] }] };
154
- render(<OpenUIArtifactRenderer schema={oldArtifact as never} />);
155
- expect(screen.getByRole("alert").textContent).toContain("$.children[0]");
156
- expect(screen.getByRole("alert").textContent).toContain("section");
157
- });
158
- });