@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -3,9 +3,9 @@
3
3
  // kann. Kein Context-Boilerplate für Konsumenten — der Hook returned
4
4
  // einen `toast()`-Trigger, mehr Public-API gibt's nicht.
5
5
  //
6
- // Variants: "default" (neutral) und "destructive" (Fehler-Akzent).
7
- // Auto-dismiss nach 5s, manuell schließbar via X-Button. Der ARIA-
8
- // Live-Region-Setup kommt komplett aus Radix.
6
+ // Variant = StatusTone (dieselbe Farb-Familie wie StatusBadge), Default
7
+ // "muted" (neutral). Auto-dismiss nach 5s, manuell schließbar via X-Button.
8
+ // Der ARIA-Live-Region-Setup kommt komplett aus Radix.
9
9
 
10
10
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
11
11
  import * as Primitive from "@radix-ui/react-toast";
@@ -21,8 +21,9 @@ import {
21
21
  useState,
22
22
  } from "react";
23
23
  import { cn } from "../lib/cn";
24
+ import type { StatusTone } from "../widgets/status-badge";
24
25
 
25
- export type ToastVariant = "default" | "destructive";
26
+ export type ToastVariant = StatusTone;
26
27
 
27
28
  export type ToastOptions = {
28
29
  readonly title: string;
@@ -111,6 +112,14 @@ const rootClass =
111
112
  "data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full " +
112
113
  "data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full";
113
114
 
115
+ const TONE_CLASS: Record<StatusTone, string> = {
116
+ ok: "border-status-ok/30 bg-status-ok/10 text-status-ok",
117
+ warn: "border-status-warn/30 bg-status-warn/10 text-status-warn",
118
+ bad: "border-status-bad/30 bg-status-bad/10 text-status-bad",
119
+ critical: "border-status-critical/40 bg-status-critical/15 text-status-critical",
120
+ muted: "border bg-background text-foreground",
121
+ };
122
+
114
123
  function ToastItem({
115
124
  entry,
116
125
  onClose,
@@ -120,10 +129,7 @@ function ToastItem({
120
129
  }): ReactNode {
121
130
  const t = useTranslation();
122
131
  const learnMore = entry.docsLinkLabel ?? t("kumiko.toast.learn-more");
123
- const variantClass =
124
- entry.variant === "destructive"
125
- ? "destructive group border-destructive bg-destructive text-destructive-foreground"
126
- : "border bg-background text-foreground";
132
+ const variantClass = TONE_CLASS[entry.variant ?? "muted"];
127
133
  return (
128
134
  <Primitive.Root
129
135
  className={cn(rootClass, variantClass)}
@@ -159,7 +165,6 @@ function ToastItem({
159
165
  "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity",
160
166
  "hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1",
161
167
  "group-hover:opacity-100",
162
- "group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50",
163
168
  )}
164
169
  aria-label={t("kumiko.dialog.close")}
165
170
  >
@@ -41,7 +41,7 @@ export function createEventSourceLiveEvents(
41
41
  try {
42
42
  parsed = JSON.parse(raw) as LiveEvent["data"];
43
43
  } catch {
44
- // Malformed payload drop it. Besser als einen fangenden Listener
44
+ // skip: malformed SSE payload, drop it rather than crash all subscribers
45
45
  // zu crashen und alle anderen subscribers mitzureißen.
46
46
  return;
47
47
  }
@@ -52,12 +52,15 @@ export function createEventSourceLiveEvents(
52
52
  };
53
53
 
54
54
  const ensureConnected = (): void => {
55
+ // skip: EventSource already connected
55
56
  if (source !== undefined) return;
57
+ // skip: no window/EventSource available (SSR/non-browser), nothing to connect
56
58
  if (typeof window === "undefined" || typeof EventSource === "undefined") return;
57
59
  source = new EventSource(url);
58
60
  };
59
61
 
60
62
  const ensureListenersForEntity = (entityName: string): void => {
63
+ // skip: not connected yet, listeners get wired once ensureConnected runs
61
64
  if (source === undefined) return;
62
65
  for (const verb of VERBS) {
63
66
  const type = `${entityName}.${verb}`;
@@ -70,7 +73,9 @@ export function createEventSourceLiveEvents(
70
73
  };
71
74
 
72
75
  const closeIfEmpty = (): void => {
76
+ // skip: subscribers remain, connection still needed
73
77
  if (subscribers.size > 0) return;
78
+ // skip: already closed, nothing to close
74
79
  if (source === undefined) return;
75
80
  source.close();
76
81
  source = undefined;
package/src/styles.css CHANGED
@@ -80,6 +80,25 @@
80
80
  --radius-md: calc(var(--radius) - 2px);
81
81
  --radius-lg: var(--radius);
82
82
  --radius-xl: calc(var(--radius) + 4px);
83
+
84
+ /* Semantische Status-Farben (Widgets: StatusBadge, Charts, StatCard-Tones).
85
+ @theme generiert die Utilities (text-status-ok, bg-status-warn/10, …).
86
+ Dark-Werte; Light-Overrides unten. Apps überschreiben in ihrer styles.css. */
87
+ --color-status-ok: #22c55e;
88
+ --color-status-warn: #f59e0b;
89
+ --color-status-bad: #ef4444;
90
+ --color-status-critical: #f87171;
91
+ }
92
+
93
+ /* Card-Chrome-Maße — Framework-Defaults. Eine App überschreibt selektiv in
94
+ ihrer eigenen styles.css (@theme oder :root, kommt in der Cascade nach dem
95
+ @import und gewinnt); nicht-überschriebene bleiben auf diesen Werten.
96
+ Plain :root (nicht @theme) → immer emittiert, kein Tree-Shaking. Defaults
97
+ reproduzieren exakt p-6 / rounded-xl / shadow-sm. */
98
+ :root {
99
+ --card-padding: 1.5rem;
100
+ --card-radius: var(--radius-xl);
101
+ --card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
83
102
  }
84
103
 
85
104
  /* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
@@ -121,6 +140,12 @@
121
140
  --color-sidebar-accent-foreground: hsl(0 0% 9%);
122
141
  --color-sidebar-border: hsl(0 0% 89.8%);
123
142
  --color-sidebar-ring: hsl(0 0% 63%);
143
+
144
+ /* Status-Farben Light — kräftiger/dunkler als die Dark-Werte. */
145
+ --color-status-ok: #16a34a;
146
+ --color-status-warn: #d97706;
147
+ --color-status-bad: #dc2626;
148
+ --color-status-critical: #991b1b;
124
149
  }
125
150
 
126
151
  * {
package/src/tokens.ts CHANGED
@@ -55,6 +55,7 @@ function persistMode(mode: ThemeMode): void {
55
55
  * in der Host-HTML (siehe Header-Kommentar) macht dasselbe synchron
56
56
  * vor dem ersten Paint. */
57
57
  export function applyStoredThemeMode(): void {
58
+ // skip: no document (SSR/non-DOM context), nothing to apply
58
59
  if (typeof document === "undefined") return;
59
60
  let stored: string | null = null;
60
61
  try {
@@ -63,6 +64,7 @@ export function applyStoredThemeMode(): void {
63
64
  // skip: localStorage kann werfen (Private-Mode) — ohne gespeicherte
64
65
  // Wahl bleibt der Server-/HTML-Default stehen.
65
66
  }
67
+ // skip: no valid persisted mode stored, keep server/HTML default
66
68
  if (stored !== "dark" && stored !== "light") return;
67
69
  document.documentElement.classList.toggle("dark", stored === "dark");
68
70
  notifyThemeChange();
@@ -99,12 +101,14 @@ export function useBrowserTokensApi(): TokensApi {
99
101
  tokens: cssVarTokens,
100
102
  mode,
101
103
  setMode: (next) => {
104
+ // skip: no document (SSR/non-DOM context), nothing to toggle
102
105
  if (typeof document === "undefined") return;
103
106
  document.documentElement.classList.toggle("dark", next === "dark");
104
107
  persistMode(next);
105
108
  notifyThemeChange();
106
109
  },
107
110
  toggleMode: () => {
111
+ // skip: no document (SSR/non-DOM context), nothing to toggle
108
112
  if (typeof document === "undefined") return;
109
113
  const nowDark = document.documentElement.classList.toggle("dark");
110
114
  persistMode(nowDark ? "dark" : "light");
@@ -0,0 +1,66 @@
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("fallback renders while image is unloaded", () => {
24
+ render(
25
+ <Avatar>
26
+ <AvatarImage src="/photo.png" alt="User" />
27
+ <AvatarFallback>FB</AvatarFallback>
28
+ </Avatar>,
29
+ );
30
+ // happy-dom never loads images, so AvatarImage never reaches "loaded" —
31
+ // this only exercises the unloaded state, not an actual load failure.
32
+ expect(screen.getByText("FB")).toBeTruthy();
33
+ expect(document.querySelector('[data-slot="avatar-fallback"]')).not.toBeNull();
34
+ expect(document.querySelector('[data-slot="avatar-image"]')).toBeNull();
35
+ });
36
+
37
+ test("AvatarBadge renders inside avatar", () => {
38
+ render(
39
+ <Avatar data-testid="av">
40
+ <AvatarFallback>AB</AvatarFallback>
41
+ <AvatarBadge data-testid="badge">!</AvatarBadge>
42
+ </Avatar>,
43
+ );
44
+ expect(screen.getByTestId("badge")).toBeTruthy();
45
+ expect(document.querySelector('[data-slot="avatar-badge"]')).not.toBeNull();
46
+ });
47
+
48
+ test("AvatarGroup stacks avatars and AvatarGroupCount shows overflow", () => {
49
+ render(
50
+ <AvatarGroup data-testid="group">
51
+ <Avatar>
52
+ <AvatarFallback>A</AvatarFallback>
53
+ </Avatar>
54
+ <Avatar>
55
+ <AvatarFallback>B</AvatarFallback>
56
+ </Avatar>
57
+ <AvatarGroupCount data-testid="count">+3</AvatarGroupCount>
58
+ </AvatarGroup>,
59
+ );
60
+ expect(screen.getByTestId("group")).toBeTruthy();
61
+ expect(screen.getByTestId("count").textContent).toBe("+3");
62
+ expect(document.querySelector('[data-slot="avatar-group"]')).not.toBeNull();
63
+ expect(document.querySelector('[data-slot="avatar-group-count"]')).not.toBeNull();
64
+ });
65
+ });
66
+
@@ -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,115 @@
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 { act, 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", async () => {
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
+ await act(async () => {});
111
+
112
+ expect(fetchSpy).not.toHaveBeenCalled();
113
+ expect(screen.queryByRole("status")).toBeNull();
114
+ });
115
+ });