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: [], 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<T extends Element>( query: () => T | null, errorMessage: string,): Promise<T> { 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<HTMLElement> { return waitForElement( () => document.querySelector<HTMLElement>('[data-testid="composer-editor"]'), "App should render composer editor", );} async function waitForToast(title: string, count = 1): Promise<void> { 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<void> { await vi.waitFor( () => { expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); }, { timeout: 10_000, interval: 50 }, );} async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { 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(<RouterProvider router={router} />, { 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; } });});
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: [],
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<T extends Element>(
query: () => T | null,
errorMessage: string,
): Promise<T> {
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<HTMLElement> {
return waitForElement(
() => document.querySelector<HTMLElement>('[data-testid="composer-editor"]'),
"App should render composer editor",
);
}
async function waitForToast(title: string, count = 1): Promise<void> {
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<void> {
await vi.waitFor(
() => {
expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0);
},
{ timeout: 10_000, interval: 50 },
);
}
async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
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(<RouterProvider router={router} />, { 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;
}
});
});