/** * Contract / regression tests for the Tauri terminal adapter. * * The headline test guards the keystroke-ordering bug: `write` must serialise * per handle so bytes reach the backend in call order even when the underlying * `invoke`s resolve out of order (Tauri's IPC gives no ordering guarantee for * concurrent calls). Without serialisation, fast typing/pasting garbles the CLI * input. */ import { describe, it, expect, vi, beforeEach } from "vitest"; const invoke = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args), Channel: class { onmessage: ((c: number[]) => void) | null = null; }, })); import { Channel } from "@tauri-apps/api/core"; import { makeTerminalHandle } from "./terminal"; const dec = new TextDecoder(); function handle() { return makeTerminalHandle("sess-1", new Channel()); } describe("TauriTerminalGateway write ordering (regression)", () => { beforeEach(() => invoke.mockReset()); it("delivers bytes to the backend in call order despite out-of-order resolution", async () => { // Each `write_terminal` invoke resolves after a delay that is the INVERSE of // its arrival order, so the *first* call resolves last. If writes were // fire-and-forget (no chaining), the backend would observe them reversed. const arrivals: string[] = []; let order = 0; invoke.mockImplementation((cmd: string, payload: { request: { data: number[] } }) => { if (cmd !== "write_terminal") return Promise.resolve(); const callIndex = order++; const delay = (4 - callIndex) * 10; // 1st call → longest delay return new Promise((resolve) => { setTimeout(() => { arrivals.push(dec.decode(Uint8Array.from(payload.request.data))); resolve(); }, delay); }); }); const h = handle(); const enc = new TextEncoder(); // Fire five writes back-to-back, as fast typing would. const writes = ["a", "b", "c", "d", "e"].map((c) => h.write(enc.encode(c))); await Promise.all(writes); // Backend stdin order MUST equal the order `write` was called. expect(arrivals).toEqual(["a", "b", "c", "d", "e"]); }); it("a rejected write does not block subsequent writes (chain survives errors)", async () => { const arrivals: string[] = []; let call = 0; invoke.mockImplementation( (_cmd: string, payload?: { request: { data: number[] } }) => { // Only `write_terminal` calls carry a payload; ignore any bare probe. if (!payload) return Promise.resolve(); call++; if (call === 1) return Promise.reject(new Error("boom")); arrivals.push(dec.decode(Uint8Array.from(payload.request.data))); return Promise.resolve(); }, ); const h = handle(); const enc = new TextEncoder(); const first = h.write(enc.encode("x")); // rejects const second = h.write(enc.encode("y")); // must still run, in order // The rejected write surfaces its error to its own caller… let firstErr: unknown; await first.catch((e) => { firstErr = e; }); expect(firstErr).toBeInstanceOf(Error); // …but the chain survives, so the next write still reaches the backend. await second; expect(arrivals).toEqual(["y"]); }); it("write nests sessionId + data array inside the request DTO", async () => { invoke.mockResolvedValue(undefined); const h = handle(); await h.write(Uint8Array.from([104, 105])); // "hi" expect(invoke).toHaveBeenCalledWith("write_terminal", { request: { sessionId: "sess-1", data: [104, 105] }, }); }); it("resize forwards rows/cols inside the request DTO", async () => { invoke.mockResolvedValue(undefined); const h = handle(); await h.resize(40, 120); expect(invoke).toHaveBeenCalledWith("resize_terminal", { request: { sessionId: "sess-1", rows: 40, cols: 120 }, }); }); });