1060 lines · 34.0 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path";import { EventEmitter } from "node:events"; import { describe, expect, it, afterEach, vi } from "vitest";import { createServer } from "./wsServer";import WebSocket from "ws"; import {  DEFAULT_TERMINAL_ID,  WS_CHANNELS,  WS_METHODS,  type KeybindingsConfig,  type ResolvedKeybindingsConfig,  type WsPush,  type WsResponse,} from "@t3tools/contracts";import { ProjectRegistry } from "./projectRegistry";import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";import type {  TerminalClearInput,  TerminalCloseInput,  TerminalEvent,  TerminalOpenInput,  TerminalResizeInput,  TerminalSessionSnapshot,  TerminalWriteInput,} from "@t3tools/contracts";import type { TerminalManager } from "./terminalManager"; interface PendingMessages {  queue: unknown[];  waiters: Array<(message: unknown) => void>;} const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {  private readonly sessions = new Map<string, TerminalSessionSnapshot>();   private key(threadId: string, terminalId: string): string {    return `${threadId}\u0000${terminalId}`;  }   async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {    const now = new Date().toISOString();    const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;    const snapshot: TerminalSessionSnapshot = {      threadId: input.threadId,      terminalId,      cwd: input.cwd,      status: "running",      pid: 4242,      history: "",      exitCode: null,      exitSignal: null,      updatedAt: now,    };    this.sessions.set(this.key(input.threadId, terminalId), snapshot);    queueMicrotask(() => {      this.emit("event", {        type: "started",        threadId: input.threadId,        terminalId,        createdAt: now,        snapshot,      });    });    return snapshot;  }   async write(input: TerminalWriteInput): Promise<void> {    const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;    const existing = this.sessions.get(this.key(input.threadId, terminalId));    if (!existing) {      throw new Error(`Unknown terminal thread: ${input.threadId}`);    }    queueMicrotask(() => {      this.emit("event", {        type: "output",        threadId: input.threadId,        terminalId,        createdAt: new Date().toISOString(),        data: input.data,      });    });  }   async resize(_input: TerminalResizeInput): Promise<void> {}   async clear(input: TerminalClearInput): Promise<void> {    const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;    queueMicrotask(() => {      this.emit("event", {        type: "cleared",        threadId: input.threadId,        terminalId,        createdAt: new Date().toISOString(),      });    });  }   async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {    const now = new Date().toISOString();    const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;    const snapshot: TerminalSessionSnapshot = {      threadId: input.threadId,      terminalId,      cwd: input.cwd,      status: "running",      pid: 5252,      history: "",      exitCode: null,      exitSignal: null,      updatedAt: now,    };    this.sessions.set(this.key(input.threadId, terminalId), snapshot);    queueMicrotask(() => {      this.emit("event", {        type: "restarted",        threadId: input.threadId,        terminalId,        createdAt: now,        snapshot,      });    });    return snapshot;  }   async close(input: TerminalCloseInput): Promise<void> {    if (input.terminalId) {      this.sessions.delete(this.key(input.threadId, input.terminalId));      return;    }    for (const key of [...this.sessions.keys()]) {      if (key.startsWith(`${input.threadId}\u0000`)) {        this.sessions.delete(key);      }    }  }   dispose(): void {}} function connectWs(port: number, token?: string): Promise<WebSocket> {  return new Promise((resolve, reject) => {    const query = token ? `?token=${encodeURIComponent(token)}` : "";    const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`);    const pending: PendingMessages = { queue: [], waiters: [] };    pendingBySocket.set(ws, pending);     ws.on("message", (raw) => {      const parsed = JSON.parse(String(raw));      const waiter = pending.waiters.shift();      if (waiter) {        waiter(parsed);        return;      }      pending.queue.push(parsed);    });     ws.once("open", () => resolve(ws));    ws.once("error", () => reject(new Error("WebSocket connection failed")));  });} function waitForMessage(ws: WebSocket): Promise<unknown> {  const pending = pendingBySocket.get(ws);  if (!pending) {    return Promise.reject(new Error("WebSocket not initialized"));  }   const queued = pending.queue.shift();  if (queued !== undefined) {    return Promise.resolve(queued);  }   return new Promise((resolve) => {    pending.waiters.push(resolve);  });} async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> {  const id = crypto.randomUUID();  const message = JSON.stringify({ id, method, ...(params !== undefined ? { params } : {}) });  ws.send(message);   // Wait for response with matching id  while (true) {    const parsed = (await waitForMessage(ws)) as Record<string, unknown>;    if (parsed.id === id) {      return parsed as WsResponse;    }  }} function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {  const resolved: ResolvedKeybindingsConfig = [];  for (const binding of bindings) {    const compiled = compileResolvedKeybindingRule(binding);    if (!compiled) {      throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`);    }    resolved.push(compiled);  }  return resolved;} const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig {  if (custom.length === 0) {    return DEFAULT_RESOLVED_KEYBINDINGS;  }   const overriddenCommands = new Set(custom.map((binding) => binding.command));  const retainedDefaults = DEFAULT_KEYBINDINGS.filter(    (binding) => !overriddenCommands.has(binding.command),  );  return compileKeybindings([...retainedDefaults, ...custom].slice(-256));} describe("WebSocket Server", () => {  let server: ReturnType<typeof createServer> | null = null;  const connections: WebSocket[] = [];  const tempDirs: string[] = [];   function makeTempDir(prefix: string): string {    const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));    tempDirs.push(dir);    return dir;  }   function createTestServer(    options: {      cwd?: string;      devUrl?: string;      authToken?: string;      stateDir?: string;      gitManager?: {        status: (input: { cwd: string }) => Promise<unknown>;        runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;      };      terminalManager?: TerminalManager;    } = {},  ): ReturnType<typeof createServer> {    const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");    return createServer({      port: 0,      cwd: options.cwd ?? "/test/project",      ...(options.devUrl ? { devUrl: options.devUrl } : {}),      ...(options.authToken ? { authToken: options.authToken } : {}),      projectRegistry: new ProjectRegistry(stateDir),      ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),      ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),    });  }   afterEach(async () => {    for (const ws of connections) {      ws.close();    }    connections.length = 0;    if (server) {      await server.stop();    }    server = null;    for (const dir of tempDirs.splice(0, tempDirs.length)) {      fs.rmSync(dir, { recursive: true, force: true });    }    vi.restoreAllMocks();  });   it("sends welcome message on connect", async () => {    server = createTestServer({ cwd: "/test/project" });    // Get the actual port after listen    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;    expect(port).toBeGreaterThan(0);     const ws = await connectWs(port);    connections.push(ws);     const message = (await waitForMessage(ws)) as WsPush;    expect(message.type).toBe("push");    expect(message.channel).toBe(WS_CHANNELS.serverWelcome);    expect(message.data).toEqual({      cwd: "/test/project",      projectName: "project",    });  });   it("logs outbound websocket push events in dev mode", async () => {    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {      // Keep test output clean while verifying websocket logs.    });     server = createTestServer({      cwd: "/test/project",      devUrl: "http://localhost:5173",    });    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;    expect(port).toBeGreaterThan(0);     const ws = await connectWs(port);    connections.push(ws);    await waitForMessage(ws);     expect(      logSpy.mock.calls.some(([message]) => {        if (typeof message !== "string") return false;        return (          message.includes("[ws]") &&          message.includes("outgoing push") &&          message.includes(`channel="${WS_CHANNELS.serverWelcome}"`)        );      }),    ).toBe(true);  });   it("responds to server.getConfig", async () => {    const fakeHome = makeTempDir("t3code-home-");    vi.spyOn(os, "homedir").mockReturnValue(fakeHome);    server = createTestServer({ cwd: "/my/workspace" });    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;     const ws = await connectWs(port);    connections.push(ws);     // Consume welcome message    await waitForMessage(ws);     const response = await sendRequest(ws, WS_METHODS.serverGetConfig);    expect(response.error).toBeUndefined();    expect(response.result).toEqual({      cwd: "/my/workspace",      keybindings: DEFAULT_RESOLVED_KEYBINDINGS,    });  });   it("reads keybindings from ~/.t3/keybindings.json", async () => {    const fakeHome = makeTempDir("t3code-home-");    const configDir = path.join(fakeHome, ".t3");    fs.mkdirSync(configDir, { recursive: true });    fs.writeFileSync(      path.join(configDir, "keybindings.json"),      JSON.stringify([        { key: "cmd+j", command: "terminal.toggle" },        { key: "mod+d", command: "terminal.split", when: "terminalFocus" },        { key: "mod+n", command: "terminal.new", when: "terminalFocus" },      ]),      "utf8",    );    vi.spyOn(os, "homedir").mockReturnValue(fakeHome);     server = createTestServer({ cwd: "/my/workspace" });    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;     const ws = await connectWs(port);    connections.push(ws);     await waitForMessage(ws);     const response = await sendRequest(ws, WS_METHODS.serverGetConfig);    expect(response.error).toBeUndefined();    expect(response.result).toEqual({      cwd: "/my/workspace",      keybindings: mergeWithDefaultsForTest([        { key: "cmd+j", command: "terminal.toggle" },        { key: "mod+d", command: "terminal.split", when: "terminalFocus" },        { key: "mod+n", command: "terminal.new", when: "terminalFocus" },      ]),    });  });   it("warns and ignores invalid keybinding entries", async () => {    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});    const fakeHome = makeTempDir("t3code-home-invalid-entry-");    const configDir = path.join(fakeHome, ".t3");    fs.mkdirSync(configDir, { recursive: true });    fs.writeFileSync(      path.join(configDir, "keybindings.json"),      JSON.stringify([        { key: "mod+j", command: "terminal.toggle" },        { key: "mod+z", command: "invalid.command", when: "terminalFocus" },      ]),      "utf8",    );    vi.spyOn(os, "homedir").mockReturnValue(fakeHome);     server = createTestServer({ cwd: "/my/workspace" });    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;     const ws = await connectWs(port);    connections.push(ws);     await waitForMessage(ws);     const response = await sendRequest(ws, WS_METHODS.serverGetConfig);    expect(response.error).toBeUndefined();    expect(response.result).toEqual({      cwd: "/my/workspace",      keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]),    });    expect(      warnSpy.mock.calls.some(([message]) =>        String(message).includes("ignoring invalid keybinding entries"),      ),    ).toBe(true);  });   it("warns and ignores keybindings with malformed when expressions", async () => {    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});    const fakeHome = makeTempDir("t3code-home-invalid-when-");    const configDir = path.join(fakeHome, ".t3");    fs.mkdirSync(configDir, { recursive: true });    fs.writeFileSync(      path.join(configDir, "keybindings.json"),      JSON.stringify([{ key: "mod+j", command: "terminal.toggle", when: "terminalFocus && (" }]),      "utf8",    );    vi.spyOn(os, "homedir").mockReturnValue(fakeHome);     server = createTestServer({ cwd: "/my/workspace" });    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;     const ws = await connectWs(port);    connections.push(ws);     await waitForMessage(ws);     const response = await sendRequest(ws, WS_METHODS.serverGetConfig);    expect(response.error).toBeUndefined();    expect(response.result).toEqual({      cwd: "/my/workspace",      keybindings: DEFAULT_RESOLVED_KEYBINDINGS,    });    expect(      warnSpy.mock.calls.some(([message]) =>        String(message).includes("ignoring invalid keybinding entries"),      ),    ).toBe(true);  });   it("reads keybindings once at startup and caches the resolved config", async () => {    const fakeHome = makeTempDir("t3code-home-cached-config-");    const configDir = path.join(fakeHome, ".t3");    fs.mkdirSync(configDir, { recursive: true });    const keybindingsPath = path.join(configDir, "keybindings.json");    fs.writeFileSync(      keybindingsPath,      JSON.stringify([{ key: "cmd+j", command: "terminal.toggle" }]),      "utf8",    );    vi.spyOn(os, "homedir").mockReturnValue(fakeHome);     server = createTestServer({ cwd: "/my/workspace" });    await server.start();    const addr = server.httpServer.address();    const port = typeof addr === "object" && addr !== null ? addr.port : 0;     const ws = await connectWs(port);    connections.push(ws);    await waitForMessage(ws);     const firstResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);    expect(firstResponse.error).toBeUndefined();    expect(firstResponse.result).toEqual({      cwd: "/my/workspace",      keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]),    });     fs.writeFileSync(      keybindingsPath,      JSON.stringify([{ key: "cmd+k", command: "terminal.toggle" }]),      "utf8",    );     const secondResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);    expect(secondResponse.error).toBeUndefined();    expect(secondResponse.result).toEqual(firstResponse.result);  });   it("upserts keybinding rules and updates cached server config", async () => {    const fakeHome = makeTempDir("t3code-home-upsert-keybinding-");    const configDir = path.join(fakeHome, ".t3");    fs.mkdirSync(configDir, { recursive: true });    const keybindingsPath = path.join(configDir, "keybindings.json");

Lines 1–500 of 1060.