Skip to content

Commit b24872c

Browse files
鲁工鲁工
authored andcommitted
ci: keep Node 18 honest without vitest, which cannot run there
The 18.x matrix job failed at `npm test`: vitest 4 pulls in rolldown, which imports `styleText` from node:util (added in Node 20.12) and declares engines ^20.19.0 || >=22.12.0. Build passed on 18.x — only the test runner is incompatible, not the shipped code. Dropping 18.x from the matrix would have removed the one guard that catches ESM-only dependencies before users hit ERR_REQUIRE_ESM, so instead: - unit tests run on 20.x/24.x, where the runner is supported - a new node18-runtime job installs production dependencies only and exercises the committed dist/ — closer to what a user actually installs scripts/smoke.mjs (Node built-ins only) starts a gateway against a local fake upstream and asserts module loading, /health provenance, non-streaming forwarding, SSE passthrough and usage accounting. It also covers a gap opened in v1.8.0: replacing node-fetch with built-in fetch changed the streaming body from a Node stream to a Web ReadableStream, and the tests exercising that path can no longer run on the engines floor. Verified passing on Node 18.20.8.
1 parent da1a8bc commit b24872c

3 files changed

Lines changed: 218 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
# 18 is the engines floor; verify it stays honest (built-in fetch, no ESM-only deps)
15-
node-version: [18.x, 20.x, 24.x]
14+
# vitest 4 (via rolldown) needs Node >=20.19; the engines floor of 18
15+
# is covered by the node18-runtime job below, against the shipped dist.
16+
node-version: [20.x, 24.x]
1617
steps:
1718
- uses: actions/checkout@v4
1819

@@ -30,14 +31,38 @@ jobs:
3031
- name: Test
3132
run: npm test
3233

34+
- name: Smoke test the built gateway
35+
run: npm run smoke
36+
3337
- name: Verify committed dist matches source
38+
if: matrix.node-version == '24.x'
3439
run: |
3540
if ! git diff --exit-code --stat dist/; then
3641
echo "::error::dist/ is stale - run 'npm run build' and commit the result"
3742
exit 1
3843
fi
3944
40-
- name: Smoke test CLI
45+
node18-runtime:
46+
# The package declares engines >=18. The test runner cannot run there, so
47+
# verify the *published artifact* loads and serves traffic on the floor,
48+
# installed with production dependencies only. This is the backstop that
49+
# catches ESM-only dependencies (ERR_REQUIRE_ESM) before users do.
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: actions/checkout@v4
53+
54+
- uses: actions/setup-node@v4
55+
with:
56+
node-version: 18.x
57+
cache: npm
58+
59+
- name: Install production dependencies only
60+
run: npm ci --omit=dev
61+
62+
- name: CLI runs on the engines floor
4163
run: |
4264
node dist/cli.js --version
43-
node dist/cli.js models | head -30
65+
node dist/cli.js models | head -20
66+
67+
- name: Smoke test the shipped dist
68+
run: node scripts/smoke.mjs

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"clean": "rimraf dist",
1616
"test": "vitest run",
1717
"test:watch": "vitest",
18+
"smoke": "node scripts/smoke.mjs",
1819
"prepublishOnly": "npm run build"
1920
},
2021
"keywords": [

scripts/smoke.mjs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Runtime smoke test for the built dist/, using only Node built-ins and
3+
* production dependencies.
4+
*
5+
* Exists because the test runner (vitest 4) requires Node >=20.19 while the
6+
* package supports Node >=18. Without this, the engines floor is an unverified
7+
* claim — exactly how an ESM-only node-fetch once shipped and crashed users on
8+
* Node 18/20 while passing locally on 24.
9+
*
10+
* Covers: module loading (ERR_REQUIRE_ESM), config discovery, /health,
11+
* non-streaming forwarding, and SSE passthrough (built-in fetch returns a Web
12+
* ReadableStream, whose async iteration differs from the old node-fetch stream).
13+
*
14+
* Usage: node scripts/smoke.mjs
15+
*/
16+
17+
import { spawn } from 'node:child_process';
18+
import { createRequire } from 'node:module';
19+
import fs from 'node:fs';
20+
import http from 'node:http';
21+
import os from 'node:os';
22+
import path from 'node:path';
23+
import { fileURLToPath } from 'node:url';
24+
25+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26+
const GATEWAY_PORT = 8099;
27+
28+
let failures = 0;
29+
function check(name, condition, detail = '') {
30+
if (condition) {
31+
console.log(` \x1b[32mok\x1b[0m ${name}`);
32+
} else {
33+
failures++;
34+
console.log(` \x1b[31mFAIL\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`);
35+
}
36+
}
37+
38+
async function waitFor(fn, timeoutMs = 15000) {
39+
const deadline = Date.now() + timeoutMs;
40+
while (Date.now() < deadline) {
41+
if (await fn()) return true;
42+
await new Promise((r) => setTimeout(r, 200));
43+
}
44+
return false;
45+
}
46+
47+
// 1. The published entry point must load under CJS require() on this Node version.
48+
const require = createRequire(import.meta.url);
49+
const pkg = require(path.join(repoRoot, 'dist', 'index.js'));
50+
check('dist/index.js loads via require()', typeof pkg.createServer === 'function');
51+
check('ConfigManager is exported', typeof pkg.ConfigManager === 'function');
52+
53+
// 2. Scratch config pointing at a local fake upstream, so no real key is used.
54+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccmr-smoke-'));
55+
const configPath = path.join(tmpDir, 'models.yaml');
56+
57+
const upstream = http.createServer((req, res) => {
58+
let raw = '';
59+
req.on('data', (c) => (raw += c));
60+
req.on('end', () => {
61+
const body = raw ? JSON.parse(raw) : {};
62+
if (body.stream) {
63+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
64+
res.write(
65+
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":3,"output_tokens":0}}}\n\n'
66+
);
67+
res.write(
68+
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}\n\n'
69+
);
70+
res.write('event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":2}}\n\n');
71+
res.end();
72+
} else {
73+
res.writeHead(200, { 'Content-Type': 'application/json' });
74+
res.end(
75+
JSON.stringify({
76+
id: 'msg_smoke',
77+
type: 'message',
78+
role: 'assistant',
79+
content: [{ type: 'text', text: 'pong' }],
80+
model: 'smoke-001',
81+
stop_reason: 'end_turn',
82+
stop_sequence: null,
83+
usage: { input_tokens: 3, output_tokens: 2 },
84+
})
85+
);
86+
}
87+
});
88+
});
89+
90+
let gateway;
91+
try {
92+
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
93+
const upstreamPort = upstream.address().port;
94+
95+
fs.writeFileSync(
96+
configPath,
97+
`default_model: smoke-v1
98+
providers:
99+
smoke:
100+
display_name: Smoke Upstream
101+
provider: custom
102+
base_url: http://127.0.0.1:${upstreamPort}
103+
api_key_env: SMOKE_TEST_KEY
104+
auth_header: Authorization
105+
auth_type: bearer
106+
default_variant: v1
107+
variants:
108+
v1:
109+
display_name: "Smoke Model V1"
110+
model_id: smoke-001
111+
max_tokens: 4096
112+
context_window: 128000
113+
`
114+
);
115+
116+
// 3. Start the gateway from the committed dist, isolated from any real config.
117+
gateway = spawn(
118+
process.execPath,
119+
[path.join(repoRoot, 'dist', 'cli.js'), 'start', '-p', String(GATEWAY_PORT), '-c', configPath],
120+
{
121+
env: { ...process.env, SMOKE_TEST_KEY: 'sk-smoke', CCMR_HOME: tmpDir },
122+
stdio: ['ignore', 'pipe', 'pipe'],
123+
cwd: tmpDir,
124+
}
125+
);
126+
let gatewayOutput = '';
127+
gateway.stdout.on('data', (d) => (gatewayOutput += d));
128+
gateway.stderr.on('data', (d) => (gatewayOutput += d));
129+
130+
const base = `http://127.0.0.1:${GATEWAY_PORT}`;
131+
const up = await waitFor(async () => {
132+
try {
133+
return (await fetch(`${base}/health`)).ok;
134+
} catch {
135+
return false;
136+
}
137+
});
138+
check('gateway starts and answers /health', up, gatewayOutput.slice(-400));
139+
if (!up) throw new Error('gateway never became healthy');
140+
141+
const health = await (await fetch(`${base}/health`)).json();
142+
check('/health reports the loaded config file', health.config_file === configPath);
143+
check('/health reports the model as available', health.models['smoke-v1'] === 'available');
144+
145+
// 4. Non-streaming forwarding.
146+
const jsonRes = await fetch(`${base}/v1/messages`, {
147+
method: 'POST',
148+
headers: { 'Content-Type': 'application/json' },
149+
body: JSON.stringify({
150+
model: 'smoke-v1',
151+
max_tokens: 16,
152+
messages: [{ role: 'user', content: 'ping' }],
153+
}),
154+
});
155+
const json = await jsonRes.json();
156+
check('non-streaming request forwards and returns content', json?.content?.[0]?.text === 'pong');
157+
158+
// 5. SSE passthrough — the built-in-fetch ReadableStream path.
159+
const streamRes = await fetch(`${base}/v1/messages`, {
160+
method: 'POST',
161+
headers: { 'Content-Type': 'application/json' },
162+
body: JSON.stringify({
163+
model: 'smoke-v1',
164+
max_tokens: 16,
165+
stream: true,
166+
messages: [{ role: 'user', content: 'ping' }],
167+
}),
168+
});
169+
const sse = await streamRes.text();
170+
check('SSE stream passes message_start through', sse.includes('message_start'));
171+
check('SSE stream passes text deltas through', sse.includes('"text":"pong"'));
172+
check('SSE stream passes message_delta through', sse.includes('message_delta'));
173+
174+
// 6. Usage accounting parsed the stream.
175+
const usage = await (await fetch(`${base}/usage`)).json();
176+
check('usage counts both requests', usage.totals.requests === 2, JSON.stringify(usage.totals));
177+
} finally {
178+
if (gateway) gateway.kill();
179+
upstream.close();
180+
fs.rmSync(tmpDir, { recursive: true, force: true });
181+
}
182+
183+
console.log('');
184+
if (failures > 0) {
185+
console.error(`Smoke test FAILED on Node ${process.version} (${failures} check(s))`);
186+
process.exit(1);
187+
}
188+
console.log(`Smoke test passed on Node ${process.version}`);

0 commit comments

Comments
 (0)