import "../index.css"; import { ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, type ProjectId, type ServerConfig, type ThreadId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { ws, http, HttpResponse } from "msw"; import { setupWorker } from "msw/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; import { getRouter } from "../router"; import { useStore } from "../store"; const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: WsWelcomePayload; } let fixture: TestFixture; let wsClient: { send: (data: string) => void } | null = null; let pushSequence = 1; const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], issues: [], providers: [ { provider: "codex", status: "ready", available: true, authStatus: "authenticated", checkedAt: NOW_ISO, }, ], availableEditors: [], }; } function createMinimalSnapshot(): OrchestrationReadModel { return { snapshotSequence: 1, projects: [ { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", defaultModel: "gpt-5", scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, }, ], threads: [ { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", branch: "main", worktreePath: null, latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, messages: [ { id: "msg-1" as MessageId, role: "user", text: "hello", turnId: null, streaming: false, createdAt: NOW_ISO, updatedAt: NOW_ISO, }, ], activities: [], proposedPlans: [], workUnits: [], checkpoints: [], session: { threadId: THREAD_ID, status: "ready", providerName: "codex", runtimeMode: "full-access", activeTurnId: null, lastError: null, updatedAt: NOW_ISO, }, }, ], updatedAt: NOW_ISO, }; } function buildFixture(): TestFixture { return { snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), welcome: { cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, bootstrapThreadId: THREAD_ID, }, }; } function resolveWsRpc(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, hasOriginRemote: true, branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } if (tag === WS_METHODS.gitStatus) { return { branch: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, aheadCount: 0, behindCount: 0, pr: null, }; } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false }; } return {}; } const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { wsClient = client; pushSequence = 1; client.send( JSON.stringify({ type: "push", sequence: pushSequence++, channel: WS_CHANNELS.serverWelcome, data: fixture.welcome, }), ); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; let request: { id: string; body: { _tag: string; [key: string]: unknown } }; try { request = JSON.parse(rawData); } catch { return; } const method = request.body?._tag; if (typeof method !== "string") return; client.send( JSON.stringify({ id: request.id, result: resolveWsRpc(method), }), ); }); }), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) { if (!wsClient) throw new Error("WebSocket client not connected"); wsClient.send( JSON.stringify({ type: "push", sequence: pushSequence++, channel: WS_CHANNELS.serverConfigUpdated, data: { issues, providers: fixture.serverConfig.providers, }, }), ); } function queryToastTitles(): string[] { return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map( (el) => el.textContent ?? "", ); } async function waitForElement( query: () => T | null, errorMessage: string, ): Promise { let element: T | null = null; await vi.waitFor( () => { element = query(); expect(element, errorMessage).toBeTruthy(); }, { timeout: 8_000, interval: 16 }, ); return element!; } async function waitForComposerEditor(): Promise { return waitForElement( () => document.querySelector('[data-testid="composer-editor"]'), "App should render composer editor", ); } async function waitForToast(title: string, count = 1): Promise { await vi.waitFor( () => { const matches = queryToastTitles().filter((t) => t === title); expect(matches.length, `Expected ${count} "${title}" toast(s)`).toBeGreaterThanOrEqual(count); }, { timeout: 4_000, interval: 16 }, ); } async function waitForNoToast(title: string): Promise { await vi.waitFor( () => { expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); }, { timeout: 10_000, interval: 50 }, ); } async function mountApp(): Promise<{ cleanup: () => Promise }> { const host = document.createElement("div"); host.style.position = "fixed"; host.style.inset = "0"; host.style.width = "100vw"; host.style.height = "100vh"; host.style.display = "grid"; host.style.overflow = "hidden"; document.body.append(host); const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); const screen = await render(, { container: host }); await waitForComposerEditor(); return { cleanup: async () => { await screen.unmount(); host.remove(); }, }; } describe("Keybindings update toast", () => { beforeAll(async () => { fixture = buildFixture(); await worker.start({ onUnhandledRequest: "bypass", quiet: true, serviceWorker: { url: "/mockServiceWorker.js" }, }); }); afterAll(async () => { await worker.stop(); }); beforeEach(() => { localStorage.clear(); document.body.innerHTML = ""; pushSequence = 1; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); useStore.setState({ projects: [], threads: [], threadsHydrated: false, }); }); afterEach(() => { document.body.innerHTML = ""; }); it("shows a toast for each consecutive keybinding update with no issues", async () => { const mounted = await mountApp(); try { sendServerConfigUpdatedPush([]); await waitForToast("Keybindings updated", 1); // Each server push represents a distinct file change, so it should produce its own toast. sendServerConfigUpdatedPush([]); await waitForToast("Keybindings updated", 2); } finally { await mounted.cleanup(); } }); it("shows a warning toast when keybinding config has issues", async () => { const mounted = await mountApp(); try { sendServerConfigUpdatedPush([ { kind: "keybindings.malformed-config", message: "Expected JSON array" }, ]); await waitForToast("Invalid keybindings configuration"); } finally { await mounted.cleanup(); } }); it("does not show a toast from the replayed cached value on subscribe", async () => { const mounted = await mountApp(); try { sendServerConfigUpdatedPush([]); await waitForToast("Keybindings updated"); await waitForNoToast("Keybindings updated"); // Remount the app — onServerConfigUpdated replays the cached value // synchronously on subscribe. This should NOT produce a toast. await mounted.cleanup(); const remounted = await mountApp(); // Give it a moment to process the replayed value await new Promise((resolve) => setTimeout(resolve, 500)); const titles = queryToastTitles(); expect( titles.filter((t) => t === "Keybindings updated").length, "Replayed cached value should not produce a toast", ).toBe(0); await remounted.cleanup(); } catch (error) { await mounted.cleanup().catch(() => {}); throw error; } }); });