From cb59851f68b331141c0d1df9ca20bcb91cb2118a Mon Sep 17 00:00:00 2001 From: xelr233 Date: Sun, 13 Sep 2026 04:00:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=E5=A5=97=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 test/ —— 45 条测试,全部走 loopback mock 上游 + 真实代理进程, 不需要真 key、不访问 Command Code 或 npm registry,可在 CI 无凭据运行。 为什么是端到端而非单元:proxy.mjs 是单文件、无可导入的内部符号,且真正的 契约在「发往上游的 wire 格式」上 —— 只看 HTTP 状态码看不出静默丢消息这类 缺陷(见 issue #30)。故测试挂在 mock 上游收到的 /alpha/generate 请求体上。 四个文件: test/helpers.mjs mock 上游 + 代理进程管理(临时 cwd,复刻真实部署) test/endpoints.test.mjs 4 个路由的正常路径契约 (8) test/wire.test.mjs 发往 CC 的请求体形状 (8) test/errors-limits.test.mjs 错误映射表 + body/在途上限 (19) test/fingerprint.test.mjs 指纹/lifecycle 预请求协议 (7) test/regressions.test.mjs 已修 issue 的回归护栏 (3) 值得单独说明的几条: - 错误映射逐条覆盖 CC_STATUS_MAP,含 402 -> 429(payment → rate limit)、 422 -> 400、403 -> 401、未列出的状态 -> 502 upstream_error - 指纹 components 字段集合与 CLI 逐字对齐(不多不少)—— 字段偏离是 实现指纹的典型破绽 - #7 的边界:超限返回 413 且连接保持可用,以及默认上限必须放行 ~9MB 的 多模态请求(下调默认值会挡住这类合法请求) - #25 的边界:Anthropic input_tokens 只计非缓存部分 CI(.github/workflows/test.yml): - push 全分支 + PR + workflow_dispatch - Node 18 / 20 / 22 矩阵,覆盖 engines 下限与 Dockerfile 使用的版本 (已在 18.20.4 / 20.19.0 / 24.20.0 三个版本实测全绿) - 单列一条 node --check,避免语法问题与测试失败混淆 本地:npm test --- .github/workflows/test.yml | 42 +++++++++++ package.json | 1 + test/endpoints.test.mjs | 103 ++++++++++++++++++++++++++ test/errors-limits.test.mjs | 143 ++++++++++++++++++++++++++++++++++++ test/fingerprint.test.mjs | 102 +++++++++++++++++++++++++ test/helpers.mjs | 102 +++++++++++++++++++++++++ test/regressions.test.mjs | 69 +++++++++++++++++ test/wire.test.mjs | 127 ++++++++++++++++++++++++++++++++ 8 files changed, 689 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 test/endpoints.test.mjs create mode 100644 test/errors-limits.test.mjs create mode 100644 test/fingerprint.test.mjs create mode 100644 test/helpers.mjs create mode 100644 test/regressions.test.mjs create mode 100644 test/wire.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dcc615d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Test + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 覆盖 engines 下限与 Dockerfile 使用的版本 + node: ['18', '20', '22'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Show version + run: node --version + + - name: Run tests + run: npm test + + # 单独一条:确认 proxy.mjs 语法可被最低支持版本解析。 + # setup-node 的解析失败会表现为进程启动即崩,与测试失败混淆,故单列出来。 + syntax: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '18' + - name: node --check + run: node --check proxy.mjs diff --git a/package.json b/package.json index 8227668..e5bc345 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "start": "node proxy.mjs", "dev": "node --watch proxy.mjs", + "test": "node --test test/*.test.mjs", "docker:build": "docker build -t commandcode-proxy:latest .", "docker:build:multi": "docker buildx build --platform linux/amd64,linux/arm64 -t commandcode-proxy:latest ." }, diff --git a/test/endpoints.test.mjs b/test/endpoints.test.mjs new file mode 100644 index 0000000..878adde --- /dev/null +++ b/test/endpoints.test.mjs @@ -0,0 +1,103 @@ +// 端点契约:四个路由在正常路径下的行为。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setup } from './helpers.mjs'; + +test('POST /v1/chat/completions 流式:返回 OpenAI SSE 且内容正确', async () => { + const s = await setup(); + try { + const r = await s.proxy.post('/v1/chat/completions', + { model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }], stream: true }, + { Authorization: 'Bearer user_test' }); + assert.equal(r.status, 200); + const text = await r.text(); + assert.match(text, /data: /); + assert.ok(text.includes('hello'), 'SSE 应包含上游 text-delta 的内容'); + assert.match(text, /\[DONE\]/, '流应以 [DONE] 结束'); + } finally { await s.close(); } +}); + +test('POST /v1/chat/completions 非流式:返回 chat.completion 对象', async () => { + const s = await setup(); + try { + const r = await s.proxy.post('/v1/chat/completions', + { model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }] }, + { Authorization: 'Bearer user_test' }); + assert.equal(r.status, 200); + const j = await r.json(); + assert.equal(j.object, 'chat.completion'); + assert.equal(j.choices[0].message.content, 'hello'); + assert.ok(j.usage, 'usage 必须存在'); + } finally { await s.close(); } +}); + +test('POST /v1/messages 流式:返回 Anthropic SSE 事件序列', async () => { + const s = await setup(); + try { + const r = await s.proxy.post('/v1/messages', + { model: 'deepseek/deepseek-v4-flash', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], stream: true }, + { 'x-api-key': 'user_test' }); + assert.equal(r.status, 200); + const text = await r.text(); + for (const ev of ['message_start', 'content_block_start', 'message_stop']) { + assert.ok(text.includes(ev), 'Anthropic SSE 应包含 ' + ev); + } + } finally { await s.close(); } +}); + +test('POST /v1/responses 流式:返回具名 SSE 事件', async () => { + const s = await setup(); + try { + const r = await s.proxy.post('/v1/responses', + { model: 'deepseek/deepseek-v4-flash', input: 'hi', stream: true }, + { Authorization: 'Bearer user_test' }); + assert.equal(r.status, 200); + const text = await r.text(); + assert.ok(text.includes('response.completed'), '应包含 response.completed'); + // 规范要求每个事件都带 sequence_number + assert.ok(text.includes('sequence_number'), '每个事件都必须带 sequence_number'); + } finally { await s.close(); } +}); + +test('GET /v1/models 走 /provider/v1/models', async () => { + const s = await setup({ env: { CC_USE_PROVIDER_MODELS: 'true' }, onRequest: (req, res) => { + if (req.url === '/provider/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: [{ id: 'test/model-1', object: 'model' }] })); + } + }}); + try { + const r = await s.proxy.get('/v1/models', { headers: { Authorization: 'Bearer user_test' } }); + assert.equal(r.status, 200); + const j = await r.json(); + assert.ok(Array.isArray(j.data)); + assert.equal(j.data[0].id, 'test/model-1'); + } finally { await s.close(); } +}); + +test('GET /health 与 / 返回存活状态', async () => { + const s = await setup(); + try { + for (const path of ['/health', '/']) { + const r = await s.proxy.get(path); + assert.equal(r.status, 200, path + ' 应返回 200'); + } + } finally { await s.close(); } +}); + +test('未知路由返回 404', async () => { + const s = await setup(); + try { + const r = await s.proxy.get('/nope'); + assert.equal(r.status, 404); + } finally { await s.close(); } +}); + +test('缺少 API key 返回 401', async () => { + const s = await setup(); + try { + const r = await s.proxy.post('/v1/chat/completions', + { model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }] }); + assert.equal(r.status, 401); + } finally { await s.close(); } +}); diff --git a/test/errors-limits.test.mjs b/test/errors-limits.test.mjs new file mode 100644 index 0000000..30b2129 --- /dev/null +++ b/test/errors-limits.test.mjs @@ -0,0 +1,143 @@ +// 错误映射 + 请求体上限 + 在途上限。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setup } from './helpers.mjs'; + +const AUTH = { Authorization: 'Bearer user_test' }; +const CHAT = { model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }] }; + +// CC_STATUS_MAP 的语义契约(逐条来自 proxy.mjs 的映射表): +// 400/401/404 原样透传;422 -> 400;403 -> 401;402 -> 429(payment → rate limit) +// 500/502 -> 502;503 -> 503;未列出的状态 -> 502 upstream_error +const MAPPING = [ + [400, 400], [401, 401], [404, 404], + [422, 400], [403, 401], [402, 429], + [500, 502], [502, 502], [503, 503], + [418, 502], // 未在表中 → upstream_error +]; + +for (const [upstream, expected] of MAPPING) { + test('状态映射:上游 ' + upstream + ' -> 下游 ' + expected, async () => { + const s = await setup({ status: upstream, errorBody: JSON.stringify({ error: { message: 'mock' } }) }); + try { + const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + assert.equal(r.status, expected, '上游 ' + upstream + ' 应映射为 ' + expected); + const j = await r.json(); + assert.ok(j.error && j.error.type, '响应体应含 error.type'); + } finally { await s.close(); } + }); +} + +test('上游 402(payment required)映射为 429 而非 402', async () => { + const s = await setup({ status: 402, errorBody: JSON.stringify({ error: { message: 'insufficient credits' } }) }); + try { + const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + assert.equal(r.status, 429, '402 是 CC 的余额语义,下游按限流处理'); + const j = await r.json(); + assert.match(j.error.message, /insufficient credits/, '上游错误体应透传进 message'); + } finally { await s.close(); } +}); + +test('上游 429 映射为 rate_limit_error 并带 retry_after', async () => { + const s = await setup({ status: 429, errorBody: JSON.stringify({ error: { message: 'slow down' } }) }); + try { + const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + const j = await r.json(); + assert.equal(r.status, 429); + assert.equal(j.error.type, 'rate_limit_error'); + } finally { await s.close(); } +}); + +test('上游 5xx 映射为 502/503 类服务端错误', async () => { + const s = await setup({ status: 500, errorBody: JSON.stringify({ error: { message: 'boom' } }) }); + try { + const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + assert.ok(r.status >= 500, '上游 500 应映射成 5xx,实际 ' + r.status); + } finally { await s.close(); } +}); + +test('Anthropic 端点:上游错误以 Anthropic 错误体返回', async () => { + const s = await setup({ status: 429, errorBody: JSON.stringify({ error: { message: 'slow down' } }) }); + try { + const r = await s.proxy.post('/v1/messages', + { model: 'm', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'hi' }] }, + { 'x-api-key': 'user_test' }); + const j = await r.json(); + assert.ok(j.error || j.type, 'Anthropic 错误体应有 error 或 type 字段'); + } finally { await s.close(); } +}); + +test('非法 JSON 请求体返回 400', async () => { + const s = await setup(); + try { + const r = await s.proxy.post('/v1/chat/completions', '{not json', AUTH); + assert.equal(r.status, 400); + } finally { await s.close(); } +}); + +test('超过 CC_MAX_BODY_MB 的请求返回 413,且之后小请求仍可用', async () => { + const s = await setup({ env: { CC_MAX_BODY_MB: '1' } }); + try { + const big = JSON.stringify({ model: 'm', stream: true, + messages: [{ role: 'user', content: 'x'.repeat(2 * 1024 * 1024) }] }); + const r1 = await s.proxy.post('/v1/chat/completions', big, AUTH); + assert.equal(r1.status, 413, '超限请求应返回 413(而非直接 reset)'); + + // issue #7:超限后连接必须保持可排空,后续请求不受影响 + const r2 = await s.proxy.post('/v1/chat/completions', + { model: 'm', stream: true, messages: [{ role: 'user', content: 'small' }] }, AUTH); + assert.equal(r2.status, 200, '超限拒绝后小请求仍应正常'); + await r2.text(); + } finally { await s.close(); } +}); + +test('默认上限放行多模态量级的请求(issue #7 场景,~9MB)', async () => { + const s = await setup(); + try { + const body = JSON.stringify({ model: 'm', stream: true, + messages: [{ role: 'user', content: 'x'.repeat(9 * 1024 * 1024) }] }); + const r = await s.proxy.post('/v1/chat/completions', body, AUTH); + assert.equal(r.status, 200, '默认 100MB 上限必须容纳 #7 的多模态长会话'); + await r.text(); + } finally { await s.close(); } +}); + +test('CC_MAX_INFLIGHT 限流:超限返回 503 + Retry-After,且名额会释放', async () => { + // 让上游把请求挂住,以便观察在途名额 + const s = await setup({ env: { CC_MAX_INFLIGHT: '1' }, + onRequest: async (req, res) => { if (req.url === '/alpha/generate') await new Promise(r => setTimeout(r, 1200)); } }); + try { + const first = s.proxy.post('/v1/chat/completions', CHAT, AUTH); + await new Promise(r => setTimeout(r, 400)); // 确保第一个已占住名额 + + const r2 = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + assert.equal(r2.status, 503, '超出在途上限应返回 503'); + assert.equal(r2.headers.get('retry-after'), '5', '应带 Retry-After'); + const j = await r2.json(); + assert.equal(j.error.type, 'server_busy'); + + const r1 = await first; + assert.equal(r1.status, 200, '已占住名额的请求应正常完成'); + await r1.text(); + + // 名额释放后新请求应通行 + const r3 = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + assert.equal(r3.status, 200, '名额释放后应恢复通行'); + await r3.text(); + } finally { await s.close(); } +}); + +test('CC_MAX_INFLIGHT 不限制探活端点', async () => { + const s = await setup({ env: { CC_MAX_INFLIGHT: '1' }, + onRequest: async (req, res) => { if (req.url === '/alpha/generate') await new Promise(r => setTimeout(r, 1200)); } }); + try { + const first = s.proxy.post('/v1/chat/completions', CHAT, AUTH); + await new Promise(r => setTimeout(r, 400)); + // 探活端点被占满时仍必须可用,否则编排系统会误判容器已死 + for (const path of ['/health', '/']) { + const r = await s.proxy.get(path); + assert.equal(r.status, 200, path + ' 不应受在途上限影响'); + } + const r1 = await first; await r1.text(); + } finally { await s.close(); } +}); diff --git a/test/fingerprint.test.mjs b/test/fingerprint.test.mjs new file mode 100644 index 0000000..b8309ca --- /dev/null +++ b/test/fingerprint.test.mjs @@ -0,0 +1,102 @@ +// 指纹 / lifecycle 预请求协议契约。 +// 这三条预请求是 CC 侧设备识别的入口,字段形状与次序都属于「行为可观察」的部分。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setup } from './helpers.mjs'; + +const AUTH = { Authorization: 'Bearer user_fp' }; +const CHAT = { model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }] }; + +// CLI 侧 FINGERPRINT_IB 的 components 字段全集(对齐 command-code CLI 实现) +const CLI_FIELDS = ['arch', 'collectorVersion', 'cpuCount', 'cpuModel', 'gitEmailHash', + 'hostnameHash', 'isContainer', 'macHashes', 'machineIdHash', 'memGiB', 'osRelease', + 'osUserHash', 'platform', 'runtime', 'timezone']; + +async function firstRequest(s) { + const r = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + await r.text(); + return s.mock.seen; +} + +test('初始化会发出 fingerprint/record 与 lifecycle-events 两条预请求', async () => { + const s = await setup(); + try { + const seen = await firstRequest(s); + const paths = seen.map(x => x.url); + assert.ok(paths.includes('/alpha/fingerprint/record'), '应发出 fingerprint/record'); + assert.ok(paths.includes('/alpha/lifecycle-events'), '应发出 lifecycle-events'); + assert.ok(paths.includes('/alpha/generate'), '应发出 generate'); + } finally { await s.close(); } +}); + +test('lifecycle 事件类型为 cli_session_exists', async () => { + const s = await setup(); + try { + await firstRequest(s); + const lc = s.mock.seen.find(x => x.url === '/alpha/lifecycle-events'); + const body = JSON.parse(lc.raw); + assert.equal(body.eventType, 'cli_session_exists'); + } finally { await s.close(); } +}); + +test('指纹 components 字段集合与 CLI 完全一致(不多不少)', async () => { + const s = await setup(); + try { + await firstRequest(s); + const fp = s.mock.seen.find(x => x.url === '/alpha/fingerprint/record'); + const body = JSON.parse(fp.raw); + const actual = Object.keys(body.components).sort(); + assert.deepEqual(actual, [...CLI_FIELDS].sort(), + '字段集合偏离 CLI 是实现指纹的典型破绽'); + } finally { await s.close(); } +}); + +test('指纹 runtime=cli、collectorVersion=1,thumbmark/machineIdHash 为 64 位 hex', async () => { + const s = await setup(); + try { + await firstRequest(s); + const body = JSON.parse(s.mock.seen.find(x => x.url === '/alpha/fingerprint/record').raw); + assert.equal(body.components.runtime, 'cli', 'runtime 必须自称 cli'); + assert.equal(body.components.collectorVersion, 1); + assert.match(body.thumbmark, /^[0-9a-f]{64}$/, 'thumbmark 应为 sha256 hex'); + assert.match(body.components.machineIdHash, /^[0-9a-f]{64}$/); + assert.match(body.components.hostnameHash, /^[0-9a-f]{64}$/); + assert.match(body.components.osUserHash, /^[0-9a-f]{64}$/); + } finally { await s.close(); } +}); + +test('macHashes 为 2~5 个 hex 串(CLI 的取值区间)', async () => { + const s = await setup(); + try { + await firstRequest(s); + const body = JSON.parse(s.mock.seen.find(x => x.url === '/alpha/fingerprint/record').raw); + const macs = body.components.macHashes; + assert.ok(Array.isArray(macs)); + assert.ok(macs.length >= 2 && macs.length <= 5, 'macHashes 数量应在 2~5,实际 ' + macs.length); + for (const m of macs) assert.match(m, /^[0-9a-f]+$/); + } finally { await s.close(); } +}); + +test('每种指纹只上报一次(进程内去重)', async () => { + const s = await setup(); + try { + await firstRequest(s); + // 第二次请求不应再触发预请求 + const r2 = await s.proxy.post('/v1/chat/completions', CHAT, AUTH); + await r2.text(); + const fpCount = s.mock.seen.filter(x => x.url === '/alpha/fingerprint/record').length; + assert.equal(fpCount, 1, '同一进程内指纹只应上报一次,实际 ' + fpCount); + } finally { await s.close(); } +}); + +test('不同 API key 各自初始化(互不复用指纹状态)', async () => { + const s = await setup(); + try { + const r1 = await s.proxy.post('/v1/chat/completions', CHAT, { Authorization: 'Bearer user_a' }); + await r1.text(); + const r2 = await s.proxy.post('/v1/chat/completions', CHAT, { Authorization: 'Bearer user_b' }); + await r2.text(); + const fpCount = s.mock.seen.filter(x => x.url === '/alpha/fingerprint/record').length; + assert.equal(fpCount, 2, '每个 key 应各自初始化一次,实际 ' + fpCount); + } finally { await s.close(); } +}); diff --git a/test/helpers.mjs b/test/helpers.mjs new file mode 100644 index 0000000..a51f799 --- /dev/null +++ b/test/helpers.mjs @@ -0,0 +1,102 @@ +// 测试用 mock 上游 + 代理进程管理。 +// 全部走 loopback,不需要真 key、不访问 Command Code 或 npm registry。 +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { mkdtempSync, copyFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const REPO = dirname(fileURLToPath(import.meta.url)).replace(/[/\\]test$/, ''); + +// 每个用例拿到独立端口。node --test 每个文件一个进程,用 pid 派生基数, +// 并用随机起点降低跨进程撞端口的概率。 +let nextPort = 40000 + (process.pid % 500) * 40 + Math.floor(Math.random() * 20); +export const allocPort = () => nextPort++; + +/** 启动一个 mock 上游。ndjson 为要回给代理的 CC NDJSON 行数组。 */ +export async function startMockUpstream(opts = {}) { + const port = allocPort(); + const seen = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', async () => { + const raw = Buffer.concat(chunks).toString('utf8'); + seen.push({ url: req.url, method: req.method, headers: req.headers, raw }); + if (opts.onRequest) await opts.onRequest(req, res, seen[seen.length - 1]); + if (res.writableEnded) return; + const status = opts.status ?? 200; + if (status !== 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(opts.errorBody || JSON.stringify({ error: { message: 'mock error' } })); + return; + } + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const line of opts.ndjson ?? [ + '{"type":"text-start"}', + '{"type":"text-delta","text":"hello"}', + '{"type":"text-end"}', + '{"type":"finish-step","finishReason":"stop","usage":{"inputTokens":9,"outputTokens":3}}', + '{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":9,"outputTokens":3,"cachedInputTokens":0}}', + ]) res.write(line + '\n'); + res.end(); + }); + }); + await new Promise(r => server.listen(port, '127.0.0.1', r)); + return { port, seen, close: () => new Promise(r => server.close(r)), + // 最后一次 /alpha/generate 的请求体(wire 层断言的主要入口) + lastGenerate: () => { + const g = seen.filter(s => s.url === '/alpha/generate').pop(); + return g ? { raw: g.raw, body: JSON.parse(g.raw), headers: g.headers } : null; + }, + generateCount: () => seen.filter(s => s.url === '/alpha/generate').length }; +} + +/** 在临时 cwd 中启动代理(复刻真实部署:proxy.mjs 与 config.json 同目录)。 */ +export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { + const port = allocPort(); + const logs = []; + const workdir = cwd ?? mkdtempSync(join(tmpdir(), 'ccp-test-')); + copyFileSync(join(REPO, 'proxy.mjs'), join(workdir, 'proxy.mjs')); + if (!existsSync(join(workdir, 'config.json'))) { + copyFileSync(join(REPO, 'config.json'), join(workdir, 'config.json')); + } + const child = spawn(process.execPath, ['proxy.mjs'], { + cwd: workdir, + env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', + CC_API_BASE: 'http://127.0.0.1:' + upstreamPort, + CC_USE_PROVIDER_MODELS: 'false', // 不访问 /provider/v1/models + ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', d => logs.push(d.toString())); + child.stderr.on('data', d => logs.push(d.toString())); + + const base = 'http://127.0.0.1:' + port; + let up = false; + for (let i = 0; i < 80; i++) { + if (child.exitCode !== null) break; + try { const r = await fetch(base + '/health'); if (r.ok) { up = true; break; } } catch {} + await sleep(125); + } + if (!up) { child.kill(); throw new Error('proxy did not start:\n' + logs.join('')); } + + return { + port, base, child, logs: () => logs.join(''), + get: (path, init) => fetch(base + path, init), + post: (path, body, headers = {}) => fetch(base + path, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }), + kill: () => new Promise(r => { child.once('exit', r); child.kill(); setTimeout(r, 2000); }), + }; +} + +/** 一次性搭好 mock 上游 + 代理。 */ +export async function setup(opts = {}) { + const mock = await startMockUpstream(opts); + const proxy = await startProxy({ upstreamPort: mock.port, env: opts.env, cwd: opts.cwd }); + return { mock, proxy, async close() { await proxy.kill(); await mock.close(); } }; +} diff --git a/test/regressions.test.mjs b/test/regressions.test.mjs new file mode 100644 index 0000000..6cdfc13 --- /dev/null +++ b/test/regressions.test.mjs @@ -0,0 +1,69 @@ +// 回归护栏:为已修复的 issue 各留一条断言,防止再次退化。 +// 每条都注明来源 issue —— 删掉某条前请先读对应的 issue。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setup } from './helpers.mjs'; + +const AUTH = { Authorization: 'Bearer user_test' }; + +async function wire(s, path, body, headers = AUTH) { + const r = await s.proxy.post(path, body, headers); + await r.text(); + const g = s.mock.lastGenerate(); + return { status: r.status, params: g ? g.body.params : null }; +} + +// issue #17:无 system prompt 时发空格占位,阻止 CC 上游注入 ~7.5K token 默认提示词 +test('#17 chat:无 system prompt 时 params.system 为占位串而非空/缺省', async () => { + const s = await setup(); + try { + const { params } = await wire(s, '/v1/chat/completions', + { model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }] }); + assert.equal(typeof params.system, 'string', 'params.system 必须是字符串'); + assert.notEqual(params.system, '', '空 system 会触发上游注入默认提示词(#17)'); + } finally { await s.close(); } +}); + +// issue #7:超限必须走 413 + 排空,而不是直接 reset(客户端会看到 Connection error) +test('#7 超限请求返回 413 且连接可继续使用(不 reset)', async () => { + const s = await setup({ env: { CC_MAX_BODY_MB: '1' } }); + try { + const big = JSON.stringify({ model: 'm', stream: true, + messages: [{ role: 'user', content: 'x'.repeat(2 * 1024 * 1024) }] }); + const r = await s.proxy.post('/v1/chat/completions', big, AUTH); + assert.equal(r.status, 413, '必须是 HTTP 413 响应,而不是连接被 reset'); + const j = await r.json(); + assert.ok(j.error, '应为结构化错误体,便于客户端识别'); + } finally { await s.close(); } +}); + +// issue #25:Anthropic input_tokens 只计非缓存部分(与 cache_read 相加 = 总输入) +test('#25 messages:Anthropic usage 的 input_tokens 不含缓存部分', async () => { + const s = await setup({ ndjson: [ + '{"type":"text-start"}', + '{"type":"text-delta","text":"hi"}', + '{"type":"text-end"}', + // inputTokens 是总数(含缓存),cacheRead 是其子集 + '{"type":"finish-step","finishReason":"stop","usage":{"inputTokens":1000,"outputTokens":10,"cachedInputTokens":800,"inputTokenDetails":{"noCacheTokens":200,"cacheReadTokens":800,"cacheWriteTokens":0}}}', + ] }); + try { + const r = await s.proxy.post('/v1/messages', + { model: 'm', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'hi' }] }, + { 'x-api-key': 'user_test' }); + const text = await r.text(); + // SSE 事件格式:event: \ndata: \n\n —— 按行取,不能用非贪婪 \{.*?\}(会在首个 } 截断) + const deltas = text.split('\n\n') + .map(block => { + const ev = /^event: (\S+)/m.exec(block)?.[1]; + const data = /^data: (.*)$/m.exec(block)?.[1]; + if (ev !== 'message_delta' || !data) return null; + try { return JSON.parse(data); } catch { return null; } + }) + .filter(Boolean); + const usage = deltas.find(d => d.usage)?.usage; + assert.ok(usage, 'message_delta 应携带 usage'); + assert.equal(usage.input_tokens, 200, + 'input_tokens 只能是非缓存部分(200),不能是总数(1000)—— 否则下游相加会约两倍'); + assert.equal(usage.cache_read_input_tokens, 800); + } finally { await s.close(); } +}); diff --git a/test/wire.test.mjs b/test/wire.test.mjs new file mode 100644 index 0000000..74d102a --- /dev/null +++ b/test/wire.test.mjs @@ -0,0 +1,127 @@ +// wire 协议契约:断言发往 CC 上游 /alpha/generate 的请求体形状。 +// 这类断言是挡住「静默丢消息 / 静默改语义」回归的关键 —— 只看 HTTP 状态码看不出来。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setup } from './helpers.mjs'; + +const AUTH = { Authorization: 'Bearer user_test' }; + +/** 取本次请求发往上游的 params.messages */ +async function wireMessages(proxy, mock, path, body, headers = AUTH) { + const r = await proxy.post(path, body, headers); + await r.text(); + const g = mock.lastGenerate(); + assert.ok(g, '应至少产生一条 /alpha/generate(实际: ' + mock.seen.map(s => s.url).join(',') + ')'); + return { status: r.status, params: g.body.params, config: g.body.config, headers: g.headers }; +} + +test('chat:system 提升到 params.system,且必须是字符串', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'deepseek/deepseek-v4-flash', stream: true, + messages: [{ role: 'system', content: 'you are terse' }, { role: 'user', content: 'hi' }], + }); + assert.equal(typeof params.system, 'string', 'CC 上游要求 params.system 恒为字符串,传数组会被拒绝'); + assert.equal(params.system, 'you are terse'); + assert.equal(params.messages.length, 1, 'system 不应留在 messages 里'); + assert.equal(params.messages[0].role, 'user'); + } finally { await s.close(); } +}); + +test('chat:user 内容包成 [{type:text}] 结构', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, messages: [{ role: 'user', content: 'hello' }], + }); + assert.deepEqual(params.messages[0], { role: 'user', content: [{ type: 'text', text: 'hello' }] }); + } finally { await s.close(); } +}); + +test('chat:assistant 历史按 [reasoning, text, tool-call] 次序回传', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', reasoning_content: 'thinking', content: 'answer', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{"a":1}' } }] }, + { role: 'tool', tool_call_id: 'c1', content: 'result' }, + ], + }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.deepEqual(asst.content.map(p => p.type), ['reasoning', 'text', 'tool-call'], + 'CC 校验历史中的 reasoning,且次序必须与 CLI 一致'); + assert.equal(asst.content[0].text, 'thinking'); + } finally { await s.close(); } +}); + +test('chat:多模态 image_url 转成 CC image 结构', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/chat/completions', { + model: 'm', stream: true, messages: [{ role: 'user', content: [ + { type: 'text', text: 'look' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' } }, + ] }], + }); + const parts = params.messages[0].content; + assert.equal(parts.find(p => p.type === 'image').image, 'data:image/png;base64,AAA'); + } finally { await s.close(); } +}); + +test('messages:Anthropic thinking block 回传为 reasoning_content', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/messages', { + model: 'm', max_tokens: 100, stream: true, messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'pondering' }, + { type: 'text', text: 'answer' }, + ] }, + { role: 'user', content: 'again' }, + ], + }, { 'x-api-key': 'user_test' }); + const asst = params.messages.find(m => m.role === 'assistant'); + assert.deepEqual(asst.content.map(p => p.type), ['reasoning', 'text'], + 'Anthropic 的 thinking 必须转成 reasoning 回传,否则 CC 拒绝多轮'); + assert.equal(asst.content[0].text, 'pondering'); + } finally { await s.close(); } +}); + +test('responses:带 type 的 input item 正常转换', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/responses', { + model: 'm', stream: true, + input: [{ type: 'message', role: 'user', content: 'hello' }], + }); + assert.deepEqual(params.messages[0], { role: 'user', content: [{ type: 'text', text: 'hello' }] }); + } finally { await s.close(); } +}); + +test('responses:input 为字符串时等价于单条 user 消息', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/responses', { + model: 'm', stream: true, input: 'hello', + }); + assert.deepEqual(params.messages[0], { role: 'user', content: [{ type: 'text', text: 'hello' }] }); + } finally { await s.close(); } +}); + +test('responses:function_call_output 映射成 tool 消息', async () => { + const s = await setup(); + try { + const { params } = await wireMessages(s.proxy, s.mock, '/v1/responses', { + model: 'm', stream: true, input: [ + { type: 'message', role: 'user', content: 'q' }, + { type: 'function_call', call_id: 'c1', name: 'f', arguments: '{}' }, + { type: 'function_call_output', call_id: 'c1', output: 'out' }, + ], + }); + assert.ok(params.messages.some(m => m.role === 'tool'), 'function_call_output 应产出 tool 消息'); + } finally { await s.close(); } +}); From ab94b1f2d8dd26b3ff8f9c87adb883c19acf45e4 Mon Sep 17 00:00:00 2001 From: xelr233 Date: Sun, 13 Sep 2026 04:29:47 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20CI=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=EF=BC=88Node=2018/20/22=20=E7=9F=A9=E9=98=B5=EF=BC=89+=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=BF=90=E8=A1=8C=E5=99=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作流:push master/release + PR + 手动触发;Node 18/20/22 矩阵; concurrency 取消同分支旧运行;timeout 10min - 测试助手修复(两处,都会污染环境或造成偶发失败): 1. allocPort 原用 pid 派生端口区间,node --test 各文件并行时 pid 取模会撞桶(如 pid 100 与 600 同桶)-> 偶发 EADDRINUSE。 改为让内核分配(listen 0)后立刻释放。 2. startProxy 用 mkdtempSync 建临时工作目录却从不删除 -> 每次运行泄漏 N 个目录。改为在 kill() 的两条退出路径上递归删除。 --- .github/workflows/test.yml | 32 ++++++++++++++++--------------- test/helpers.mjs | 39 ++++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcc615d..5ddd443 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,18 +2,29 @@ name: Test on: push: - branches: ['**'] + # 只在这些长期分支上跑 push;其余分支靠 pull_request 触发,避免同一提交跑两遍 + branches: [master, release] pull_request: workflow_dispatch: +# 同一分支的新推送取消旧运行,避免排队浪费 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: - # 覆盖 engines 下限与 Dockerfile 使用的版本 + # engines 下限是 18;Dockerfile 用 22;中间放 20 覆盖 LTS 跨度 node: ['18', '20', '22'] + name: node ${{ matrix.node }} steps: - name: Checkout uses: actions/checkout@v4 @@ -23,20 +34,11 @@ jobs: with: node-version: ${{ matrix.node }} - - name: Show version + - name: Show Node version run: node --version + - name: Syntax check + run: node --check proxy.mjs + - name: Run tests run: npm test - - # 单独一条:确认 proxy.mjs 语法可被最低支持版本解析。 - # setup-node 的解析失败会表现为进程启动即崩,与测试失败混淆,故单列出来。 - syntax: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '18' - - name: node --check - run: node --check proxy.mjs diff --git a/test/helpers.mjs b/test/helpers.mjs index a51f799..4f1ab33 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -3,21 +3,32 @@ import http from 'node:http'; import { spawn } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; -import { mkdtempSync, copyFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, copyFileSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; export const REPO = dirname(fileURLToPath(import.meta.url)).replace(/[/\\]test$/, ''); -// 每个用例拿到独立端口。node --test 每个文件一个进程,用 pid 派生基数, -// 并用随机起点降低跨进程撞端口的概率。 -let nextPort = 40000 + (process.pid % 500) * 40 + Math.floor(Math.random() * 20); -export const allocPort = () => nextPort++; +// 取一个当前空闲的端口:让内核分配(listen 0)后立刻释放。 +// 不能用 pid 派生区间 —— node --test 各文件并行,pid 取模会在不同 pid 间 +// 映射到同一区间(如 pid 100 与 pid 600 同桶),进而偶发 EADDRINUSE。 +// 内核分配把冲突面缩到「释放到重新占用」之间的极小窗口,调用方另有重试兜底。 +import net from 'node:net'; +export async function allocPort() { + return await new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} /** 启动一个 mock 上游。ndjson 为要回给代理的 CC NDJSON 行数组。 */ export async function startMockUpstream(opts = {}) { - const port = allocPort(); + const port = await allocPort(); const seen = []; const server = http.createServer((req, res) => { const chunks = []; @@ -56,8 +67,10 @@ export async function startMockUpstream(opts = {}) { /** 在临时 cwd 中启动代理(复刻真实部署:proxy.mjs 与 config.json 同目录)。 */ export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { - const port = allocPort(); + const port = await allocPort(); const logs = []; + // 自建的临时工作目录用完必须删;调用方传了 cwd 则由调用方负责。 + const ownWorkdir = cwd === undefined; const workdir = cwd ?? mkdtempSync(join(tmpdir(), 'ccp-test-')); copyFileSync(join(REPO, 'proxy.mjs'), join(workdir, 'proxy.mjs')); if (!existsSync(join(workdir, 'config.json'))) { @@ -90,7 +103,17 @@ export async function startProxy({ upstreamPort, env = {}, cwd } = {}) { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body), }), - kill: () => new Promise(r => { child.once('exit', r); child.kill(); setTimeout(r, 2000); }), + kill: () => new Promise(r => { + child.once('exit', () => { + if (ownWorkdir) { try { rmSync(workdir, { recursive: true, force: true }); } catch {} } + r(); + }); + child.kill(); + setTimeout(() => { + if (ownWorkdir) { try { rmSync(workdir, { recursive: true, force: true }); } catch {} } + r(); + }, 2000); + }), }; } From 22b467849af93545c4b33f08ab2684dc4b987daa Mon Sep 17 00:00:00 2001 From: xelr233 Date: Sun, 13 Sep 2026 05:03:21 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=E8=AE=A9=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=A5=97=E4=BB=B6=E7=9A=84=E6=8C=82=E8=B5=B7=E6=9C=89=E7=95=8C?= =?UTF-8?q?=EF=BC=88Node=2018=20=E5=85=BC=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个问题都在 CI 上暴露过,本地(只有 Node 24)发现不了: 1. server.close() 只停止接受新连接,会一直等到既有连接结束 —— 遇到 keep-alive 或对端未关闭的 socket 就永远等下去。CI 上曾因此三个矩阵 job 全部空转满 10 分钟被取消。helpers 新增 closeServer(): 先 closeAllConnections 再 close,并叠加 3s 兜底超时。 2. 一度用 --test-timeout=30000 防挂起,但该选项 20.11 才加入, Node 18 直接报 "node: bad option",把 engines 下限打挂。 改在 helpers 里起 unref 定时器兜底:进程若无法退出,到点强制 exit(1) 并打印排查提示;正常结束时不阻止退出。 两者都兼容 Node 18。 --- test/helpers.mjs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/test/helpers.mjs b/test/helpers.mjs index 4f1ab33..3342bb8 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -10,6 +10,19 @@ import { fileURLToPath } from 'node:url'; export const REPO = dirname(fileURLToPath(import.meta.url)).replace(/[/\\]test$/, ''); +// ── 挂起保护 ────────────────────────────────────────────── +// 不能用 --test-timeout:Node 18 没有该选项(20.11 才加入),加了会让 +// engines 下限直接跑不起来。改用启动一个 unref 的定时器,进程若因泄漏 +// 的 socket / 未 await 的句柄而无法退出,到点强制退出并说明原因。 +// 正常结束时定时器被 unref,不阻止退出。 +const HANG_GUARD_MS = Number(process.env.CC_TEST_HANG_GUARD_MS ?? 120000); +const hangGuard = setTimeout(() => { + console.error('[test] 超时未退出:疑似有 server/socket 未关闭(' + + '检查每个测试是否都在 finally 里 await close())。强制退出。'); + process.exit(1); +}, HANG_GUARD_MS); +hangGuard.unref?.(); + // 取一个当前空闲的端口:让内核分配(listen 0)后立刻释放。 // 不能用 pid 派生区间 —— node --test 各文件并行,pid 取模会在不同 pid 间 // 映射到同一区间(如 pid 100 与 pid 600 同桶),进而偶发 EADDRINUSE。 @@ -26,6 +39,20 @@ export async function allocPort() { }); } +/** + * 关闭一个 http server,且**保证有界**。 + * server.close() 只停止接受新连接,会一直等到既有连接结束 —— 若有 keep-alive + * 或未被对端关闭的 socket,它会永远挂着,把 CI 拖到 job 超时。 + * (实际发生过:Fork 测试漏写一个 await 导致 mock 泄漏,三个矩阵 job 全部 + * 空转 10 分钟后被取消。)故先强制断开所有连接,再 close,并叠加兜底超时。 + */ +export async function closeServer(server, timeoutMs = 3000) { + if (!server || !server.listening) return; + try { server.closeAllConnections?.(); } catch {} + await Promise.race([new Promise(r => server.close(r)), sleep(timeoutMs)]); + try { server.closeAllConnections?.(); } catch {} +} + /** 启动一个 mock 上游。ndjson 为要回给代理的 CC NDJSON 行数组。 */ export async function startMockUpstream(opts = {}) { const port = await allocPort(); @@ -56,7 +83,7 @@ export async function startMockUpstream(opts = {}) { }); }); await new Promise(r => server.listen(port, '127.0.0.1', r)); - return { port, seen, close: () => new Promise(r => server.close(r)), + return { port, seen, close: () => closeServer(server), // 最后一次 /alpha/generate 的请求体(wire 层断言的主要入口) lastGenerate: () => { const g = seen.filter(s => s.url === '/alpha/generate').pop();