@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.
- 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__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
package/src/primitives/toast.tsx
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
// Auto-dismiss nach 5s, manuell schließbar via X-Button.
|
|
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 =
|
|
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
|
>
|
package/src/sse/live-events.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function createEventSourceLiveEvents(
|
|
|
41
41
|
try {
|
|
42
42
|
parsed = JSON.parse(raw) as LiveEvent["data"];
|
|
43
43
|
} catch {
|
|
44
|
-
//
|
|
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
|
+
});
|