|
| 1 | +import assert from "node:assert/strict" |
| 2 | +import { describe, it } from "node:test" |
| 3 | + |
| 4 | +import { createInstanceClient } from "./instance-client" |
| 5 | +import type { WorkspaceManager } from "./manager" |
| 6 | + |
| 7 | +/** |
| 8 | + * Minimal stand-in for the parts of {@link WorkspaceManager} the factory reads. |
| 9 | + * The factory only touches three members, so a structural stub is enough and |
| 10 | + * keeps the test free of the full manager's heavy dependencies. |
| 11 | + */ |
| 12 | +interface StubWorkspaceManager { |
| 13 | + getInstancePort: (id: string) => number | undefined |
| 14 | + getInstanceAuthorizationHeader: (id: string) => string | undefined |
| 15 | + get: (id: string) => { path: string } | undefined |
| 16 | +} |
| 17 | + |
| 18 | +function makeManager(overrides: Partial<StubWorkspaceManager> = {}): StubWorkspaceManager { |
| 19 | + return { |
| 20 | + getInstancePort: overrides.getInstancePort ?? (() => undefined), |
| 21 | + getInstanceAuthorizationHeader: overrides.getInstanceAuthorizationHeader ?? (() => undefined), |
| 22 | + get: overrides.get ?? (() => undefined), |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +interface CapturedRequest { |
| 27 | + url: string |
| 28 | + headers: Headers |
| 29 | +} |
| 30 | + |
| 31 | +/** |
| 32 | + * Installs a global `fetch` stub that records every outgoing request and |
| 33 | + * answers a minimal healthy JSON body. Returns the capture buffer and a |
| 34 | + * restore function. The stub tolerates both `fetch(url, init)` and |
| 35 | + * `fetch(Request)` invocation styles so it is independent of the SDK's |
| 36 | + * internal call convention. |
| 37 | + */ |
| 38 | +function installRecordingFetch(): { requests: CapturedRequest[]; restore: () => void } { |
| 39 | + const requests: CapturedRequest[] = [] |
| 40 | + const original = globalThis.fetch |
| 41 | + |
| 42 | + globalThis.fetch = (async (input: any, init: any) => { |
| 43 | + if (input instanceof Request) { |
| 44 | + requests.push({ url: input.url, headers: new Headers(init?.headers ?? input.headers) }) |
| 45 | + } else { |
| 46 | + requests.push({ url: String(input), headers: new Headers(init?.headers) }) |
| 47 | + } |
| 48 | + return new Response(JSON.stringify({ healthy: true }), { |
| 49 | + status: 200, |
| 50 | + headers: { "content-type": "application/json" }, |
| 51 | + }) |
| 52 | + }) as typeof fetch |
| 53 | + |
| 54 | + return { requests, restore: () => { globalThis.fetch = original } } |
| 55 | +} |
| 56 | + |
| 57 | +describe("createInstanceClient", () => { |
| 58 | + it("returns null when the instance has no open port", () => { |
| 59 | + const manager = makeManager({ getInstancePort: () => undefined }) |
| 60 | + assert.equal(createInstanceClient(manager as unknown as WorkspaceManager, "ws-1"), null) |
| 61 | + }) |
| 62 | + |
| 63 | + it("targets the loopback host and port on outgoing requests", async () => { |
| 64 | + const { requests, restore } = installRecordingFetch() |
| 65 | + try { |
| 66 | + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) |
| 67 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") |
| 68 | + assert.ok(client, "expected a client when the instance has a port") |
| 69 | + |
| 70 | + await client!.global.health() |
| 71 | + const parsed = new URL(requests[0].url) |
| 72 | + assert.equal(parsed.hostname, "127.0.0.1") |
| 73 | + assert.equal(parsed.port, "4321") |
| 74 | + } finally { |
| 75 | + restore() |
| 76 | + } |
| 77 | + }) |
| 78 | + |
| 79 | + it("attaches the authorization header when one is configured", async () => { |
| 80 | + const { requests, restore } = installRecordingFetch() |
| 81 | + try { |
| 82 | + const manager = makeManager({ |
| 83 | + getInstancePort: () => 4321, |
| 84 | + getInstanceAuthorizationHeader: () => "Basic abc", |
| 85 | + get: () => ({ path: "/repo" }), |
| 86 | + }) |
| 87 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") |
| 88 | + |
| 89 | + await client!.global.health() |
| 90 | + assert.equal(requests[0].headers.get("authorization"), "Basic abc") |
| 91 | + } finally { |
| 92 | + restore() |
| 93 | + } |
| 94 | + }) |
| 95 | + |
| 96 | + it("omits the authorization header when none is configured", async () => { |
| 97 | + const { requests, restore } = installRecordingFetch() |
| 98 | + try { |
| 99 | + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) |
| 100 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") |
| 101 | + |
| 102 | + await client!.global.health() |
| 103 | + assert.equal(requests[0].headers.get("authorization"), null) |
| 104 | + } finally { |
| 105 | + restore() |
| 106 | + } |
| 107 | + }) |
| 108 | + |
| 109 | + it("scopes requests to the workspace directory", async () => { |
| 110 | + const { requests, restore } = installRecordingFetch() |
| 111 | + try { |
| 112 | + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) |
| 113 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") |
| 114 | + |
| 115 | + await client!.global.health() |
| 116 | + // GET requests carry directory as a query parameter (see SDK rewrite). |
| 117 | + assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/repo") |
| 118 | + } finally { |
| 119 | + restore() |
| 120 | + } |
| 121 | + }) |
| 122 | + |
| 123 | + it("does not scope requests when the workspace has no path", async () => { |
| 124 | + const { requests, restore } = installRecordingFetch() |
| 125 | + try { |
| 126 | + const manager = makeManager({ getInstancePort: () => 4321, get: () => undefined }) |
| 127 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1") |
| 128 | + |
| 129 | + await client!.global.health() |
| 130 | + assert.equal(new URL(requests[0].url).searchParams.get("directory"), null) |
| 131 | + } finally { |
| 132 | + restore() |
| 133 | + } |
| 134 | + }) |
| 135 | + |
| 136 | + it("honours an explicit directory override over the workspace root", async () => { |
| 137 | + const { requests, restore } = installRecordingFetch() |
| 138 | + try { |
| 139 | + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/workspace-root" }) }) |
| 140 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { |
| 141 | + directory: "/explicit/session-dir", |
| 142 | + }) |
| 143 | + |
| 144 | + await client!.global.health() |
| 145 | + assert.equal(new URL(requests[0].url).searchParams.get("directory"), "/explicit/session-dir") |
| 146 | + } finally { |
| 147 | + restore() |
| 148 | + } |
| 149 | + }) |
| 150 | + |
| 151 | + it("applies the loopback timeout and aborts a stuck instance", async () => { |
| 152 | + const original = globalThis.fetch |
| 153 | + // Never resolves on its own; only settles when the passed signal aborts, |
| 154 | + // mirroring how a real fetch honours an AbortSignal. Without the factory |
| 155 | + // timeout this call would hang forever and time the test out. |
| 156 | + globalThis.fetch = (async (_input: any, init: any) => { |
| 157 | + return new Promise((_resolve, reject) => { |
| 158 | + const signal = (init as RequestInit | undefined)?.signal |
| 159 | + if (!signal) return |
| 160 | + if (signal.aborted) reject((signal as AbortSignal).reason ?? new Error("aborted")) |
| 161 | + else signal.addEventListener("abort", () => reject((signal as AbortSignal).reason ?? new Error("aborted"))) |
| 162 | + }) |
| 163 | + }) as typeof fetch |
| 164 | + try { |
| 165 | + const manager = makeManager({ getInstancePort: () => 4321, get: () => ({ path: "/repo" }) }) |
| 166 | + const client = createInstanceClient(manager as unknown as WorkspaceManager, "ws-1", { timeoutMs: 10 }) |
| 167 | + |
| 168 | + // SDK methods resolve with { error } rather than throwing by default. |
| 169 | + const result = await client!.global.health() |
| 170 | + assert.ok(result.error, "expected the stuck-instance call to surface an error") |
| 171 | + } finally { |
| 172 | + globalThis.fetch = original |
| 173 | + } |
| 174 | + }) |
| 175 | +}) |
0 commit comments