@cosmicdrift/kumiko-renderer-web 0.159.1 → 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 +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/ui/__tests__/avatar.test.tsx +64 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
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
|
+
});
|
|
@@ -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,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
|
+
});
|