|
| 1 | +import { Effect } from "effect" |
| 2 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" |
| 3 | + |
| 4 | +import type { ProjectItem } from "@effect-template/lib" |
| 5 | + |
| 6 | +import type { ProjectDetails } from "../src/api/contracts.js" |
| 7 | +import { clearProjectEvents, listProjectEventsSince } from "../src/services/events.js" |
| 8 | +import { |
| 9 | + createTerminalSession, |
| 10 | + deleteTerminalSession, |
| 11 | + listProjectTerminalSessions |
| 12 | +} from "../src/services/terminal-sessions.js" |
| 13 | + |
| 14 | +const prepareProjectSshMock = vi.hoisted(() => vi.fn()) |
| 15 | +const probeProjectSshReadyMock = vi.hoisted(() => vi.fn()) |
| 16 | +const runCommandCaptureMock = vi.hoisted(() => vi.fn()) |
| 17 | +const upProjectMock = vi.hoisted(() => vi.fn()) |
| 18 | +const getProjectMock = vi.hoisted(() => vi.fn()) |
| 19 | +const getProjectItemByIdMock = vi.hoisted(() => vi.fn()) |
| 20 | +const waitForProjectSshReadyMock = vi.hoisted(() => vi.fn()) |
| 21 | + |
| 22 | +vi.mock("@effect-template/lib", () => ({ |
| 23 | + prepareProjectSsh: prepareProjectSshMock, |
| 24 | + probeProjectSshReady: probeProjectSshReadyMock, |
| 25 | + renderError: vi.fn((error: unknown) => String(error)), |
| 26 | + waitForProjectSshReady: waitForProjectSshReadyMock |
| 27 | +})) |
| 28 | + |
| 29 | +vi.mock("@effect-template/lib/shell/command-runner", () => ({ |
| 30 | + runCommandCapture: runCommandCaptureMock |
| 31 | +})) |
| 32 | + |
| 33 | +vi.mock("../src/services/projects.js", () => ({ |
| 34 | + getProject: getProjectMock, |
| 35 | + getProjectItemById: getProjectItemByIdMock, |
| 36 | + upProject: upProjectMock |
| 37 | +})) |
| 38 | + |
| 39 | +const projectId = "/controller/org/repo/issue-7" |
| 40 | +const projectKey = "repo-issue-7" |
| 41 | +const displayName = "org/repo" |
| 42 | + |
| 43 | +const projectItem = { |
| 44 | + authorizedKeysExists: true, |
| 45 | + authorizedKeysPath: "/controller/org/repo/issue-7/authorized_keys", |
| 46 | + codexAuthPath: "/controller/org/repo/issue-7/.orch/auth/codex", |
| 47 | + codexHome: "/home/dev/.codex", |
| 48 | + containerName: "dg-repo-issue-7", |
| 49 | + displayName, |
| 50 | + envGlobalPath: "/controller/org/repo/issue-7/.orch/env/global.env", |
| 51 | + envProjectPath: "/controller/org/repo/issue-7/.orch/env/project.env", |
| 52 | + lastKnownStatus: "running", |
| 53 | + lastStartAction: "up", |
| 54 | + lastStartedAtEpochMs: 1_778_000_000_000, |
| 55 | + lastStartedAtIso: "2026-05-06T19:00:00.000Z", |
| 56 | + projectDir: projectId, |
| 57 | + repoRef: "issue-7", |
| 58 | + repoUrl: "https://github.com/org/repo.git", |
| 59 | + serviceName: "app", |
| 60 | + sshCommand: "ssh -p 2222 dev@localhost", |
| 61 | + sshKeyPath: null, |
| 62 | + sshPort: 2222, |
| 63 | + sshUser: "dev", |
| 64 | + targetDir: "/home/dev/app" |
| 65 | +} satisfies ProjectItem |
| 66 | + |
| 67 | +const projectDetails = { |
| 68 | + authorizedKeysExists: true, |
| 69 | + authorizedKeysPath: "/controller/org/repo/issue-7/authorized_keys", |
| 70 | + clonedOnHostname: "host", |
| 71 | + codexAuthPath: "/controller/org/repo/issue-7/.orch/auth/codex", |
| 72 | + codexHome: "/home/dev/.codex", |
| 73 | + containerName: "dg-repo-issue-7", |
| 74 | + displayName, |
| 75 | + envGlobalPath: "/controller/org/repo/issue-7/.orch/env/global.env", |
| 76 | + envProjectPath: "/controller/org/repo/issue-7/.orch/env/project.env", |
| 77 | + id: projectId, |
| 78 | + projectDir: projectId, |
| 79 | + projectKey, |
| 80 | + repoRef: "issue-7", |
| 81 | + repoUrl: "https://github.com/org/repo.git", |
| 82 | + serviceName: "app", |
| 83 | + sshCommand: "ssh -p 2222 dev@localhost", |
| 84 | + sshPort: 2222, |
| 85 | + sshSessions: 0, |
| 86 | + sshUser: "dev", |
| 87 | + startedAtEpochMs: 1_778_000_000_000, |
| 88 | + startedAtIso: "2026-05-06T19:00:00.000Z", |
| 89 | + status: "running", |
| 90 | + statusLabel: "Up", |
| 91 | + targetDir: "/home/dev/app" |
| 92 | +} satisfies ProjectDetails |
| 93 | + |
| 94 | +const cleanupSessions = (): Effect.Effect<void, never> => |
| 95 | + Effect.forEach( |
| 96 | + listProjectTerminalSessions(projectId), |
| 97 | + (session) => deleteTerminalSession(projectId, session.id).pipe(Effect.catchAll(() => Effect.void)), |
| 98 | + { discard: true } |
| 99 | + ) |
| 100 | + |
| 101 | +const runTestEffect = <A>(effect: Effect.Effect<A, unknown, unknown>): Promise<A> => |
| 102 | + Effect.runPromise(effect as Effect.Effect<A, unknown, never>) |
| 103 | + |
| 104 | +const phaseFromEvent = (event: { readonly payload: unknown }): string | null => { |
| 105 | + if (typeof event.payload !== "object" || event.payload === null || !Object.hasOwn(event.payload, "phase")) { |
| 106 | + return null |
| 107 | + } |
| 108 | + return String(Reflect.get(event.payload, "phase")) |
| 109 | +} |
| 110 | + |
| 111 | +describe("terminal sessions service", () => { |
| 112 | + beforeEach(() => { |
| 113 | + clearProjectEvents(projectId) |
| 114 | + prepareProjectSshMock.mockReset() |
| 115 | + probeProjectSshReadyMock.mockReset() |
| 116 | + runCommandCaptureMock.mockReset() |
| 117 | + upProjectMock.mockReset() |
| 118 | + getProjectMock.mockReset() |
| 119 | + getProjectItemByIdMock.mockReset() |
| 120 | + waitForProjectSshReadyMock.mockReset() |
| 121 | + |
| 122 | + prepareProjectSshMock.mockReturnValue({ |
| 123 | + args: ["-p", "2222", "dev@localhost"], |
| 124 | + command: "ssh", |
| 125 | + cwd: "/repo", |
| 126 | + item: projectItem |
| 127 | + }) |
| 128 | + runCommandCaptureMock.mockImplementation(() => Effect.fail(new Error("docker inspect skipped in tests"))) |
| 129 | + getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem)) |
| 130 | + }) |
| 131 | + |
| 132 | + afterEach(() => { |
| 133 | + Effect.runSync(cleanupSessions()) |
| 134 | + clearProjectEvents(projectId) |
| 135 | + }) |
| 136 | + |
| 137 | + it("creates a terminal session immediately when SSH is already ready", async () => { |
| 138 | + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(true)) |
| 139 | + getProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) |
| 140 | + |
| 141 | + const result = await runTestEffect(createTerminalSession(projectId)) |
| 142 | + const events = listProjectEventsSince(projectId, 0) |
| 143 | + const phases = events |
| 144 | + .filter((event) => event.type === "project.deployment.status") |
| 145 | + .map(phaseFromEvent) |
| 146 | + .filter((phase): phase is string => phase !== null) |
| 147 | + |
| 148 | + expect(upProjectMock).not.toHaveBeenCalled() |
| 149 | + expect(waitForProjectSshReadyMock).not.toHaveBeenCalled() |
| 150 | + expect(getProjectMock).toHaveBeenCalledWith(projectId) |
| 151 | + expect(result.project).toEqual(projectDetails) |
| 152 | + expect(result.session.projectId).toBe(projectId) |
| 153 | + expect(result.session.sshCommand).toBe("ssh -p 2222 dev@localhost") |
| 154 | + expect(phases).toEqual(["ssh.prepare", "ssh.fast-ready"]) |
| 155 | + }) |
| 156 | + |
| 157 | + it("falls back to project startup and SSH wait when SSH is not ready", async () => { |
| 158 | + probeProjectSshReadyMock.mockImplementation(() => Effect.succeed(false)) |
| 159 | + upProjectMock.mockImplementation(() => Effect.succeed(projectDetails)) |
| 160 | + waitForProjectSshReadyMock.mockImplementation(() => Effect.void) |
| 161 | + |
| 162 | + const result = await runTestEffect(createTerminalSession(projectId)) |
| 163 | + const events = listProjectEventsSince(projectId, 0) |
| 164 | + const phases = events |
| 165 | + .filter((event) => event.type === "project.deployment.status") |
| 166 | + .map(phaseFromEvent) |
| 167 | + .filter((phase): phase is string => phase !== null) |
| 168 | + |
| 169 | + expect(upProjectMock).toHaveBeenCalledWith(projectId, undefined, true, { startupMode: "ssh-open" }) |
| 170 | + expect(waitForProjectSshReadyMock).toHaveBeenCalledTimes(1) |
| 171 | + expect(getProjectMock).not.toHaveBeenCalled() |
| 172 | + expect(result.project).toEqual(projectDetails) |
| 173 | + expect(result.session.projectId).toBe(projectId) |
| 174 | + expect(phases).toEqual(["ssh.prepare", "ssh.wait", "ssh.ready", "ssh.post-start"]) |
| 175 | + }) |
| 176 | +}) |
0 commit comments