diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 22:24:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 22:24:20 +0900 |
| commit | f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa (patch) | |
| tree | a035b2e2d2cb122cdd54c841d87e45965746e2c5 | |
| parent | e1c8cf3257cb33457aa882c548f5195ecc0f9854 (diff) | |
| download | dispatch-web-f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa.tar.gz dispatch-web-f1409cd46d5a3cfb9002cbcdfd4ab947ac6846aa.zip | |
Slice 1 follow-up: component-render interaction tests (CR-1/CR-2)
- vite: add @testing-library/svelte's svelteTesting() plugin so component
render()/mount() resolves Svelte's browser build under vitest/jsdom
- dep: @testing-library/user-event for realistic interaction tests
- app: 7 component-render tests driving App.svelte through a fake socket
(catalog render, subscribe-on-click, unsub/sub ordering, aria-current,
error banner, action invoke)
Verified green: svelte-check 0/0, vitest 91 passed, biome clean, vite build ok.
| -rw-r--r-- | bun.lock | 3 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/app/App.test.ts | 259 | ||||
| -rw-r--r-- | vite.config.ts | 11 |
4 files changed, 268 insertions, 6 deletions
@@ -12,6 +12,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@testing-library/jest-dom": "^6.6.0", "@testing-library/svelte": "^5.2.0", + "@testing-library/user-event": "^14.6.1", "@tsconfig/svelte": "^5.0.0", "jsdom": "^25.0.0", "svelte": "^5.0.0", @@ -191,6 +192,8 @@ "@testing-library/svelte-core": ["@testing-library/[email protected]", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="], + "@testing-library/user-event": ["@testing-library/[email protected]", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@tsconfig/svelte": ["@tsconfig/[email protected]", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="], "@types/aria-query": ["@types/[email protected]", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/package.json b/package.json index 244baf1..cf856d3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@testing-library/jest-dom": "^6.6.0", "@testing-library/svelte": "^5.2.0", + "@testing-library/user-event": "^14.6.1", "@tsconfig/svelte": "^5.0.0", "jsdom": "^25.0.0", "svelte": "^5.0.0", diff --git a/src/app/App.test.ts b/src/app/App.test.ts new file mode 100644 index 0000000..ce37586 --- /dev/null +++ b/src/app/App.test.ts @@ -0,0 +1,259 @@ +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { WebSocketLike } from "../adapters/ws"; +import App from "./App.svelte"; +import { createAppStore } from "./store.svelte"; + +interface FakeSocket extends WebSocketLike { + sent: string[]; + resolveOpen(): void; + feedMessage(data: SurfaceServerMessage): void; +} + +function fakeSocket(): FakeSocket { + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; +} + +function sentMessages(ws: FakeSocket) { + return ws.sent.map((s) => JSON.parse(s)); +} + +describe("App component interaction tests", () => { + it("renders empty state when catalog is empty", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + expect(screen.getByText("No surfaces available")).toBeInTheDocument(); + + store.dispose(); + }); + + it("renders a catalog button per entry after a catalog message", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent("Surface One"); + expect(buttons[1]).toHaveTextContent("Surface Two"); + + store.dispose(); + }); + + it("clicking a catalog entry subscribes and renders its surface", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + const button = screen.getByRole("button", { name: /Surface One/ }); + ws.sent.length = 0; + await user.click(button); + + const msgs = sentMessages(ws); + const subscribe = msgs.find( + (m: { type: string; surfaceId: string }) => m.type === "subscribe" && m.surfaceId === "s1", + ); + expect(subscribe).toBeTruthy(); + + ws.feedMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + + expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); + expect(await screen.findByText("Tokens")).toBeInTheDocument(); + expect(await screen.findByText("1,234")).toBeInTheDocument(); + + store.dispose(); + }); + + it("clicking a different entry unsubscribes the previous then subscribes the new", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /Surface One/ })); + ws.sent.length = 0; + + await user.click(screen.getByRole("button", { name: /Surface Two/ })); + + const msgs = sentMessages(ws) as Array<{ type: string; surfaceId: string }>; + const unsubIdx = msgs.findIndex((m) => m.type === "unsubscribe" && m.surfaceId === "s1"); + const subIdx = msgs.findIndex((m) => m.type === "subscribe" && m.surfaceId === "s2"); + expect(unsubIdx).toBeGreaterThanOrEqual(0); + expect(subIdx).toBeGreaterThanOrEqual(0); + expect(unsubIdx).toBeLessThan(subIdx); + + store.dispose(); + }); + + it("selected catalog button reflects aria-current", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + const btn1 = screen.getByRole("button", { name: /Surface One/ }); + const btn2 = screen.getByRole("button", { name: /Surface Two/ }); + + await user.click(btn1); + expect(btn1).toHaveAttribute("aria-current", "true"); + expect(btn2).not.toHaveAttribute("aria-current"); + + await user.click(btn2); + expect(btn2).toHaveAttribute("aria-current", "true"); + expect(btn1).not.toHaveAttribute("aria-current"); + + store.dispose(); + }); + + it("an error message renders the alert banner", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "error", + message: "Something went wrong", + }); + + render(App, { props: { store } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something went wrong"); + + store.dispose(); + }); + + it("invoking a field action sends an invoke", async () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /Surface One/ })); + + ws.feedMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "toggle", + label: "Dark Mode", + value: false, + action: { actionId: "toggle-dark" }, + }, + ], + }, + }); + + ws.sent.length = 0; + const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" }); + await user.click(checkbox); + + const msgs = sentMessages(ws); + const invoke = msgs.find( + (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) => + m.type === "invoke" && + m.surfaceId === "s1" && + m.actionId === "toggle-dark" && + m.payload === true, + ); + expect(invoke).toBeTruthy(); + + store.dispose(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index fab62a0..5ff83cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,14 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { svelteTesting } from "@testing-library/svelte/vite"; import { defineConfig } from "vitest/config"; // Dev server on the reserved FRONTEND_PORT (24204). Vitest config lives here too // (jsdom + globals) so component tests run without extra config. export default defineConfig({ - plugins: [svelte()], + // svelteTesting() forces Svelte's `browser` resolve condition under vitest so + // component render()/mount() works in jsdom (a plain test.resolve.conditions + // does not propagate to Vite's SSR resolution — sveltejs/svelte#11394). + plugins: [svelte(), svelteTesting()], // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN / // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true` // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set @@ -14,10 +18,5 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./vitest-setup.ts"], - // Svelte 5's exports map resolves `svelte` → server build under the default - // condition; force the browser build so component tests can mount(). - resolve: { - conditions: ["browser"], - }, }, }); |
