From 51382c10828834c293c1027dc9774e71b0be4a7a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:46:39 +0900 Subject: [PATCH] test(team-mode): add live tmux smoke test for team layout --- .../team-layout-tmux/live-tmux-smoke.test.ts | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 src/features/team-mode/team-layout-tmux/live-tmux-smoke.test.ts diff --git a/src/features/team-mode/team-layout-tmux/live-tmux-smoke.test.ts b/src/features/team-mode/team-layout-tmux/live-tmux-smoke.test.ts new file mode 100644 index 000000000..349e1dd84 --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/live-tmux-smoke.test.ts @@ -0,0 +1,320 @@ +/// + +import { randomUUID } from "node:crypto" +import { mkdir, rm } from "node:fs/promises" +import path from "node:path" + +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { spawn } from "bun" + +const LIVE = process.env.OMO_LIVE_TMUX === "1" +const HOSTNAME = "127.0.0.1" +const layoutSpecifier = import.meta.resolve("./layout") + +type TeamLayoutMemberLike = { + name: string + sessionId: string + worktreePath?: string +} + +type TmuxManagerLike = { + getServerUrl: () => string +} + +type TeamLayoutResultLike = { + focusWindowId: string + gridWindowId: string + focusPanesByMember: Record + gridPanesByMember: Record + targetSessionId: string + ownedSession: boolean +} + +type LoadedLayoutModule = { + createTeamLayout?: unknown + removeTeamLayout?: unknown +} + +type TmuxCommandResult = { + success: boolean + stdout: string + stderr: string + exitCode: number +} + +type TmuxWindow = { + id: string + name: string +} + +type LiveTestState = { + callerPaneId: string + callerSessionId: string + callerSessionName: string + healthServer: ReturnType + originalTmux: string | undefined + originalTmuxPane: string | undefined + socketPath: string + tempRoot: string + tmuxManager: TmuxManagerLike +} + +let liveTestState: LiveTestState | null = null + +function requireLiveTestState(): LiveTestState { + if (liveTestState === null) { + throw new Error("live tmux smoke test state was not initialized") + } + + return liveTestState +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" +} + +function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike { + if (!isRecord(value)) { + return false + } + + return typeof value.focusWindowId === "string" + && typeof value.gridWindowId === "string" + && isRecord(value.focusPanesByMember) + && isRecord(value.gridPanesByMember) + && typeof value.targetSessionId === "string" + && typeof value.ownedSession === "boolean" +} + +async function runTmuxCommand(args: string[]): Promise { + const subprocess = spawn(["tmux", ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(subprocess.stdout).text(), + new Response(subprocess.stderr).text(), + subprocess.exited, + ]) + + return { + success: exitCode === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode, + } +} + +async function createCallerSession(sessionName: string): Promise<{ callerSessionId: string; callerPaneId: string; socketPath: string }> { + const createdSession = await runTmuxCommand([ + "new-session", + "-d", + "-s", + sessionName, + "-P", + "-F", + "#{session_id} #{pane_id}", + ]) + + if (!createdSession.success) { + throw new Error(`failed to create caller tmux session: ${createdSession.stderr || createdSession.stdout}`) + } + + const [callerSessionId, callerPaneId] = createdSession.stdout.split(" ", 2) + if (!callerSessionId || !callerPaneId) { + throw new Error(`failed to parse caller session identifiers: ${createdSession.stdout}`) + } + + const socketPathResult = await runTmuxCommand(["display-message", "-p", "-t", callerPaneId, "#{socket_path}"]) + if (!socketPathResult.success || socketPathResult.stdout.length === 0) { + throw new Error(`failed to resolve tmux socket path: ${socketPathResult.stderr || socketPathResult.stdout}`) + } + + return { callerSessionId, callerPaneId, socketPath: socketPathResult.stdout } +} + +async function listWindows(sessionId: string): Promise { + const listedWindows = await runTmuxCommand(["list-windows", "-t", sessionId, "-F", "#{window_id}\t#{window_name}"]) + if (!listedWindows.success) { + throw new Error(`failed to list tmux windows: ${listedWindows.stderr || listedWindows.stdout}`) + } + + return listedWindows.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [id, name] = line.split("\t", 2) + if (!id || !name) { + throw new Error(`failed to parse tmux window line: ${line}`) + } + + return { id, name } + }) +} + +async function waitForCondition(predicate: () => Promise): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + if (await predicate()) { + return true + } + + await new Promise((resolve) => { + setTimeout(resolve, 100) + }) + } + + return false +} + +async function loadLayoutModule(): Promise { + return import(`${layoutSpecifier}?live=${Date.now()}-${Math.random()}`) +} + +async function invokeCreateTeamLayout( + layoutModule: LoadedLayoutModule, + teamRunId: string, + members: TeamLayoutMemberLike[], + tmuxManager: TmuxManagerLike, +): Promise { + const createTeamLayout = layoutModule.createTeamLayout + if (!(createTeamLayout instanceof Function)) { + throw new Error("createTeamLayout export missing") + } + + const result = await Promise.resolve(Reflect.apply(createTeamLayout, undefined, [teamRunId, members, tmuxManager])) + if (!isTeamLayoutResultLike(result)) { + throw new Error("createTeamLayout returned an unexpected result") + } + + return result +} + +async function invokeRemoveTeamLayout( + layoutModule: LoadedLayoutModule, + teamRunId: string, + tmuxManager: TmuxManagerLike, + layoutResult: TeamLayoutResultLike, + targetSessionId: string, +): Promise { + const removeTeamLayout = layoutModule.removeTeamLayout + if (!(removeTeamLayout instanceof Function)) { + throw new Error("removeTeamLayout export missing") + } + + await Promise.resolve(Reflect.apply(removeTeamLayout, undefined, [ + teamRunId, + { + ownedSession: false, + targetSessionId, + focusWindowId: layoutResult.focusWindowId, + gridWindowId: layoutResult.gridWindowId, + }, + tmuxManager, + ])) +} + +describe("team-mode live tmux smoke", () => { + beforeEach(async () => { + if (!LIVE) { + return + } + + const callerSessionName = `omo-smoke-${Date.now()}` + const { callerSessionId, callerPaneId, socketPath } = await createCallerSession(callerSessionName) + const tempRoot = path.join("/tmp", `omo-live-tmux-${randomUUID()}`) + await mkdir(path.join(tempRoot, "lead"), { recursive: true }) + await mkdir(path.join(tempRoot, "member-two"), { recursive: true }) + + const healthServer = Bun.serve({ + port: 0, + hostname: HOSTNAME, + fetch(request) { + const requestUrl = new URL(request.url) + if (requestUrl.pathname === "/global/health") { + return new Response("ok") + } + + return new Response("not found", { status: 404 }) + }, + }) + + liveTestState = { + callerPaneId, + callerSessionId, + callerSessionName, + healthServer, + originalTmux: process.env.TMUX, + originalTmuxPane: process.env.TMUX_PANE, + socketPath, + tempRoot, + tmuxManager: { + getServerUrl: () => `http://${HOSTNAME}:${healthServer.port}`, + }, + } + + process.env.TMUX = `${socketPath},0,0` + process.env.TMUX_PANE = callerPaneId + }) + + afterEach(async () => { + const state = liveTestState + liveTestState = null + if (state === null) { + return + } + + state.healthServer.stop(true) + process.env.TMUX = state.originalTmux + process.env.TMUX_PANE = state.originalTmuxPane + await runTmuxCommand(["kill-session", "-t", state.callerSessionName]) + await rm(state.tempRoot, { recursive: true, force: true }) + }) + + test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then two new windows appear in the caller session AND removeTeamLayout deletes exactly those two windows leaving the caller session intact", async () => { + // given + const state = requireLiveTestState() + const layoutModule = await loadLayoutModule() + const teamRunId = randomUUID() + const shortTeamRunId = teamRunId.slice(0, 8) + const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`] + const initialWindows = await listWindows(state.callerSessionId) + const members: TeamLayoutMemberLike[] = [ + { + name: "lead", + sessionId: `${teamRunId}-lead`, + worktreePath: path.join(state.tempRoot, "lead"), + }, + { + name: "member-two", + sessionId: `${teamRunId}-member-two`, + worktreePath: path.join(state.tempRoot, "member-two"), + }, + ] + + // when + const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager) + const windowsAppeared = await waitForCondition(async () => { + const windows = await listWindows(state.callerSessionId) + return expectedWindowNames.every((windowName) => windows.some((window) => window.name === windowName)) + }) + + await invokeRemoveTeamLayout(layoutModule, teamRunId, state.tmuxManager, layoutResult, state.callerSessionId) + const windowsRemoved = await waitForCondition(async () => { + const windows = await listWindows(state.callerSessionId) + const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName)) + const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",") + return noExpectedWindowsRemain && sameWindowIds + }) + const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId]) + + // then + expect(layoutResult.focusWindowId.length).toBeGreaterThan(0) + expect(layoutResult.gridWindowId.length).toBeGreaterThan(0) + expect(windowsAppeared).toBe(true) + expect(windowsRemoved).toBe(true) + expect(callerSessionStillAlive.success).toBe(true) + expect(process.env.TMUX_PANE).toBe(state.callerPaneId) + }) +})