@tangle-network/ui 11.51.6 → 11.51.8
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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -0
- package/dist/chat/agent-timeline.d.ts +1 -1
- package/dist/chat/speaker.d.ts +2 -2
- package/dist/chat.d.ts +2 -2
- package/dist/index.d.ts +4 -4
- package/package.json +31 -1
- package/src/editor/source-markdown-editor.test.tsx +7 -7
- package/src/editor/document-editor-pane.test.tsx +0 -60
- package/src/editor/editor-lazy.test.tsx +0 -102
- package/src/editor/editor-peers.test.ts +0 -250
- package/src/editor/editor-provider.test.tsx +0 -226
- package/src/editor/markdown-document-editor.test.tsx +0 -126
- package/src/editor/tiptap-editor.test.tsx +0 -228
- package/src/files/file-artifact-pane.test.tsx +0 -301
- package/src/files/file-card.test.tsx +0 -62
- package/src/files/file-list.test.tsx +0 -81
- package/src/files/pdf-loader.test.ts +0 -130
- package/src/files/pdf-viewer.test.tsx +0 -245
- package/src/files/rich-file-tree.test.tsx +0 -90
- package/src/markdown/api-presentation.test.tsx +0 -63
- package/src/markdown/code-block.test.tsx +0 -201
- package/src/markdown/markdown-table.test.tsx +0 -76
- package/src/markdown/markdown.test.tsx +0 -166
- package/src/openui/schema.test.tsx +0 -158
- package/src/primitives/button.test.tsx +0 -230
- package/src/primitives/card.test.tsx +0 -71
- package/src/primitives/chip.test.tsx +0 -200
- package/src/primitives/date-picker.test.tsx +0 -146
- package/src/primitives/dialog.test.tsx +0 -75
- package/src/primitives/input.test.tsx +0 -161
- package/src/primitives/interaction-states.test.tsx +0 -287
- package/src/primitives/pagination-alert.test.tsx +0 -180
- package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +0 -165
- package/src/primitives/radio-group.test.tsx +0 -26
- package/src/primitives/segmented-control.test.tsx +0 -451
- package/src/primitives/table.test.tsx +0 -72
- package/src/primitives/theme-toggle.test.tsx +0 -168
- package/src/primitives/toast.test.tsx +0 -130
- package/src/primitives/wizard.test.tsx +0 -106
- package/src/run/expandable-content.test.tsx +0 -52
|
@@ -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\n\n)"}
|
|
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
|
-
{"\n\n[](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>{""}</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>{""}</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
|
-
});
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
3
|
-
import userEvent from "@testing-library/user-event";
|
|
4
|
-
import { hydrateRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { renderToString } from "react-dom/server";
|
|
6
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
import { Button } from "./button";
|
|
8
|
-
|
|
9
|
-
afterEach(cleanup);
|
|
10
|
-
|
|
11
|
-
describe.each(["disabled", "loading"] as const)("%s controls", (state) => {
|
|
12
|
-
it.each([false, true])("natively disables button elements (asChild=%s)", async (asChild) => {
|
|
13
|
-
const user = userEvent.setup();
|
|
14
|
-
const click = vi.fn();
|
|
15
|
-
const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
|
|
16
|
-
render(<form onSubmit={submit}>
|
|
17
|
-
<Button {...{ [state]: true }} asChild={asChild} onClick={click}>
|
|
18
|
-
{asChild ? <button disabled={false} aria-busy={false}>Save</button> : "Save"}
|
|
19
|
-
</Button>
|
|
20
|
-
<input aria-label="Next field" />
|
|
21
|
-
</form>);
|
|
22
|
-
const button = screen.getByRole("button", { name: "Save" });
|
|
23
|
-
expect(button).toBeDisabled();
|
|
24
|
-
expect(button).toHaveAccessibleName("Save");
|
|
25
|
-
if (state === "loading") expect(button).toHaveAttribute("aria-busy", "true");
|
|
26
|
-
await user.click(button);
|
|
27
|
-
button.click();
|
|
28
|
-
button.focus();
|
|
29
|
-
expect(button).not.toHaveFocus();
|
|
30
|
-
await user.keyboard("{Enter} ");
|
|
31
|
-
expect(click).not.toHaveBeenCalled();
|
|
32
|
-
expect(submit).not.toHaveBeenCalled();
|
|
33
|
-
await user.tab();
|
|
34
|
-
expect(screen.getByRole("textbox")).toHaveFocus();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("blocks slotted anchor activation before child AND slot capture/bubble handlers", async () => {
|
|
38
|
-
const user = userEvent.setup();
|
|
39
|
-
const activation = vi.fn();
|
|
40
|
-
const handlers = {
|
|
41
|
-
onClick: activation, onClickCapture: activation,
|
|
42
|
-
onAuxClick: activation, onAuxClickCapture: activation,
|
|
43
|
-
onDoubleClick: activation, onDoubleClickCapture: activation,
|
|
44
|
-
onPointerDown: activation, onPointerDownCapture: activation,
|
|
45
|
-
onPointerUp: activation, onPointerUpCapture: activation,
|
|
46
|
-
onMouseDown: activation, onMouseDownCapture: activation,
|
|
47
|
-
onMouseUp: activation, onMouseUpCapture: activation,
|
|
48
|
-
onKeyDown: activation, onKeyDownCapture: activation,
|
|
49
|
-
onKeyUp: activation, onKeyUpCapture: activation,
|
|
50
|
-
};
|
|
51
|
-
render(<div onClick={activation}>
|
|
52
|
-
<Button asChild {...{ [state]: true }} aria-busy={false} {...handlers}>
|
|
53
|
-
<a href="#destination" tabIndex={0} aria-disabled={false} aria-busy={false} {...handlers}>
|
|
54
|
-
<span>Open account</span>
|
|
55
|
-
</a>
|
|
56
|
-
</Button>
|
|
57
|
-
<button>Next action</button>
|
|
58
|
-
</div>);
|
|
59
|
-
const link = screen.getByRole("link", { name: "Open account" });
|
|
60
|
-
expect(link).toHaveAttribute("aria-disabled", "true");
|
|
61
|
-
expect(link).toHaveAttribute("tabindex", "-1");
|
|
62
|
-
expect(link).not.toHaveAttribute("disabled");
|
|
63
|
-
expect(link).not.toHaveAttribute("href");
|
|
64
|
-
if (state === "loading") expect(link).toHaveAttribute("aria-busy", "true");
|
|
65
|
-
const target = screen.getByText("Open account");
|
|
66
|
-
expect(fireEvent.click(target)).toBe(false);
|
|
67
|
-
expect(fireEvent.doubleClick(target)).toBe(false);
|
|
68
|
-
expect(fireEvent(target, new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }))).toBe(false);
|
|
69
|
-
expect(fireEvent.pointerDown(target)).toBe(false);
|
|
70
|
-
expect(fireEvent.pointerUp(target)).toBe(false);
|
|
71
|
-
expect(fireEvent.mouseDown(target)).toBe(false);
|
|
72
|
-
expect(fireEvent.mouseUp(target)).toBe(false);
|
|
73
|
-
for (const key of ["Enter", " "]) {
|
|
74
|
-
expect(fireEvent.keyDown(link, { key })).toBe(false);
|
|
75
|
-
expect(fireEvent.keyUp(link, { key })).toBe(false);
|
|
76
|
-
}
|
|
77
|
-
await user.click(target);
|
|
78
|
-
link.click();
|
|
79
|
-
// Even programmatically focused, a disabled link cannot activate by keyboard.
|
|
80
|
-
link.focus();
|
|
81
|
-
await user.keyboard("{Enter} ");
|
|
82
|
-
expect(activation).not.toHaveBeenCalled();
|
|
83
|
-
link.blur();
|
|
84
|
-
await user.tab();
|
|
85
|
-
expect(screen.getByRole("button", { name: "Next action" })).toHaveFocus();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("retains the action name and hides the reduced-motion spinner from accessibility", () => {
|
|
90
|
-
const { rerender } = render(<Button loading aria-busy={false}>Save changes</Button>);
|
|
91
|
-
const button = screen.getByRole("button", { name: "Save changes" });
|
|
92
|
-
expect(button).toHaveAttribute("aria-busy", "true");
|
|
93
|
-
const spinner = button.querySelector("svg");
|
|
94
|
-
expect(spinner).toHaveAttribute("aria-hidden", "true");
|
|
95
|
-
expect(spinner).toHaveAttribute("focusable", "false");
|
|
96
|
-
expect(spinner).toHaveClass("motion-reduce:animate-none");
|
|
97
|
-
expect(button).toHaveClass("motion-reduce:transition-none", "motion-reduce:active:scale-100");
|
|
98
|
-
expect(button.querySelector("title")).toBeNull();
|
|
99
|
-
rerender(<Button loading size="icon" aria-label="Refresh reports"><span aria-hidden="true">↻</span></Button>);
|
|
100
|
-
expect(screen.getByRole("button")).toHaveAccessibleName("Refresh reports");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it.each(["disabled", "loading"] as const)("restores link attributes and original Slot handler order when %s ends", async (state) => {
|
|
104
|
-
const user = userEvent.setup();
|
|
105
|
-
const calls: string[] = [];
|
|
106
|
-
const ui = (unavailable: boolean) => <Button asChild {...{ [state]: unavailable }} onClick={() => calls.push("slot")}>
|
|
107
|
-
<a href="#destination" tabIndex={2} onClick={(event) => { event.preventDefault(); calls.push("child"); }}>Open</a>
|
|
108
|
-
</Button>;
|
|
109
|
-
const { rerender } = render(ui(true));
|
|
110
|
-
const link = screen.getByRole("link");
|
|
111
|
-
rerender(ui(false));
|
|
112
|
-
expect(link).toHaveAttribute("href", "#destination");
|
|
113
|
-
expect(link).toHaveAttribute("tabindex", "2");
|
|
114
|
-
expect(link).not.toHaveAttribute("aria-disabled");
|
|
115
|
-
expect(link).not.toHaveAttribute("aria-busy");
|
|
116
|
-
expect(link).not.toHaveAttribute("role");
|
|
117
|
-
await user.click(link);
|
|
118
|
-
expect(calls).toEqual(["child", "slot"]);
|
|
119
|
-
calls.length = 0;
|
|
120
|
-
link.focus();
|
|
121
|
-
await user.keyboard("{Enter}");
|
|
122
|
-
expect(calls).toEqual(["child", "slot"]);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("preserves non-activation keyboard handlers on disabled links", () => {
|
|
126
|
-
const calls: string[] = [];
|
|
127
|
-
render(<Button asChild disabled onKeyDownCapture={() => calls.push("slot capture")} onKeyDown={() => calls.push("slot bubble")}>
|
|
128
|
-
<a href="#destination" onKeyDownCapture={() => calls.push("child capture")} onKeyDown={() => calls.push("child bubble")}>Open</a>
|
|
129
|
-
</Button>);
|
|
130
|
-
expect(fireEvent.keyDown(screen.getByRole("link"), { key: "ArrowRight" })).toBe(true);
|
|
131
|
-
expect(calls).toEqual(["child capture", "slot capture", "child bubble", "slot bubble"]);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("respects a native child's own disabled state", () => {
|
|
135
|
-
render(<Button asChild disabled={false}><button disabled>Save</button></Button>);
|
|
136
|
-
expect(screen.getByRole("button")).toBeDisabled();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("composes refs on a slotted native button", () => {
|
|
140
|
-
const outerRef = React.createRef<HTMLButtonElement>();
|
|
141
|
-
const childRef = React.createRef<HTMLButtonElement>();
|
|
142
|
-
const { unmount } = render(<Button asChild ref={outerRef}><button ref={childRef}>Save</button></Button>);
|
|
143
|
-
expect(outerRef.current).toBe(screen.getByRole("button"));
|
|
144
|
-
expect(childRef.current).toBe(outerRef.current);
|
|
145
|
-
unmount();
|
|
146
|
-
expect(outerRef.current).toBeNull();
|
|
147
|
-
expect(childRef.current).toBeNull();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("preserves caller busy state when Button is not loading", () => {
|
|
151
|
-
render(<><Button aria-busy>Save</Button><Button asChild aria-busy><a href="#destination">Open</a></Button></>);
|
|
152
|
-
expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
|
|
153
|
-
expect(screen.getByRole("link")).toHaveAttribute("aria-busy", "true");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("preserves implicit submission, explicit types, and enabled keyboard activation", async () => {
|
|
157
|
-
const user = userEvent.setup();
|
|
158
|
-
const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
|
|
159
|
-
render(<form onSubmit={submit}>
|
|
160
|
-
<Button>Implicit submit</Button>
|
|
161
|
-
<Button asChild><button>Slotted submit</button></Button>
|
|
162
|
-
<Button type="button">Explicit button</Button>
|
|
163
|
-
<Button type="submit">Explicit submit</Button>
|
|
164
|
-
<Button asChild type="submit"><button type="button">Child button</button></Button>
|
|
165
|
-
</form>);
|
|
166
|
-
const implicit = screen.getByRole("button", { name: "Implicit submit" });
|
|
167
|
-
expect(implicit).not.toHaveAttribute("type");
|
|
168
|
-
await user.click(implicit);
|
|
169
|
-
await user.click(screen.getByRole("button", { name: "Slotted submit" }));
|
|
170
|
-
await user.click(screen.getByRole("button", { name: "Explicit submit" }));
|
|
171
|
-
expect(submit).toHaveBeenCalledTimes(3);
|
|
172
|
-
await user.click(screen.getByRole("button", { name: "Explicit button" }));
|
|
173
|
-
await user.click(screen.getByRole("button", { name: "Child button" }));
|
|
174
|
-
expect(submit).toHaveBeenCalledTimes(3);
|
|
175
|
-
implicit.focus();
|
|
176
|
-
await user.keyboard("{Enter} ");
|
|
177
|
-
expect(submit).toHaveBeenCalledTimes(5);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("forwards native refs and composes both refs on a slotted anchor", () => {
|
|
181
|
-
const nativeRef = React.createRef<HTMLButtonElement>();
|
|
182
|
-
const anchorRef = React.createRef<HTMLAnchorElement>();
|
|
183
|
-
const slotRef = vi.fn();
|
|
184
|
-
const { rerender, unmount } = render(<>
|
|
185
|
-
<Button ref={nativeRef}>Save</Button>
|
|
186
|
-
<Button asChild loading ref={slotRef}><a ref={anchorRef} href="#destination">Open</a></Button>
|
|
187
|
-
</>);
|
|
188
|
-
const link = screen.getByRole("link");
|
|
189
|
-
expect(nativeRef.current).toBe(screen.getByRole("button"));
|
|
190
|
-
expect(anchorRef.current).toBe(link);
|
|
191
|
-
expect(slotRef).toHaveBeenLastCalledWith(link);
|
|
192
|
-
rerender(<>
|
|
193
|
-
<Button ref={nativeRef}>Save</Button>
|
|
194
|
-
<Button asChild ref={slotRef}><a ref={anchorRef} href="#destination">Open</a></Button>
|
|
195
|
-
</>);
|
|
196
|
-
expect(anchorRef.current).toBe(link);
|
|
197
|
-
expect(slotRef).toHaveBeenLastCalledWith(link);
|
|
198
|
-
unmount();
|
|
199
|
-
expect(nativeRef.current).toBeNull();
|
|
200
|
-
expect(anchorRef.current).toBeNull();
|
|
201
|
-
expect(slotRef).toHaveBeenLastCalledWith(null);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("hydrates disabled/loading native and slotted controls without changing their names", async () => {
|
|
205
|
-
const ui = <>
|
|
206
|
-
<Button loading>Save</Button>
|
|
207
|
-
<Button asChild loading><button>Submit</button></Button>
|
|
208
|
-
<Button asChild disabled><a href="#destination">Open</a></Button>
|
|
209
|
-
</>;
|
|
210
|
-
const container = document.createElement("div");
|
|
211
|
-
container.innerHTML = renderToString(ui);
|
|
212
|
-
document.body.appendChild(container);
|
|
213
|
-
const html = container.innerHTML;
|
|
214
|
-
const recoverableError = vi.fn();
|
|
215
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
216
|
-
let root: Root | undefined;
|
|
217
|
-
try {
|
|
218
|
-
await act(async () => { root = hydrateRoot(container, ui, { onRecoverableError: recoverableError }); });
|
|
219
|
-
expect(container.innerHTML).toBe(html);
|
|
220
|
-
expect(within(container).getByRole("button", { name: "Save" })).toBeDisabled();
|
|
221
|
-
expect(within(container).getByRole("button", { name: "Submit" })).toBeDisabled();
|
|
222
|
-
expect(within(container).getByRole("link", { name: "Open" })).not.toHaveAttribute("href");
|
|
223
|
-
expect(recoverableError).not.toHaveBeenCalled();
|
|
224
|
-
expect(consoleError).not.toHaveBeenCalled();
|
|
225
|
-
} finally {
|
|
226
|
-
await act(async () => { root?.unmount(); });
|
|
227
|
-
consoleError.mockRestore();
|
|
228
|
-
container.remove();
|
|
229
|
-
}
|
|
230
|
-
});
|