@cosmicdrift/kumiko-renderer-web 0.158.2 → 0.160.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.158.2",
3
+ "version": "0.160.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.158.2",
20
- "@cosmicdrift/kumiko-headless": "0.158.2",
21
- "@cosmicdrift/kumiko-renderer": "0.158.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.160.0",
20
+ "@cosmicdrift/kumiko-headless": "0.160.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.160.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,75 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { ActionMenu, type MenuItemDef } from "../primitives/action-menu";
5
+
6
+ describe("ActionMenu", () => {
7
+ test("renders trigger with aria-label and default testId", () => {
8
+ render(
9
+ <ActionMenu
10
+ trigger={<span>Open</span>}
11
+ triggerLabel="More actions"
12
+ items={[{ kind: "item", id: "a", label: "Alpha", onSelect: () => {} }]}
13
+ />,
14
+ );
15
+ const trigger = screen.getByTestId("action-menu-trigger");
16
+ expect(trigger.getAttribute("aria-label")).toBe("More actions");
17
+ expect(screen.getByText("Open")).toBeTruthy();
18
+ });
19
+
20
+ test("opens menu: item, separator, label, shortcut, danger, icon", async () => {
21
+ const user = userEvent.setup();
22
+ const onSelect = mock(() => {});
23
+ const items: MenuItemDef[] = [
24
+ { kind: "label", label: "Section" },
25
+ {
26
+ kind: "item",
27
+ id: "edit",
28
+ label: "Edit",
29
+ icon: <span data-testid="edit-icon">✎</span>,
30
+ shortcut: "⌘E",
31
+ onSelect,
32
+ },
33
+ { kind: "separator" },
34
+ {
35
+ kind: "item",
36
+ id: "delete",
37
+ label: "Delete",
38
+ variant: "danger",
39
+ onSelect: () => {},
40
+ },
41
+ {
42
+ kind: "item",
43
+ id: "disabled",
44
+ label: "Disabled",
45
+ disabled: true,
46
+ onSelect: () => {},
47
+ },
48
+ ];
49
+
50
+ render(
51
+ <ActionMenu
52
+ trigger={<span>Menu</span>}
53
+ items={items}
54
+ align="start"
55
+ minWidth="12rem"
56
+ testId="am"
57
+ />,
58
+ );
59
+ await user.click(screen.getByTestId("am"));
60
+
61
+ await waitFor(() => {
62
+ expect(screen.getByText("Section")).toBeTruthy();
63
+ expect(screen.getByTestId("action-menu-item-edit")).toBeTruthy();
64
+ expect(screen.getByTestId("edit-icon")).toBeTruthy();
65
+ expect(screen.getByText("⌘E")).toBeTruthy();
66
+ expect(screen.getByTestId("action-menu-item-delete").className).toContain("text-destructive");
67
+ expect(screen.getByTestId("action-menu-item-disabled").getAttribute("data-disabled")).toBe(
68
+ "",
69
+ );
70
+ });
71
+
72
+ await user.click(screen.getByTestId("action-menu-item-edit"));
73
+ expect(onSelect).toHaveBeenCalled();
74
+ });
75
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ Breadcrumb,
5
+ BreadcrumbEllipsis,
6
+ BreadcrumbItem,
7
+ BreadcrumbLink,
8
+ BreadcrumbList,
9
+ BreadcrumbPage,
10
+ BreadcrumbSeparator,
11
+ } from "../ui/breadcrumb";
12
+
13
+ describe("Breadcrumb", () => {
14
+ test("renders nav list with links, separators, page, ellipsis", () => {
15
+ render(
16
+ <Breadcrumb>
17
+ <BreadcrumbList>
18
+ <BreadcrumbItem>
19
+ <BreadcrumbLink href="/">Home</BreadcrumbLink>
20
+ </BreadcrumbItem>
21
+ <BreadcrumbSeparator />
22
+ <BreadcrumbItem>
23
+ <BreadcrumbEllipsis />
24
+ </BreadcrumbItem>
25
+ <BreadcrumbSeparator>
26
+ <span data-testid="custom-sep">/</span>
27
+ </BreadcrumbSeparator>
28
+ <BreadcrumbItem>
29
+ <BreadcrumbLink asChild>
30
+ <a href="/docs">Docs</a>
31
+ </BreadcrumbLink>
32
+ </BreadcrumbItem>
33
+ <BreadcrumbSeparator />
34
+ <BreadcrumbItem>
35
+ <BreadcrumbPage>Current</BreadcrumbPage>
36
+ </BreadcrumbItem>
37
+ </BreadcrumbList>
38
+ </Breadcrumb>,
39
+ );
40
+
41
+ expect(screen.getByLabelText("breadcrumb")).toBeTruthy();
42
+ expect(screen.getByText("Home").getAttribute("href")).toBe("/");
43
+ expect(screen.getByText("More")).toBeTruthy();
44
+ expect(screen.getByTestId("custom-sep")).toBeTruthy();
45
+ expect(screen.getByText("Docs").getAttribute("href")).toBe("/docs");
46
+ const page = screen.getByText("Current");
47
+ expect(page.getAttribute("aria-current")).toBe("page");
48
+ expect(page.getAttribute("aria-disabled")).toBe("true");
49
+ });
50
+ });
@@ -499,6 +499,31 @@ describe("KumikoScreen", () => {
499
499
  expect(bannerText).not.toBe("errors.access.denied");
500
500
  });
501
501
 
502
+ test("entityList: query-Error zeigt übersetzten Text statt rohem i18nKey (issue #1193)", async () => {
503
+ const dispatcher = makeDispatcher({
504
+ query: (async () => ({
505
+ isSuccess: false,
506
+ error: {
507
+ code: "access_denied",
508
+ httpStatus: 403,
509
+ i18nKey: "errors.access.denied",
510
+ message: "",
511
+ },
512
+ })) as unknown as Dispatcher["query"],
513
+ });
514
+
515
+ render(
516
+ <DispatcherProvider dispatcher={dispatcher}>
517
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list" />
518
+ </DispatcherProvider>,
519
+ );
520
+
521
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-error")).toBeTruthy());
522
+ const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
523
+ expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
524
+ expect(bannerText).not.toBe("errors.access.denied");
525
+ });
526
+
502
527
  // RowActions-Mapping (Tier 2.7a Resolution-Layer): pinst dass
503
528
  // EntityListBody die Schema-Form (handler-QN, label-i18nKey, payload-
504
529
  // builder, visible-Function, confirmLabel) zu DataTableRowAction
@@ -0,0 +1,29 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { ProfileMenu } from "../layout/profile-menu";
5
+
6
+ describe("ProfileMenu", () => {
7
+ test("wraps Avatar trigger and opens ActionMenu items", async () => {
8
+ const user = userEvent.setup();
9
+ const onSelect = mock(() => {});
10
+ render(
11
+ <ProfileMenu
12
+ user={{ id: "u1", label: "Ada Lovelace" }}
13
+ items={[
14
+ { kind: "item", id: "profile", label: "View profile", onSelect },
15
+ { kind: "separator" },
16
+ { kind: "item", id: "out", label: "Sign out", variant: "danger", onSelect: () => {} },
17
+ ]}
18
+ />,
19
+ );
20
+ const trigger = screen.getByTestId("profile-menu-trigger");
21
+ expect(trigger.getAttribute("aria-label")).toBe("Open Ada Lovelace menu");
22
+ await user.click(trigger);
23
+ await waitFor(() => {
24
+ expect(screen.getByTestId("action-menu-item-profile")).toBeTruthy();
25
+ });
26
+ await user.click(screen.getByTestId("action-menu-item-profile"));
27
+ expect(onSelect).toHaveBeenCalled();
28
+ });
29
+ });
@@ -157,6 +157,7 @@ export function createMockDispatcher(options: MockDispatcherOptions = {}): Dispa
157
157
  options.batch ??
158
158
  ((async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"]),
159
159
  statusStore,
160
+ async *stream() {},
160
161
  pendingWrites: () => [],
161
162
  pendingFiles: () => [],
162
163
  };
@@ -0,0 +1,58 @@
1
+ //
2
+ // Pins the XSS-escaping behavior for tenant-/pattern-authored content
3
+ // (kumiko-framework#1321). renderer-web has no dangerouslySetInnerHTML
4
+ // and no field-renderer path that wires tenant data into href/src — this
5
+ // test guards JSX auto-escaping against future regressions (e.g. if
6
+ // someone adds rich-text/markdown support via dangerouslySetInnerHTML),
7
+ // and pins the Link primitive's scheme guard (kumiko-framework#1365)
8
+ // against javascript:/data: hrefs as defense-in-depth.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { defaultPrimitives } from "../primitives";
12
+ import { StatCard } from "../widgets/stat";
13
+ import { render, screen } from "./test-utils";
14
+
15
+ const PAYLOAD = '<script>window.__xss = true;</script><img src=x onerror="window.__xss = true">';
16
+ const UNSAFE_HREFS = [
17
+ "javascript:window.__xss = true",
18
+ "data:text/html,<script>window.__xss=true</script>",
19
+ ];
20
+
21
+ describe("XSS-safety (tenant-authored content)", () => {
22
+ test("Text-Primitive rendert Payload als Text, nicht als HTML", () => {
23
+ render(<defaultPrimitives.Text testId="txt">{PAYLOAD}</defaultPrimitives.Text>);
24
+ const node = screen.getByTestId("txt");
25
+ expect(node.textContent).toBe(PAYLOAD);
26
+ expect(node.querySelector("script")).toBeNull();
27
+ expect(node.querySelector("img")).toBeNull();
28
+ });
29
+
30
+ test("StatCard-Widget rendert label/value/sub als Text, nicht als HTML", () => {
31
+ render(<StatCard testId="stat" label={PAYLOAD} value={PAYLOAD} sub={PAYLOAD} />);
32
+ const node = screen.getByTestId("stat");
33
+ expect(node.querySelector("script")).toBeNull();
34
+ expect(node.querySelector("img")).toBeNull();
35
+ expect(node.textContent).toContain(PAYLOAD);
36
+ });
37
+
38
+ test.each(UNSAFE_HREFS)("Link-Primitive neutralisiert unsicheren href-Scheme: %s", (href) => {
39
+ render(
40
+ <defaultPrimitives.Link testId="link" href={href}>
41
+ text
42
+ </defaultPrimitives.Link>,
43
+ );
44
+ const node = screen.getByTestId("link");
45
+ expect(node.getAttribute("href")).toBe("#");
46
+ });
47
+
48
+ test("Link-Primitive lässt sichere hrefs (https/mailto/relativ) unverändert", () => {
49
+ for (const href of ["https://example.com", "mailto:a@example.com", "/relative/path"]) {
50
+ render(
51
+ <defaultPrimitives.Link testId={`link-${href}`} href={href}>
52
+ text
53
+ </defaultPrimitives.Link>,
54
+ );
55
+ expect(screen.getByTestId(`link-${href}`).getAttribute("href")).toBe(href);
56
+ }
57
+ });
58
+ });
@@ -63,4 +63,15 @@ describe("firstOpenScreenQn", () => {
63
63
  ];
64
64
  expect(firstOpenScreenQn(features)).toBeUndefined();
65
65
  });
66
+
67
+ test("resolves a bare screen id in nav to the qualified screen name", () => {
68
+ const features: readonly FeatureSchema[] = [
69
+ feature({
70
+ featureName: "shop",
71
+ screens: [customScreen({ id: "catalog" })],
72
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "catalog" }],
73
+ }),
74
+ ];
75
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
76
+ });
66
77
  });
@@ -1661,6 +1661,15 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
1661
1661
 
1662
1662
  // ---- Link (anchor mit Button-/Muted-Optik) ----
1663
1663
 
1664
+ // http(s)/mailto or scheme-less (relative/anchor) allowed; javascript:, data:,
1665
+ // vbscript:, etc. rejected. Mirrors renderer-mail-html's renderSafeMarkdown
1666
+ // href guard — no sanitizer dependency needed for a four-line regex check.
1667
+ function isSafeHref(href: string): boolean {
1668
+ const trimmed = href.trim().toLowerCase();
1669
+ if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
1670
+ return /^(?:https?|mailto):/.test(trimmed);
1671
+ }
1672
+
1664
1673
  // `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
1665
1674
  // der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
1666
1675
  // authButtonClass), `muted` der dezente Sekundär-Link (ehem.
@@ -1681,7 +1690,7 @@ function DefaultLink({
1681
1690
  : "text-primary underline-offset-4 hover:underline";
1682
1691
  return (
1683
1692
  <a
1684
- href={href}
1693
+ href={isSafeHref(href) ? href : "#"}
1685
1694
  target={target}
1686
1695
  rel={target === "_blank" ? "noreferrer" : undefined}
1687
1696
  data-testid={testId}
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render, screen } from "../../__tests__/test-utils";
3
+ import {
4
+ Avatar,
5
+ AvatarBadge,
6
+ AvatarFallback,
7
+ AvatarGroup,
8
+ AvatarGroupCount,
9
+ AvatarImage,
10
+ } from "../avatar";
11
+
12
+ describe("ui/Avatar", () => {
13
+ test.each(["default", "sm", "lg"] as const)("size=%s sets data-size on root", (size) => {
14
+ render(
15
+ <Avatar size={size} data-testid="av">
16
+ <AvatarFallback>AB</AvatarFallback>
17
+ </Avatar>,
18
+ );
19
+ expect(screen.getByTestId("av").getAttribute("data-size")).toBe(size);
20
+ expect(document.querySelector('[data-slot="avatar"]')).not.toBeNull();
21
+ });
22
+
23
+ test("AvatarImage with failed load shows AvatarFallback", () => {
24
+ render(
25
+ <Avatar>
26
+ <AvatarImage src="/photo.png" alt="User" />
27
+ <AvatarFallback>FB</AvatarFallback>
28
+ </Avatar>,
29
+ );
30
+ // happy-dom does not load images — Radix keeps the fallback visible
31
+ expect(screen.getByText("FB")).toBeTruthy();
32
+ expect(document.querySelector('[data-slot="avatar-fallback"]')).not.toBeNull();
33
+ });
34
+
35
+ test("AvatarBadge renders inside avatar", () => {
36
+ render(
37
+ <Avatar data-testid="av">
38
+ <AvatarFallback>AB</AvatarFallback>
39
+ <AvatarBadge data-testid="badge">!</AvatarBadge>
40
+ </Avatar>,
41
+ );
42
+ expect(screen.getByTestId("badge")).toBeTruthy();
43
+ expect(document.querySelector('[data-slot="avatar-badge"]')).not.toBeNull();
44
+ });
45
+
46
+ test("AvatarGroup stacks avatars and AvatarGroupCount shows overflow", () => {
47
+ render(
48
+ <AvatarGroup data-testid="group">
49
+ <Avatar>
50
+ <AvatarFallback>A</AvatarFallback>
51
+ </Avatar>
52
+ <Avatar>
53
+ <AvatarFallback>B</AvatarFallback>
54
+ </Avatar>
55
+ <AvatarGroupCount data-testid="count">+3</AvatarGroupCount>
56
+ </AvatarGroup>,
57
+ );
58
+ expect(screen.getByTestId("group")).toBeTruthy();
59
+ expect(screen.getByTestId("count").textContent).toBe("+3");
60
+ expect(document.querySelector('[data-slot="avatar-group"]')).not.toBeNull();
61
+ expect(document.querySelector('[data-slot="avatar-group-count"]')).not.toBeNull();
62
+ });
63
+ });
64
+
@@ -0,0 +1,102 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { render, screen } from "../../__tests__/test-utils";
4
+ import {
5
+ Sheet,
6
+ SheetClose,
7
+ SheetContent,
8
+ SheetDescription,
9
+ SheetFooter,
10
+ SheetHeader,
11
+ SheetTitle,
12
+ SheetTrigger,
13
+ } from "../sheet";
14
+
15
+ describe("Sheet", () => {
16
+ test.each([
17
+ ["right", "slide-in-from-right"],
18
+ ["left", "slide-in-from-left"],
19
+ ["top", "slide-in-from-top"],
20
+ ["bottom", "slide-in-from-bottom"],
21
+ ] as const)("open sheet side=%s applies side-specific slide class", (side, slideClass) => {
22
+ render(
23
+ <Sheet open>
24
+ <SheetContent side={side}>
25
+ <SheetHeader>
26
+ <SheetTitle>Title</SheetTitle>
27
+ <SheetDescription>Description</SheetDescription>
28
+ </SheetHeader>
29
+ Body
30
+ <SheetFooter>Footer</SheetFooter>
31
+ </SheetContent>
32
+ </Sheet>,
33
+ );
34
+ const content = document.querySelector('[data-slot="sheet-content"]');
35
+ expect(content).not.toBeNull();
36
+ expect(content?.className).toContain(slideClass);
37
+ expect(screen.getByText("Title")).toBeTruthy();
38
+ expect(screen.getByText("Description")).toBeTruthy();
39
+ expect(screen.getByText("Body")).toBeTruthy();
40
+ expect(screen.getByText("Footer")).toBeTruthy();
41
+ expect(document.querySelector('[data-slot="sheet-overlay"]')).not.toBeNull();
42
+ expect(document.querySelector('[data-slot="sheet-header"]')).not.toBeNull();
43
+ expect(document.querySelector('[data-slot="sheet-footer"]')).not.toBeNull();
44
+ });
45
+
46
+ test("showCloseButton=true renders accessible Close control", () => {
47
+ render(
48
+ <Sheet open>
49
+ <SheetContent showCloseButton>
50
+ Content
51
+ </SheetContent>
52
+ </Sheet>,
53
+ );
54
+ const content = document.querySelector('[data-slot="sheet-content"]');
55
+ const closeButton = content?.querySelector("button");
56
+ expect(closeButton).not.toBeNull();
57
+ expect(closeButton?.textContent).toContain("Close");
58
+ });
59
+
60
+ test("showCloseButton=false hides Close control", () => {
61
+ render(
62
+ <Sheet open>
63
+ <SheetContent showCloseButton={false}>
64
+ Content
65
+ </SheetContent>
66
+ </Sheet>,
67
+ );
68
+ const content = document.querySelector('[data-slot="sheet-content"]');
69
+ expect(content?.querySelector("button")).toBeNull();
70
+ });
71
+
72
+ test("Close button calls onOpenChange(false)", async () => {
73
+ const user = userEvent.setup();
74
+ const onOpenChange = mock();
75
+ render(
76
+ <Sheet open onOpenChange={onOpenChange}>
77
+ <SheetContent>
78
+ Content
79
+ </SheetContent>
80
+ </Sheet>,
81
+ );
82
+ const closeButton = document.querySelector('[data-slot="sheet-content"] button') as HTMLButtonElement;
83
+ await user.click(closeButton);
84
+ expect(onOpenChange).toHaveBeenCalledWith(false);
85
+ });
86
+
87
+ test("SheetTrigger and SheetClose render with data-slot", () => {
88
+ render(
89
+ <Sheet open>
90
+ <SheetTrigger>Open</SheetTrigger>
91
+ <SheetContent showCloseButton={false}>
92
+ <SheetClose data-testid="sheet-dismiss">Dismiss</SheetClose>
93
+ </SheetContent>
94
+ </Sheet>,
95
+ );
96
+ expect(document.querySelector('[data-slot="sheet-trigger"]')).not.toBeNull();
97
+ expect(screen.getByTestId("sheet-dismiss")).toBeTruthy();
98
+ expect(document.querySelector('[data-slot="sheet-close"]')).not.toBeNull();
99
+ });
100
+ });
101
+
102
+
@@ -0,0 +1,114 @@
1
+ // UpdateChecker DOM coverage: fetchServerBuild + banner on id drift.
2
+ // shouldShowUpdate/isKumikoBuild stay in update-checker.test.ts (unit).
3
+
4
+ import { afterEach, describe, expect, mock, test } from "bun:test";
5
+ import { screen, waitFor } from "@testing-library/react";
6
+ import { render } from "../../__tests__/test-utils";
7
+ import { UpdateChecker } from "../update-checker";
8
+
9
+ const LOADED_BUILD = { id: "build-loaded", builtAt: "2026-01-01T00:00:00Z" };
10
+ const originalFetch = globalThis.fetch;
11
+
12
+ function mockBuildInfoFetch(handler: (url: string) => Promise<Response> | Response): void {
13
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
14
+ const url = typeof input === "string" ? input : input.toString();
15
+ return handler(url);
16
+ }) as typeof globalThis.fetch;
17
+ }
18
+
19
+ afterEach(() => {
20
+ globalThis.fetch = originalFetch;
21
+ delete window.__KUMIKO_BUILD__;
22
+ });
23
+
24
+ describe("UpdateChecker", () => {
25
+ test("build-info drift → status banner + reload button", async () => {
26
+ window.__KUMIKO_BUILD__ = LOADED_BUILD;
27
+ mockBuildInfoFetch(async (url) => {
28
+ if (url.endsWith("/build-info.json")) {
29
+ return {
30
+ ok: true,
31
+ json: async () => ({ id: "build-server", builtAt: "2026-01-02T00:00:00Z" }),
32
+ } as Response;
33
+ }
34
+ throw new Error(`unexpected fetch: ${url}`);
35
+ });
36
+
37
+ render(<UpdateChecker />);
38
+
39
+ await waitFor(() => {
40
+ expect(screen.getByRole("status")).toBeTruthy();
41
+ });
42
+ expect(screen.getByText("A new version is available.")).toBeTruthy();
43
+ expect(screen.getByRole("button", { name: "Reload" })).toBeTruthy();
44
+ });
45
+
46
+ test("build-info !ok → no banner", async () => {
47
+ window.__KUMIKO_BUILD__ = LOADED_BUILD;
48
+ mockBuildInfoFetch(async (url) => {
49
+ if (url.endsWith("/build-info.json")) {
50
+ return { ok: false, json: async () => ({}) } as Response;
51
+ }
52
+ throw new Error(`unexpected fetch: ${url}`);
53
+ });
54
+
55
+ render(<UpdateChecker />);
56
+
57
+ await waitFor(() => {
58
+ expect(globalThis.fetch).toHaveBeenCalled();
59
+ });
60
+ expect(screen.queryByRole("status")).toBeNull();
61
+ });
62
+
63
+ test("invalid build-info JSON shape → no banner", async () => {
64
+ window.__KUMIKO_BUILD__ = LOADED_BUILD;
65
+ mockBuildInfoFetch(async (url) => {
66
+ if (url.endsWith("/build-info.json")) {
67
+ return { ok: true, json: async () => ({ id: "" }) } as Response;
68
+ }
69
+ throw new Error(`unexpected fetch: ${url}`);
70
+ });
71
+
72
+ render(<UpdateChecker />);
73
+
74
+ await waitFor(() => {
75
+ expect(globalThis.fetch).toHaveBeenCalled();
76
+ });
77
+ expect(screen.queryByRole("status")).toBeNull();
78
+ });
79
+
80
+ test("broken JSON (parse error) → no banner", async () => {
81
+ window.__KUMIKO_BUILD__ = LOADED_BUILD;
82
+ mockBuildInfoFetch(async (url) => {
83
+ if (url.endsWith("/build-info.json")) {
84
+ return {
85
+ ok: true,
86
+ json: async () => {
87
+ throw new SyntaxError("Unexpected token");
88
+ },
89
+ } as Response;
90
+ }
91
+ throw new Error(`unexpected fetch: ${url}`);
92
+ });
93
+
94
+ render(<UpdateChecker />);
95
+
96
+ await waitFor(() => {
97
+ expect(globalThis.fetch).toHaveBeenCalled();
98
+ });
99
+ expect(screen.queryByRole("status")).toBeNull();
100
+ });
101
+
102
+ test("no __KUMIKO_BUILD__ → no fetch, no banner", () => {
103
+ const fetchSpy = mock(async () => ({
104
+ ok: true,
105
+ json: async () => ({ id: "other", builtAt: "" }),
106
+ }));
107
+ globalThis.fetch = fetchSpy as typeof globalThis.fetch;
108
+
109
+ render(<UpdateChecker />);
110
+
111
+ expect(fetchSpy).not.toHaveBeenCalled();
112
+ expect(screen.queryByRole("status")).toBeNull();
113
+ });
114
+ });