import "../index.css"; import { DEFAULT_SERVER_SETTINGS, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, type ProjectId, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, 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 { __resetNativeApiForTests } from "../nativeApi";import { AppAtomRegistryProvider } from "../rpc/atomRegistry";import { getServerConfig } from "../rpc/serverState";import { getRouter } from "../router";import { useStore } from "../store";import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; vi.mock("../lib/gitStatusState", () => ({ useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), useGitStatuses: () => new Map(), refreshGitStatus: () => Promise.resolve(null), resetGitStatusStateForTests: () => undefined,})); 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: ServerLifecycleWelcomePayload;} let fixture: TestFixture;const rpcHarness = new BrowserWsRpcHarness(); const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], issues: [], providers: [ { provider: "codex", enabled: true, installed: true, version: "0.116.0", status: "ready", auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], }, ], availableEditors: [], observability: { logsDirectoryPath: "/repo/project/.t3/logs", localTracingEnabled: true, otlpTracesEnabled: false, otlpMetricsEnabled: false, }, settings: { ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, }, }, };} function createMinimalSnapshot(): OrchestrationReadModel { return { snapshotSequence: 1, projects: [ { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { provider: "codex", model: "gpt-5", }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, }, ], threads: [ { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", modelSelection: { provider: "codex", model: "gpt-5", }, interactionMode: "default", runtimeMode: "full-access", branch: "main", worktreePath: null, latestTurn: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, archivedAt: null, 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, nextCursor: null, totalCount: 1, branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false }; } return {};} const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { void rpcHarness.connect(client); client.addEventListener("message", (event) => { const rawData = event.data; if (typeof rawData !== "string") return; void rpcHarness.onMessage(rawData); }); }), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),); function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { version: 1, type: "keybindingsUpdated", payload: { issues }, });} 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 waitForInitialWsSubscriptions(): Promise<void> { await vi.waitFor( () => { expect( rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), ).toBe(true); expect( rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), ).toBe(true); }, { timeout: 8_000, interval: 16 }, );} async function waitForServerConfigSnapshot(): Promise<void> { await vi.waitFor( () => { expect(getServerConfig()).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, );} 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( <AppAtomRegistryProvider> <RouterProvider router={router} /> </AppAtomRegistryProvider>, { container: host }, ); await waitForComposerEditor(); await waitForInitialWsSubscriptions(); await waitForServerConfigSnapshot(); 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 rpcHarness.disconnect(); await worker.stop(); }); beforeEach(async () => { await rpcHarness.reset({ resolveUnary: (request) => resolveWsRpc(request._tag), getInitialStreamValues: (request) => { if (request._tag === WS_METHODS.subscribeServerLifecycle) { return [ { version: 1, sequence: 1, type: "welcome", payload: fixture.welcome, }, ]; } if (request._tag === WS_METHODS.subscribeServerConfig) { return [ { version: 1, type: "snapshot", config: fixture.serverConfig, }, ]; } return []; }, }); await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); useStore.setState({ projects: [], threads: [], bootstrapComplete: 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 {
DEFAULT_SERVER_SETTINGS,
ORCHESTRATION_WS_METHODS,
type MessageId,
type OrchestrationReadModel,
type ProjectId,
type ServerConfig,
type ServerLifecycleWelcomePayload,
type ThreadId,
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 { __resetNativeApiForTests } from "../nativeApi";
import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
import { getServerConfig } from "../rpc/serverState";
import { getRouter } from "../router";
import { useStore } from "../store";
import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
vi.mock("../lib/gitStatusState", () => ({
useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }),
useGitStatuses: () => new Map(),
refreshGitStatus: () => Promise.resolve(null),
resetGitStatusStateForTests: () => undefined,
}));
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: ServerLifecycleWelcomePayload;
}
let fixture: TestFixture;
const rpcHarness = new BrowserWsRpcHarness();
const wsLink = ws.link(/ws(s)?:\/\/.*/);
function createBaseServerConfig(): ServerConfig {
return {
cwd: "/repo/project",
keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
keybindings: [],
issues: [],
providers: [
{
provider: "codex",
enabled: true,
installed: true,
version: "0.116.0",
status: "ready",
auth: { status: "authenticated" },
checkedAt: NOW_ISO,
models: [],
},
],
availableEditors: [],
observability: {
logsDirectoryPath: "/repo/project/.t3/logs",
localTracingEnabled: true,
otlpTracesEnabled: false,
otlpMetricsEnabled: false,
},
settings: {
...DEFAULT_SERVER_SETTINGS,
enableAssistantStreaming: false,
defaultThreadEnvMode: "local" as const,
textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" },
providers: {
codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
claudeAgent: { enabled: true, binaryPath: "", customModels: [] },
},
},
};
}
function createMinimalSnapshot(): OrchestrationReadModel {
return {
snapshotSequence: 1,
projects: [
{
id: PROJECT_ID,
title: "Project",
workspaceRoot: "/repo/project",
defaultModelSelection: {
provider: "codex",
model: "gpt-5",
},
scripts: [],
createdAt: NOW_ISO,
updatedAt: NOW_ISO,
deletedAt: null,
},
],
threads: [
{
id: THREAD_ID,
projectId: PROJECT_ID,
title: "Test thread",
modelSelection: {
provider: "codex",
model: "gpt-5",
},
interactionMode: "default",
runtimeMode: "full-access",
branch: "main",
worktreePath: null,
latestTurn: null,
createdAt: NOW_ISO,
updatedAt: NOW_ISO,
archivedAt: null,
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,
nextCursor: null,
totalCount: 1,
branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
};
}
if (tag === WS_METHODS.projectsSearchEntries) {
return { entries: [], truncated: false };
}
return {};
}
const worker = setupWorker(
wsLink.addEventListener("connection", ({ client }) => {
void rpcHarness.connect(client);
client.addEventListener("message", (event) => {
const rawData = event.data;
if (typeof rawData !== "string") return;
void rpcHarness.onMessage(rawData);
});
}),
http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
);
function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
version: 1,
type: "keybindingsUpdated",
payload: { issues },
});
}
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 waitForInitialWsSubscriptions(): Promise<void> {
await vi.waitFor(
() => {
expect(
rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle),
).toBe(true);
expect(
rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig),
).toBe(true);
},
{ timeout: 8_000, interval: 16 },
);
}
async function waitForServerConfigSnapshot(): Promise<void> {
await vi.waitFor(
() => {
expect(getServerConfig()).not.toBeNull();
},
{ timeout: 8_000, interval: 16 },
);
}
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(
<AppAtomRegistryProvider>
<RouterProvider router={router} />
</AppAtomRegistryProvider>,
{ container: host },
);
await waitForComposerEditor();
await waitForInitialWsSubscriptions();
await waitForServerConfigSnapshot();
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 rpcHarness.disconnect();
await worker.stop();
});
beforeEach(async () => {
await rpcHarness.reset({
resolveUnary: (request) => resolveWsRpc(request._tag),
getInitialStreamValues: (request) => {
if (request._tag === WS_METHODS.subscribeServerLifecycle) {
return [
{
version: 1,
sequence: 1,
type: "welcome",
payload: fixture.welcome,
},
];
}
if (request._tag === WS_METHODS.subscribeServerConfig) {
return [
{
version: 1,
type: "snapshot",
config: fixture.serverConfig,
},
];
}
return [];
},
});
await __resetNativeApiForTests();
localStorage.clear();
document.body.innerHTML = "";
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
useStore.setState({
projects: [],
threads: [],
bootstrapComplete: 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;
}
});
});