diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 16:29:01 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 16:29:01 +0900 |
| commit | 871957b930203c019e631c4606cfdf8266d222fa (patch) | |
| tree | 50c522018c3ce4127ffa76f4b3b6c7843e90db43 /src/app/App.test.ts | |
| parent | 7b345f132763fa6405ae858b74e46229629c19d9 (diff) | |
| download | dispatch-web-871957b930203c019e631c4606cfdf8266d222fa.tar.gz dispatch-web-871957b930203c019e631c4606cfdf8266d222fa.zip | |
feat(views,surface-host): Extensions sidebar view — auto-expanded surfaces + tables
views (new feature):
- pure panel-stack reducer + thin generic ViewSidebar (dropdown picker + add/remove),
switches on view KIND, never a surface id
Extensions view (composition root):
- folds frontend modules + backend surfaces into one "Extensions" view
- frontend module list AGGREGATED from each feature's public `manifest` export
(can't drift); no per-module version (FE features are internal to dispatch-web)
- surfaces are AUTO-SUBSCRIBED on catalog + rendered expanded (no catalog buttons)
surface-host:
- consecutive `stat` fields coalesce into one aligned label/value table (StatTable)
- generic custom-field renderer: dispatch on rendererId === "table" → SurfaceTable
(pure parseTablePayload), so a backend `custom`/table field renders generically
- shared presentational components/Table.svelte (used by both, neither feature
depends on the other)
store:
- auto-subscribe every catalog entry, unsubscribe vanished ones, re-subscribe all
on reconnect; expose all received specs via `surfaces` (drops single-selection)
backend-handoff: CR-1 — emit Loaded Extensions as a custom/table field; notes
what's already covered FE-side (renderer shipped, stat-table fallback works).
Diffstat (limited to 'src/app/App.test.ts')
| -rw-r--r-- | src/app/App.test.ts | 175 |
1 files changed, 83 insertions, 92 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 8110d41..121bd20 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,6 +1,6 @@ import type { WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { render, screen, within } from "@testing-library/svelte"; +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"; @@ -119,7 +119,7 @@ describe("App component interaction tests", () => { store.dispose(); }); - it("renders catalog buttons when surfaces are available", () => { + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -128,6 +128,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); + ws.sent.length = 0; ws.feedSurfaceMessage({ type: "catalog", catalog: [ @@ -138,17 +139,16 @@ describe("App component interaction tests", () => { render(App, { props: { store } }); - const surfacesSection = screen.getByRole("heading", { name: "Surfaces" }).closest("section"); - if (surfacesSection === null) throw new Error("Surfaces section not found"); - const buttons = within(surfacesSection).getAllByRole("button"); - expect(buttons).toHaveLength(2); - expect(buttons[0]).toHaveTextContent("Surface One"); - expect(buttons[1]).toHaveTextContent("Surface Two"); + const subscribed = sentMessages(ws) + .filter((m: { type: string }) => m.type === "subscribe") + .map((m: { surfaceId: string }) => m.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); store.dispose(); }); - it("clicking a catalog entry subscribes and renders its surface", async () => { + it("renders every surface expanded once their specs arrive", async () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, @@ -159,22 +159,15 @@ describe("App component interaction tests", () => { ws.feedSurfaceMessage({ type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], }); 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(); - + // No interaction: specs arrive and both surfaces render expanded. ws.feedSurfaceMessage({ type: "surface", spec: { @@ -184,83 +177,19 @@ describe("App component interaction tests", () => { fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], }, }); + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Surface Two" })).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, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - 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, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - 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({ @@ -300,8 +229,7 @@ describe("App component interaction tests", () => { render(App, { props: { store } }); const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: /Surface One/ })); - + // Surface is auto-subscribed; its spec arrives and renders expanded. ws.feedSurfaceMessage({ type: "surface", spec: { @@ -403,4 +331,67 @@ describe("App component interaction tests", () => { store.dispose(); }); + + it("renders a custom 'table' field of a surface as a table", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + // Auto-subscribed; the custom-table spec arrives and renders expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "custom", + rendererId: "table", + payload: { + columns: ["Name", "Scope"], + rows: [["cache-warm", "backend"]], + }, + }, + ], + }, + }); + + expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument(); + expect(await screen.findByText("cache-warm")).toBeInTheDocument(); + expect(await screen.findByText("backend")).toBeInTheDocument(); + + store.dispose(); + }); + + it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // Extensions is the default view, so the modules table renders immediately. + expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); + for (const name of ["chat", "tabs", "surface-host", "views", "conversation-cache"]) { + expect(screen.getByRole("cell", { name })).toBeInTheDocument(); + } + + store.dispose(); + }); }); |
