test(team-mode): add live tmux smoke test for team layout
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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<string, string>
|
||||
gridPanesByMember: Record<string, string>
|
||||
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<typeof Bun.serve>
|
||||
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<string, unknown> {
|
||||
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<TmuxCommandResult> {
|
||||
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<TmuxWindow[]> {
|
||||
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<boolean>): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
if (await predicate()) {
|
||||
return true
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100)
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadLayoutModule(): Promise<LoadedLayoutModule> {
|
||||
return import(`${layoutSpecifier}?live=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
async function invokeCreateTeamLayout(
|
||||
layoutModule: LoadedLayoutModule,
|
||||
teamRunId: string,
|
||||
members: TeamLayoutMemberLike[],
|
||||
tmuxManager: TmuxManagerLike,
|
||||
): Promise<TeamLayoutResultLike> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user