summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock3
-rw-r--r--package.json1
-rw-r--r--src/app/App.test.ts259
-rw-r--r--vite.config.ts11
4 files changed, 268 insertions, 6 deletions
diff --git a/bun.lock b/bun.lock
index a9725c7..6689953 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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"],
- },
},
});