{"version":3,"file":"process-manager.windows.test.d.ts","sourceRoot":"","sources":["../../../src/core/tools/process-manager.windows.test.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Windows integration test for process_manager.\n *\n * Tests the full lifecycle using real PowerShell and real processes.\n * Skipped on non-Windows platforms.\n *\n * Safety: only kills PIDs created by this test. No Stop-Process by name.\n */\nimport { spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { existsSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { afterAll, describe, expect, it } from \"vitest\";\nimport { createProcessManagerTool, type ProcessRecord } from \"./process-manager.js\";\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction psRunSync(script: string, timeoutSecs = 10): { stdout: string; stderr: string; status: number | null } {\n\tconst encoded = Buffer.from(script, \"utf-16le\").toString(\"base64\");\n\tconst result = spawnSync(\"pwsh\", [\"-NoProfile\", \"-NonInteractive\", \"-EncodedCommand\", encoded], {\n\t\ttimeout: timeoutSecs * 1000,\n\t\tencoding: \"utf-8\",\n\t\twindowsHide: true,\n\t});\n\treturn {\n\t\tstdout: (result.stdout || \"\").trim(),\n\t\tstderr: (result.stderr || \"\").trim(),\n\t\tstatus: result.status,\n\t};\n}\n\nasync function pickFreePort(min = 31000, max = 31999): Promise<number> {\n\tfor (let attempt = 0; attempt < 100; attempt++) {\n\t\tconst port = min + Math.floor(Math.random() * (max - min + 1));\n\t\tconst r = psRunSync(\n\t\t\t`$c = Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue; if ($c) { 'TAKEN' } else { 'FREE' }`,\n\t\t);\n\t\tif (r.stdout.includes(\"FREE\")) return port;\n\t}\n\tthrow new Error(\"Could not find a free port\");\n}\n\nfunction sleep(ms: number): Promise<void> {\n\treturn new Promise((r) => setTimeout(r, ms));\n}\n\nasync function httpGet(port: number, path: string, timeoutMs = 5000): Promise<{ status: number; body: string }> {\n\tconst controller = new AbortController();\n\tconst t = setTimeout(() => controller.abort(), timeoutMs);\n\ttry {\n\t\tconst res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal });\n\t\tconst body = await res.text();\n\t\treturn { status: res.status, body };\n\t} catch (err) {\n\t\treturn { status: 0, body: (err as Error).message };\n\t} finally {\n\t\tclearTimeout(t);\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Describe block\n// ---------------------------------------------------------------------------\n\nconst describeWindows = process.platform === \"win32\" ? describe : describe.skip;\n\ndescribeWindows(\"process manager Windows integration\", () => {\n\tconst fixturePaths: string[] = [];\n\tconst pidsToCleanup: number[] = [];\n\n\tafterAll(() => {\n\t\tfor (const pid of pidsToCleanup) {\n\t\t\ttry {\n\t\t\t\tpsRunSync(`Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`);\n\t\t\t} catch {\n\t\t\t\t// Already dead\n\t\t\t}\n\t\t}\n\t\tfor (const fp of fixturePaths) {\n\t\t\ttry {\n\t\t\t\tif (existsSync(fp)) unlinkSync(fp);\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\t});\n\n\tfunction createFixture(port: number): string {\n\t\tconst fp = join(tmpdir(), `jensen-pm-wintest-${randomBytes(4).toString(\"hex\")}.js`);\n\t\tconst code = [\n\t\t\t`const http = require(\"node:http\");`,\n\t\t\t`console.log(\"PROCESS_MANAGER_FIXTURE_STARTED\");`,\n\t\t\t`console.error(\"PROCESS_MANAGER_FIXTURE_STDERR_READY\");`,\n\t\t\t`const server = http.createServer((_req, res) => {`,\n\t\t\t`  res.writeHead(200, {\"Content-Type\":\"text/plain\"});`,\n\t\t\t`  res.end(\"fixture-ok\");`,\n\t\t\t`});`,\n\t\t\t`server.listen(${port}, \"127.0.0.1\", () => {});`,\n\t\t\t`setInterval(() => {}, 60000);`,\n\t\t].join(\"\\n\");\n\t\twriteFileSync(fp, code, \"utf-8\");\n\t\tfixturePaths.push(fp);\n\t\treturn fp;\n\t}\n\n\tit(\"full lifecycle: start -> status -> list -> stop -> idempotent stop\", async () => {\n\t\tconst port = await pickFreePort();\n\t\tconst fixturePath = createFixture(port);\n\t\tconst tool = createProcessManagerTool(process.cwd());\n\n\t\t// START\n\t\tconst startResult = await tool.execute(\"wintest-1\", {\n\t\t\taction: \"start\",\n\t\t\tcommand: `node \"${fixturePath}\"`,\n\t\t\texpectedPort: port,\n\t\t\treadyTimeout: 20,\n\t\t});\n\n\t\tconst record: ProcessRecord = startResult.details?.record;\n\t\texpect(record).toBeDefined();\n\t\texpect(record.runId).toBeTruthy();\n\t\texpect(record.rootPid).toBeGreaterThan(0);\n\t\texpect(record.listenerPid).toBeGreaterThan(0);\n\t\texpect(record.status).toBe(\"running\");\n\t\tpidsToCleanup.push(record.rootPid);\n\n\t\tconst runId = record.runId;\n\n\t\t// Verify listener\n\t\tconst connResult = psRunSync(\n\t\t\t`$c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { \"PID:$($c.OwningProcess)\" } else { \"NONE\" }`,\n\t\t);\n\t\tconst listenerMatch = connResult.stdout.match(/PID:(\\d+)/);\n\t\texpect(listenerMatch).not.toBeNull();\n\t\texpect(Number(listenerMatch![1])).toBe(record.listenerPid);\n\n\t\t// Verify stdout log\n\t\texpect(existsSync(record.stdoutPath)).toBe(true);\n\t\texpect(readFileSync(record.stdoutPath, \"utf-8\")).toContain(\"PROCESS_MANAGER_FIXTURE_STARTED\");\n\n\t\t// Verify stderr log\n\t\texpect(existsSync(record.stderrPath)).toBe(true);\n\t\texpect(readFileSync(record.stderrPath, \"utf-8\")).toContain(\"PROCESS_MANAGER_FIXTURE_STDERR_READY\");\n\n\t\t// HTTP\n\t\tconst httpRes = await httpGet(port, \"/\");\n\t\texpect(httpRes.status).toBe(200);\n\t\texpect(httpRes.body).toBe(\"fixture-ok\");\n\n\t\t// STATUS\n\t\tconst statusResult = await tool.execute(\"wintest-2\", { action: \"status\", runId });\n\t\tconst statusText = statusResult.content[0]?.type === \"text\" ? statusResult.content[0].text : \"\";\n\t\texpect(statusText).toContain(\"Status: running\");\n\n\t\t// LIST\n\t\tconst listResult = await tool.execute(\"wintest-3\", { action: \"list\" });\n\t\tconst listText = listResult.content[0]?.type === \"text\" ? listResult.content[0].text : \"\";\n\t\texpect(listText).toContain(runId);\n\n\t\t// STOP\n\t\tconst stopResult = await tool.execute(\"wintest-4\", { action: \"stop\", runId });\n\t\tconst stopText = stopResult.content[0]?.type === \"text\" ? stopResult.content[0].text : \"\";\n\t\texpect(stopText).toContain(\"stopped successfully\");\n\n\t\t// Wait for port release\n\t\tawait sleep(2000);\n\n\t\t// Verify listener gone\n\t\tconst connAfter = psRunSync(\n\t\t\t`$c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { \"PID:$($c.OwningProcess)\" } else { \"NONE\" }`,\n\t\t);\n\t\texpect(connAfter.stdout).toContain(\"NONE\");\n\n\t\t// Second stop (idempotent)\n\t\tconst stop2Result = await tool.execute(\"wintest-5\", { action: \"stop\", runId });\n\t\tconst stop2Text = stop2Result.content[0]?.type === \"text\" ? stop2Result.content[0].text : \"\";\n\t\texpect(stop2Text).toContain(\"already stopped\");\n\n\t\t// Remove from cleanup list (already stopped)\n\t\tpidsToCleanup.length = 0;\n\t}, 60000);\n\n\tit(\"port conflict: does not kill external process\", async () => {\n\t\tconst conflictPort = await pickFreePort();\n\t\tconst extFixturePath = createFixture(conflictPort);\n\n\t\t// Start external fixture via PowerShell (not via process_manager)\n\t\tconst extStart = psRunSync(\n\t\t\t`$proc = Start-Process -FilePath 'node' -ArgumentList '\"${extFixturePath.replace(/\"/g, '\\\\\"')}\"' -WindowStyle Hidden -PassThru; Write-Output \"PID:$($proc.Id)\"`,\n\t\t\t10,\n\t\t);\n\t\tconst extPidMatch = extStart.stdout.match(/PID:(\\d+)/);\n\t\texpect(extPidMatch).not.toBeNull();\n\t\tconst extPid = Number(extPidMatch![1]);\n\t\tpidsToCleanup.push(extPid);\n\n\t\t// Wait for external fixture to listen\n\t\tlet extReady = false;\n\t\tfor (let i = 0; i < 40; i++) {\n\t\t\tconst check = psRunSync(\n\t\t\t\t`$c = Get-NetTCPConnection -LocalPort ${conflictPort} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c -and $c.OwningProcess -eq ${extPid}) { 'READY' } else { 'WAIT' }`,\n\t\t\t);\n\t\t\tif (check.stdout.includes(\"READY\")) {\n\t\t\t\textReady = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tawait sleep(500);\n\t\t}\n\t\texpect(extReady).toBe(true);\n\n\t\t// Try to start process_manager on the same port — must fail\n\t\tconst tool = createProcessManagerTool(process.cwd());\n\t\tconst conflictFixturePath = join(tmpdir(), `jensen-pm-conflict-${randomBytes(4).toString(\"hex\")}.js`);\n\t\twriteFileSync(conflictFixturePath, fixtureContent(conflictPort), \"utf-8\");\n\t\tfixturePaths.push(conflictFixturePath);\n\n\t\tawait expect(\n\t\t\ttool.execute(\"wintest-conflict\", {\n\t\t\t\taction: \"start\",\n\t\t\t\tcommand: `node \"${conflictFixturePath}\"`,\n\t\t\t\texpectedPort: conflictPort,\n\t\t\t\treadyTimeout: 10,\n\t\t\t}),\n\t\t).rejects.toThrow();\n\n\t\t// External fixture must still be alive\n\t\tconst aliveResult = psRunSync(\n\t\t\t`$p = Get-Process -Id ${extPid} -ErrorAction SilentlyContinue; if ($p) { 'ALIVE' } else { 'DEAD' }`,\n\t\t);\n\t\texpect(aliveResult.stdout).toContain(\"ALIVE\");\n\n\t\t// External fixture must still own the port\n\t\tconst portOwner = psRunSync(\n\t\t\t`$c = Get-NetTCPConnection -LocalPort ${conflictPort} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { \"PID:$($c.OwningProcess)\" } else { \"NONE\" }`,\n\t\t);\n\t\texpect(portOwner.stdout).toContain(`PID:${extPid}`);\n\n\t\t// Cleanup external fixture\n\t\tpsRunSync(`Stop-Process -Id ${extPid} -Force -ErrorAction SilentlyContinue`);\n\t\tpidsToCleanup.length = 0;\n\t}, 60000);\n});\n\nfunction fixtureContent(port: number): string {\n\treturn [\n\t\t`const http = require(\"node:http\");`,\n\t\t`console.log(\"FIXTURE_STARTED\");`,\n\t\t`const server = http.createServer((_req, res) => {`,\n\t\t`  res.writeHead(200, {\"Content-Type\":\"text/plain\"});`,\n\t\t`  res.end(\"conflict-ok\");`,\n\t\t`});`,\n\t\t`server.listen(${port}, \"127.0.0.1\", () => {});`,\n\t\t`setInterval(() => {}, 60000);`,\n\t].join(\"\\n\");\n}\n"]}