Skip to content

Commit 51516c0

Browse files
committed
test: verify packaged runtime startup
1 parent 5ac4a5a commit 51516c0

5 files changed

Lines changed: 129 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ jobs:
134134
- run: npm run security:secrets
135135
- run: npm run build
136136
- run: npm run package:check
137+
- run: npm run acceptance:package-runtime
137138

138139
release-gate:
139140
if: always()

docs/enterprise-release-gates.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Passing unit tests alone does not prove that PromptImprover is operationally rea
2020
7. Claude and Gemini hook pre/post flows pass; Codex MCP-first flow passes.
2121
8. Dashboard API security, review mutation, health telemetry, and browser smoke flows pass.
2222
9. Dependency audit reports no known production vulnerabilities.
23-
10. Package dry-run, global installation, runtime startup, and post-restart smoke tests pass.
23+
10. Package dry-run plus `acceptance:package-runtime` global installation, runtime startup, and health smoke tests pass.
2424
11. Secret scanning finds no committed credentials.
2525
12. Linux and Windows CI jobs pass before merge.
2626

universal-refiner/package-lock.json

Lines changed: 10 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

universal-refiner/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
"acceptance:semantic": "node scripts/acceptance/semantic-provider-acceptance.mjs",
2828
"acceptance:gemma:live": "node scripts/acceptance/semantic-provider-acceptance.mjs --require-live",
2929
"acceptance:tracked-turn": "node scripts/acceptance/tracked-turn-acceptance.mjs",
30+
"acceptance:package-runtime": "node scripts/acceptance/package-runtime-smoke.mjs",
3031
"stress:event-store": "node scripts/stress/event-store-stress.mjs",
3132
"stress:event-store:soak": "node scripts/stress/event-store-soak.mjs",
32-
"release:verify": "npm run build && npm run test:coverage && npm run test:acceptance && npm run acceptance:semantic && npm run acceptance:tracked-turn && npm run test:stress && npm run stress:event-store && npm run recovery:event-store:abrupt && npm run stress:event-store:soak && npm run security:audit && npm run security:secrets && npm run package:check"
33+
"release:verify": "npm run build && npm run test:coverage && npm run test:acceptance && npm run acceptance:semantic && npm run acceptance:tracked-turn && npm run test:stress && npm run stress:event-store && npm run recovery:event-store:abrupt && npm run stress:event-store:soak && npm run security:audit && npm run security:secrets && npm run package:check && npm run acceptance:package-runtime"
3334
},
3435
"keywords": [
3536
"mcp",
@@ -45,14 +46,14 @@
4546
"better-sqlite3": "^12.8.0",
4647
"chokidar": "^5.0.0",
4748
"flexsearch": "^0.7.43",
49+
"typescript": "^5.9.3",
4850
"zod": "^4.3.6"
4951
},
5052
"devDependencies": {
5153
"@types/better-sqlite3": "^7.6.13",
5254
"@types/node": "^22.19.17",
5355
"@vitest/coverage-v8": "4.1.4",
5456
"ts-node": "^10.9.2",
55-
"typescript": "^5.9.3",
5657
"vitest": "^4.1.4"
5758
},
5859
"repository": {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import assert from "node:assert/strict";
2+
import { spawn } from "node:child_process";
3+
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { createServer } from "node:http";
7+
import { runProcess } from "../operations/child-process.mjs";
8+
9+
const repoRoot = process.cwd();
10+
const timeoutMs = Number.parseInt(process.env.PROMPT_REFINER_PACKAGE_SMOKE_TIMEOUT_MS || "120000", 10);
11+
const tempRoot = await mkdtemp(join(tmpdir(), "prompt-refiner-package-smoke-"));
12+
const packageDir = join(tempRoot, "package");
13+
const prefixDir = join(tempRoot, "prefix");
14+
const runtimeDir = join(tempRoot, "runtime");
15+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
16+
const npmExecPath = process.env.npm_execpath;
17+
let runtime;
18+
19+
try {
20+
await mkdir(packageDir, { recursive: true });
21+
await mkdir(runtimeDir, { recursive: true });
22+
const packOutput = await runNpm(["pack", "--json", "--pack-destination", packageDir]);
23+
const packed = JSON.parse(packOutput.stdout);
24+
const tarball = join(packageDir, packed[0].filename);
25+
26+
await runNpm(["install", "--global", "--prefix", prefixDir, "--no-fund", tarball]);
27+
28+
const bin = process.platform === "win32"
29+
? join(prefixDir, "gemini-prompt-refiner.cmd")
30+
: join(prefixDir, "bin", "gemini-prompt-refiner");
31+
const installedEntry = join(prefixDir, "node_modules", "gemini-prompt-refiner", "dist", "src", "index.js");
32+
await access(bin);
33+
await access(installedEntry);
34+
const port = await reservePort();
35+
36+
runtime = spawn(process.execPath, [installedEntry], {
37+
cwd: runtimeDir,
38+
env: {
39+
...process.env,
40+
PORT: String(port),
41+
PROMPT_REFINER_BACKGROUND: "true",
42+
},
43+
stdio: ["ignore", "pipe", "pipe"],
44+
windowsHide: true,
45+
});
46+
47+
let stdout = "";
48+
let stderr = "";
49+
runtime.stdout.setEncoding("utf8");
50+
runtime.stderr.setEncoding("utf8");
51+
runtime.stdout.on("data", chunk => stdout += chunk);
52+
runtime.stderr.on("data", chunk => stderr += chunk);
53+
54+
await waitForHealth(port, () => `${stdout}\n${stderr}`, timeoutMs);
55+
console.log(`Package runtime smoke passed: installed ${packed[0].name}-${packed[0].version} and served /api/health on ${port}.`);
56+
} finally {
57+
if (runtime && !runtime.killed) {
58+
runtime.kill("SIGTERM");
59+
await waitForClose(runtime, 5_000);
60+
}
61+
await rm(tempRoot, { recursive: true, force: true });
62+
}
63+
64+
async function waitForClose(child, timeoutMs) {
65+
if (child.exitCode !== null || child.signalCode !== null) return;
66+
await Promise.race([
67+
new Promise(resolve => child.once("close", resolve)),
68+
new Promise((resolve) => {
69+
setTimeout(() => {
70+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
71+
resolve();
72+
}, timeoutMs);
73+
}),
74+
]);
75+
}
76+
77+
async function reservePort() {
78+
const server = createServer();
79+
await new Promise((resolve, reject) => {
80+
server.once("error", reject);
81+
server.listen(0, "127.0.0.1", resolve);
82+
});
83+
const address = server.address();
84+
assert.ok(address && typeof address !== "string", "Port reservation failed.");
85+
const port = address.port;
86+
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
87+
return port;
88+
}
89+
90+
function runNpm(args) {
91+
return npmExecPath
92+
? runProcess(process.execPath, [npmExecPath, ...args], { cwd: repoRoot, timeoutMs })
93+
: runProcess(npmCommand, args, { cwd: repoRoot, timeoutMs });
94+
}
95+
96+
async function waitForHealth(port, readLogs, deadlineMs) {
97+
const deadline = Date.now() + deadlineMs;
98+
let lastError = "";
99+
while (Date.now() < deadline) {
100+
try {
101+
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
102+
if (response.ok) {
103+
const body = await response.json();
104+
assert.equal(body.runtime.status, "online");
105+
return;
106+
}
107+
lastError = `HTTP ${response.status}`;
108+
} catch (error) {
109+
lastError = error instanceof Error ? error.message : String(error);
110+
}
111+
await new Promise(resolve => setTimeout(resolve, 250));
112+
}
113+
throw new Error(`Package runtime did not become healthy within ${deadlineMs}ms. Last error: ${lastError}\n${readLogs()}`);
114+
}

0 commit comments

Comments
 (0)