llm-switcher 1.6.11 → 1.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -2
- package/README.vi.md +3 -2
- package/formats.mjs +158 -13
- package/package.json +1 -1
- package/proxy.mjs +54 -38
- package/scripts/run-tests.mjs +60 -12
- package/switch.mjs +38 -13
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-check.test.mjs +7 -2
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +301 -1
- package/tests/gateway.e2e.test.mjs +231 -1
- package/tests/goalcheck.e2e.test.mjs +90 -8
- package/tests/idlecompact.e2e.test.mjs +6 -1
- package/tests/lifecycle.test.mjs +5 -1
- package/tests/run-tests.test.mjs +50 -0
- package/tests/shim.test.mjs +24 -18
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +147 -3
- package/windows.mjs +63 -6
|
@@ -20,6 +20,8 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
20
20
|
|
|
21
21
|
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir, cancelExitReap;
|
|
22
22
|
const received = []; // { url, headers, body }
|
|
23
|
+
// A model list read, kept apart so a background read never shows up as the last request a test made.
|
|
24
|
+
const modelReads = [];
|
|
23
25
|
const hangState = { closed: false, slowAborted: false };
|
|
24
26
|
const bigState = { finishedAt: 0 };
|
|
25
27
|
|
|
@@ -62,13 +64,43 @@ const sse = (res, objs, { raw = [] } = {}) => {
|
|
|
62
64
|
res.end();
|
|
63
65
|
};
|
|
64
66
|
|
|
67
|
+
// Upstream errors keyed by a MOCK_ERR:<key> marker in the last user message: [status, message].
|
|
68
|
+
const MOCK_ERRORS = {
|
|
69
|
+
comma: [400, "This model's maximum context length is 131,072 tokens. However, your messages resulted in 140,000 tokens."],
|
|
70
|
+
dot: [400, 'Input exceeds the context window of 131.072 tokens: requested 140.000 tokens'],
|
|
71
|
+
space: [400, 'maximum context length is 131 072 tokens, you requested 140 000 tokens'],
|
|
72
|
+
underscore: [400, 'maximum context length is 131_072 tokens, you requested 140_000 tokens'],
|
|
73
|
+
prefixed: [400, 'prompt is too long: 200000 tokens > 199999 maximum'],
|
|
74
|
+
nonumber: [400, 'The input exceeds the context window of this model.'],
|
|
75
|
+
ksuffix: [400, 'maximum context length is 128k tokens, you requested 140k tokens'],
|
|
76
|
+
msuffix: [400, "This model's maximum context length is 1m tokens. However, your messages resulted in 1.2M tokens."],
|
|
77
|
+
narrownbsp: [400, 'maximum context length is 131\u202f072 tokens, you requested 140\u202f000 tokens'],
|
|
78
|
+
nbsp: [400, 'maximum context length is 131\u00a0072 tokens, you requested 140\u00a0000 tokens'],
|
|
79
|
+
apostrophe: [400, "maximum context length is 131'072 tokens, you requested 140'000 tokens"],
|
|
80
|
+
indian: [400, 'maximum context length is 1,31,072 tokens, you requested 1,40,000 tokens'],
|
|
81
|
+
tokenlimit: [400, 'context_length_exceeded: input is 140000 tokens, over the token limit of 131072'],
|
|
82
|
+
decimal: [400, 'context_length_exceeded: limit of 3.141 seconds per call; input is 140000 tokens'],
|
|
83
|
+
fraction: [400, 'context_length_exceeded: limit 0.125 of the window; input is 140000 tokens'],
|
|
84
|
+
limitof: [400, 'context_length_exceeded: limit of 131072 tokens, input is 140000 tokens'],
|
|
85
|
+
limitbare: [400, 'context_length_exceeded: limit 131072 tokens, input is 140000 tokens'],
|
|
86
|
+
limitnotokens: [400, 'context_length_exceeded: limit of 131072. input is 140000 tokens'],
|
|
87
|
+
hugek: [400, 'maximum context length is 100000000000000000000k tokens, you requested 200000000000000000000k tokens'],
|
|
88
|
+
hugedigits: [400, 'maximum context length is 123456789012345678901 tokens, you requested 223456789012345678901 tokens'],
|
|
89
|
+
zero: [400, 'maximum context length is 0 tokens, you requested 140000 tokens'],
|
|
90
|
+
zerok: [400, 'maximum context length is 0k tokens, you requested 140k tokens'],
|
|
91
|
+
zeroasked: [400, 'maximum context length is 131072 tokens, you requested 0 tokens'],
|
|
92
|
+
rate: [429, 'Rate limit reached on context tokens per minute: maximum context length is 131,072'],
|
|
93
|
+
server: [500, "Internal error: this model's maximum context length is 131,072 tokens"]
|
|
94
|
+
};
|
|
95
|
+
|
|
65
96
|
function startUpstream() {
|
|
66
97
|
upstream = http.createServer((req, res) => {
|
|
67
98
|
let body = '';
|
|
68
99
|
req.on('data', c => { body += c; });
|
|
69
100
|
req.on('end', () => {
|
|
70
101
|
const json = body ? JSON.parse(body) : {};
|
|
71
|
-
|
|
102
|
+
if (req.method === 'GET' && req.url.split('?')[0].endsWith('/models')) modelReads.push({ url: req.url, headers: req.headers });
|
|
103
|
+
else received.push({ url: req.url, headers: req.headers, body: json, raw: body });
|
|
72
104
|
const lastUser = JSON.stringify(json.messages?.at(-1) ?? json.contents?.at(-1) ?? '');
|
|
73
105
|
|
|
74
106
|
// A stand-in intact: claude/* is a Claude Code account, the rest is not.
|
|
@@ -79,6 +111,14 @@ function startUpstream() {
|
|
|
79
111
|
}
|
|
80
112
|
// A provider list with real windows: a 1M model that also names the point to compress at,
|
|
81
113
|
// and a smaller one. A model the list says nothing of keeps the catalog template.
|
|
114
|
+
// Two accounts on one baseURL whose lists name different output limits, read a little slowly.
|
|
115
|
+
if (req.method === 'GET' && req.url.startsWith('/chat/v1/models') && /^Bearer sk-cap-[ab]$/.test(req.headers.authorization || '')) {
|
|
116
|
+
const output = req.headers.authorization === 'Bearer sk-cap-a' ? 8192 : 2048;
|
|
117
|
+
return setTimeout(() => {
|
|
118
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ data: [{ id: 'up-capped', context_length: 200000, max_output_tokens: output }] }));
|
|
120
|
+
}, 100);
|
|
121
|
+
}
|
|
82
122
|
if (req.method === 'GET' && req.url.startsWith('/chat/v1/models')) {
|
|
83
123
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
124
|
return res.end(JSON.stringify({ data: [
|
|
@@ -123,10 +163,28 @@ function startUpstream() {
|
|
|
123
163
|
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 10, completion_tokens: 15 } }
|
|
124
164
|
], { raw: ['data: [DONE]\n\n'] });
|
|
125
165
|
}
|
|
166
|
+
if (lastUser.includes('PLACEHOLDER_ECHO')) {
|
|
167
|
+
const args = '{"cmd":"ls","env":{"A":"1","_placeholder":true},"opts":{"_placeholder":"keep"},"_placeholder":true}';
|
|
168
|
+
if (!json.stream) {
|
|
169
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
170
|
+
return res.end(JSON.stringify({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [{ id: 'call_ph', type: 'function', function: { name: 'run', arguments: args } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }));
|
|
171
|
+
}
|
|
172
|
+
return sse(res, [
|
|
173
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_ph', type: 'function', function: { name: 'run', arguments: args.slice(0, 30) } }] } }] },
|
|
174
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: args.slice(30) } }] } }] },
|
|
175
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }
|
|
176
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
177
|
+
}
|
|
126
178
|
if (lastUser.includes('RATE_LIMIT')) {
|
|
127
179
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
|
|
128
180
|
return res.end(JSON.stringify({ error: { message: 'slow down' } }));
|
|
129
181
|
}
|
|
182
|
+
const mockErr = lastUser.match(/MOCK_ERR:(\w+)/)?.[1];
|
|
183
|
+
if (mockErr && MOCK_ERRORS[mockErr]) {
|
|
184
|
+
const [status, message] = MOCK_ERRORS[mockErr];
|
|
185
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
186
|
+
return res.end(JSON.stringify({ error: { message, type: 'invalid_request_error' } }));
|
|
187
|
+
}
|
|
130
188
|
if (lastUser.includes('CONTEXT_OVERFLOW')) {
|
|
131
189
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
132
190
|
return res.end(JSON.stringify({ error: { message: "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens (132000 in the messages, 8000 in the completion). Please reduce the length of the messages or completion.", type: 'invalid_request_error', code: 'invalid_request_error' } }));
|
|
@@ -262,6 +320,13 @@ before(async () => {
|
|
|
262
320
|
intactcc: { name: 'Mock intact', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', thinkingMode: 'off', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact-user', defaultModels: { opus: 'claude/claude-opus-5', sonnet: 'gem/flash', haiku: 'claude/claude-haiku-4-5', fable: 'gem/flash' } },
|
|
263
321
|
textonly: { name: 'Mock Text Only', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-textonly', defaultModels: { opus: 'up-textonly', sonnet: 'up-badreq', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
264
322
|
capped: { name: 'Mock Capped Output', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
323
|
+
agyph: { name: 'Mock agy to AG via chat', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-agy', defaultModels: { main: 'ag/mock-flash' } },
|
|
324
|
+
cappedtwin: { name: 'Mock Capped Twin', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
325
|
+
capa: { name: 'Mock Capped A', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-cap-a', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
326
|
+
capb: { name: 'Mock Capped B', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-cap-b', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
327
|
+
cappedold: { name: 'Mock Capped Old', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
328
|
+
ovr: { name: 'Mock Override', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/elsewhere/v1`, endpoints: { 'openai-chat': `${base}/chat/v1/chat/completions` }, apiKey: 'sk-secret-ovr', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
329
|
+
ovrplain: { name: 'Mock No Override', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-ovrplain', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
265
330
|
ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
|
|
266
331
|
}
|
|
267
332
|
};
|
|
@@ -628,6 +693,123 @@ test('max_tokens above the listed output limit is clamped; a model with no liste
|
|
|
628
693
|
assert.equal(received.at(-1).body.max_tokens, 64000);
|
|
629
694
|
});
|
|
630
695
|
|
|
696
|
+
// The clamp trusts only the list this profile read within the hour, whatever other profile shares its baseURL.
|
|
697
|
+
test('max_tokens is clamped only from a fresh cache entry of the same profile', async () => {
|
|
698
|
+
const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'capped' } });
|
|
699
|
+
await listed.text();
|
|
700
|
+
const ask = async (profile) => {
|
|
701
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': profile });
|
|
702
|
+
assert.equal(res.status, 200, profile);
|
|
703
|
+
await res.text();
|
|
704
|
+
assert.equal(received.at(-1).body.model, 'up-capped', profile);
|
|
705
|
+
return received.at(-1).body.max_tokens;
|
|
706
|
+
};
|
|
707
|
+
assert.equal(await ask('cappedtwin'), 64000, 'another profile on the same baseURL has not read its list');
|
|
708
|
+
const cacheFile = path.join(tmpDir, 'model-windows.json');
|
|
709
|
+
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
710
|
+
const base = `http://127.0.0.1:${upstreamPort}/chat/v1`;
|
|
711
|
+
cache[`cappedold@${base}`] = { at: Date.now() - 2 * 3_600_000, baseURL: base, models: { 'up-capped': { context: 200000, compact: 0, output: 4096 } } };
|
|
712
|
+
fs.writeFileSync(cacheFile, JSON.stringify(cache));
|
|
713
|
+
assert.equal(await ask('cappedold'), 64000, 'an entry two hours old is not trusted');
|
|
714
|
+
assert.equal(await ask('capped'), 8192);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// No /v1/models call for these profiles: the first request reads the list in the background, the next is clamped.
|
|
718
|
+
test('a profile with no fresh model list is clamped from the request after a background read', async () => {
|
|
719
|
+
const ask = async (profile, tag) => {
|
|
720
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: tag }] }, { 'x-llm-profile': profile });
|
|
721
|
+
assert.equal(res.status, 200, tag);
|
|
722
|
+
await res.text();
|
|
723
|
+
const sent = received.findLast(r => JSON.stringify(r.body.messages || '').includes(tag));
|
|
724
|
+
assert.equal(sent.body.model, 'up-capped', tag);
|
|
725
|
+
return sent.body.max_tokens;
|
|
726
|
+
};
|
|
727
|
+
const reads = (key) => modelReads.filter(r => r.headers.authorization === `Bearer ${key}`).length;
|
|
728
|
+
const cached = (profile) => {
|
|
729
|
+
try { return JSON.parse(fs.readFileSync(path.join(tmpDir, 'model-windows.json'), 'utf8'))[`${profile}@http://127.0.0.1:${upstreamPort}/chat/v1`]; } catch { return null; }
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
assert.deepEqual(await Promise.all([ask('capa', 'cap-a-1'), ask('capa', 'cap-a-2')]), [64000, 64000], 'the first requests do not wait for the list');
|
|
733
|
+
assert.ok(await until(() => cached('capa')), 'the list of capa was read in the background');
|
|
734
|
+
assert.equal(await ask('capa', 'cap-a-3'), 8192);
|
|
735
|
+
assert.equal(reads('sk-cap-a'), 1, 'one read for the profile, however many requests found no entry');
|
|
736
|
+
|
|
737
|
+
assert.equal(await ask('capb', 'cap-b-1'), 64000, 'capa\'s entry on the same baseURL is not capb\'s');
|
|
738
|
+
assert.ok(await until(() => cached('capb')), 'the list of capb was read in the background');
|
|
739
|
+
assert.equal(await ask('capb', 'cap-b-2'), 2048);
|
|
740
|
+
assert.equal(await ask('capa', 'cap-a-4'), 8192, 'each profile keeps its own limit');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// The requests of an override profile never reach its baseURL, so neither may its key.
|
|
744
|
+
test('a profile that routes through an endpoint override reads no model list; a plain profile still does', async () => {
|
|
745
|
+
const ask = async (profile, tag) => {
|
|
746
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: tag }] }, { 'x-llm-profile': profile });
|
|
747
|
+
assert.equal(res.status, 200, tag);
|
|
748
|
+
await res.text();
|
|
749
|
+
return received.findLast(r => JSON.stringify(r.body.messages || '').includes(tag));
|
|
750
|
+
};
|
|
751
|
+
const reads = (key) => modelReads.filter(r => r.headers.authorization === `Bearer ${key}` || r.headers['x-api-key'] === key);
|
|
752
|
+
|
|
753
|
+
const sent = await ask('ovr', 'ovr-1');
|
|
754
|
+
assert.equal(sent.url, '/chat/v1/chat/completions', 'the request went to the override');
|
|
755
|
+
assert.equal(sent.body.max_tokens, 64000);
|
|
756
|
+
await ask('ovrplain', 'ovrplain-1');
|
|
757
|
+
assert.ok(await until(() => reads('sk-secret-ovrplain').length === 1), 'the plain profile reads its list in the background');
|
|
758
|
+
await ask('ovr', 'ovr-2');
|
|
759
|
+
await new Promise(r => setTimeout(r, 200));
|
|
760
|
+
assert.deepEqual(reads('sk-secret-ovr'), [], 'the override profile sent its key to no /models');
|
|
761
|
+
assert.equal((await ask('ovr', 'ovr-3')).body.max_tokens, 64000, 'nothing clamps an override profile');
|
|
762
|
+
const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'ovr' } });
|
|
763
|
+
assert.equal(listed.status, 200);
|
|
764
|
+
await listed.text();
|
|
765
|
+
assert.deepEqual(reads('sk-secret-ovr'), [], 'the /v1/models catalog sent the override key to no /models');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// Gemini gets `_placeholder` on an empty nested object; the client never declared it, so it must not come back.
|
|
769
|
+
test('A _placeholder the gateway added for Gemini is stripped from tool-call arguments, stream and non-stream', async () => {
|
|
770
|
+
const tools = [{ type: 'function', name: 'run', parameters: { type: 'object', properties: {
|
|
771
|
+
cmd: { type: 'string' },
|
|
772
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
773
|
+
opts: { type: 'object', properties: { _placeholder: { type: 'string' } } }
|
|
774
|
+
} } }];
|
|
775
|
+
const want = { cmd: 'ls', env: { A: '1' }, opts: { _placeholder: 'keep' }, _placeholder: true };
|
|
776
|
+
|
|
777
|
+
const plain = await post('/v1/responses', { model: 'main', stream: false, tools, input: 'PLACEHOLDER_ECHO' }, { 'x-llm-profile': 'agmock' });
|
|
778
|
+
assert.equal(plain.status, 200);
|
|
779
|
+
const sent = received.at(-1).body.tools.find(t => t.function.name === 'run').function.parameters;
|
|
780
|
+
assert.deepEqual(Object.keys(sent.properties.env.properties), ['_placeholder'], 'the schema sent upstream was padded');
|
|
781
|
+
const call = (await plain.json()).output.find(o => o.type === 'function_call');
|
|
782
|
+
assert.deepEqual(JSON.parse(call.arguments), want);
|
|
783
|
+
|
|
784
|
+
const streamed = await post('/v1/responses', { model: 'main', stream: true, tools, input: 'PLACEHOLDER_ECHO' }, { 'x-llm-profile': 'agmock' });
|
|
785
|
+
const done = parseSSE(await streamed.text()).find(e => e.data?.type === 'response.completed');
|
|
786
|
+
assert.ok(done, 'the stream completes');
|
|
787
|
+
assert.deepEqual(JSON.parse(done.data.response.output.find(o => o.type === 'function_call').arguments), want);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('A Code Assist (agy) client gets tool-call args without the padded _placeholder, stream and non-stream', async () => {
|
|
791
|
+
const envelope = { project: 'p', requestId: 'agent/s/1/t/1', model: 'gemini-3.8-flash-high', userAgent: 'antigravity', requestType: 'agent', request: {
|
|
792
|
+
contents: [{ role: 'user', parts: [{ text: 'PLACEHOLDER_ECHO' }] }],
|
|
793
|
+
tools: [{ functionDeclarations: [{ name: 'run', description: 'Run.', parameters: { type: 'OBJECT', properties: {
|
|
794
|
+
cmd: { type: 'STRING' }, env: { type: 'OBJECT' }, opts: { type: 'OBJECT', properties: { _placeholder: { type: 'STRING' } } }
|
|
795
|
+
} } }] }]
|
|
796
|
+
} };
|
|
797
|
+
const want = { cmd: 'ls', env: { A: '1' }, opts: { _placeholder: 'keep' }, _placeholder: true };
|
|
798
|
+
const headers = { 'content-type': 'application/json', 'x-llm-profile': 'agyph' };
|
|
799
|
+
const callOf = (chunks) => chunks.flatMap(c => c.response?.candidates?.[0]?.content?.parts || []).find(p => p.functionCall)?.functionCall;
|
|
800
|
+
|
|
801
|
+
const whole = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers, body: JSON.stringify(envelope) });
|
|
802
|
+
assert.equal(whole.status, 200);
|
|
803
|
+
const sent = received.at(-1).body.tools.find(t => t.function.name === 'run').function.parameters;
|
|
804
|
+
assert.deepEqual(Object.keys(sent.properties.env.properties), ['_placeholder'], 'the schema sent upstream was padded');
|
|
805
|
+
assert.deepEqual(callOf([await whole.json()]).args, want);
|
|
806
|
+
|
|
807
|
+
const streamed = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers, body: JSON.stringify(envelope) });
|
|
808
|
+
assert.equal(streamed.status, 200);
|
|
809
|
+
const chunks = parseSSE(await streamed.text()).map(e => e.data).filter(Boolean);
|
|
810
|
+
assert.deepEqual(callOf(chunks).args, want);
|
|
811
|
+
});
|
|
812
|
+
|
|
631
813
|
// A text-only model refuses an image that stays in the history, so every later turn would fail too.
|
|
632
814
|
test('An image refused by a text-only chat model is retried once as text in each request', async () => {
|
|
633
815
|
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
@@ -685,6 +867,54 @@ test('sendClientError maps context overflow per client', async () => {
|
|
|
685
867
|
assert.match(codexJson.error.message, /maximum context length is 131072/);
|
|
686
868
|
});
|
|
687
869
|
|
|
870
|
+
test('Context overflow numbers with thousands separators reach Claude Code whole', async () => {
|
|
871
|
+
const ask = async (key) => {
|
|
872
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: `MOCK_ERR:${key}` }] });
|
|
873
|
+
return { status: res.status, error: (await res.json()).error };
|
|
874
|
+
};
|
|
875
|
+
for (const key of ['comma', 'dot', 'space', 'underscore']) {
|
|
876
|
+
const { status, error } = await ask(key);
|
|
877
|
+
assert.equal(status, 400, key);
|
|
878
|
+
assert.equal(error.type, 'invalid_request_error', key);
|
|
879
|
+
assert.equal(error.message, `prompt is too long: 140000 tokens > 131072 maximum (${MOCK_ERRORS[key][1]})`, key);
|
|
880
|
+
}
|
|
881
|
+
const whole = async (key, asked, max) => assert.equal((await ask(key)).error.message, `prompt is too long: ${asked} tokens > ${max} maximum (${MOCK_ERRORS[key][1]})`, key);
|
|
882
|
+
await whole('ksuffix', 140000, 128000);
|
|
883
|
+
await whole('msuffix', 1200000, 1000000);
|
|
884
|
+
for (const key of ['narrownbsp', 'nbsp', 'apostrophe', 'indian', 'tokenlimit', 'limitof', 'limitbare']) await whole(key, 140000, 131072);
|
|
885
|
+
for (const key of ['decimal', 'fraction', 'limitnotokens']) {
|
|
886
|
+
assert.equal((await ask(key)).error.message, `prompt is too long: ${MOCK_ERRORS[key][1]}`, `${key}: a number after 'limit' that is no token count is ignored`);
|
|
887
|
+
}
|
|
888
|
+
for (const key of ['hugek', 'hugedigits']) {
|
|
889
|
+
assert.equal((await ask(key)).error.message, `prompt is too long: ${MOCK_ERRORS[key][1]}`, `${key}: a count past the safe integer range is no token count`);
|
|
890
|
+
}
|
|
891
|
+
for (const key of ['zero', 'zerok', 'zeroasked']) {
|
|
892
|
+
assert.equal((await ask(key)).error.message, `prompt is too long: ${MOCK_ERRORS[key][1]}`, `${key}: a count of zero is no token count`);
|
|
893
|
+
}
|
|
894
|
+
assert.deepEqual(await ask('prefixed'), { status: 400, error: { type: 'invalid_request_error', message: MOCK_ERRORS.prefixed[1] } });
|
|
895
|
+
assert.deepEqual(await ask('nonumber'), { status: 400, error: { type: 'invalid_request_error', message: `prompt is too long: ${MOCK_ERRORS.nonumber[1]}` } });
|
|
896
|
+
assert.deepEqual(await ask('rate'), { status: 429, error: { type: 'rate_limit_error', message: MOCK_ERRORS.rate[1] } });
|
|
897
|
+
assert.deepEqual(await ask('server'), { status: 500, error: { type: 'api_error', message: MOCK_ERRORS.server[1] } });
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('Codex WS: an upstream context overflow fails the turn with context_length_exceeded', async () => {
|
|
901
|
+
const ws = await rawWs();
|
|
902
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:comma over ws' })));
|
|
903
|
+
assert.ok(await until(() => ws.messages.some(m => m.type === 'response.failed')), 'no response.failed');
|
|
904
|
+
const failed = ws.messages.find(m => m.type === 'response.failed').response.error;
|
|
905
|
+
assert.equal(failed.code, 'context_length_exceeded');
|
|
906
|
+
assert.equal(failed.message, MOCK_ERRORS.comma[1]);
|
|
907
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:rate over ws' })));
|
|
908
|
+
assert.ok(await until(() => ws.messages.filter(m => m.type === 'response.failed').length === 2), 'no second response.failed');
|
|
909
|
+
assert.equal(ws.messages.filter(m => m.type === 'response.failed')[1].response.error.code, 'rate_limit_exceeded');
|
|
910
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:server over ws' })));
|
|
911
|
+
assert.ok(await until(() => ws.messages.filter(m => m.type === 'response.failed').length === 3), 'no third response.failed');
|
|
912
|
+
const server = ws.messages.filter(m => m.type === 'response.failed')[2].response.error;
|
|
913
|
+
assert.equal(server.code, 'server_error', 'a 500 that names the context length is a server error, not an overflow');
|
|
914
|
+
assert.equal(server.message, MOCK_ERRORS.server[1]);
|
|
915
|
+
ws.socket.destroy();
|
|
916
|
+
});
|
|
917
|
+
|
|
688
918
|
test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
|
|
689
919
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
|
|
690
920
|
const events = parseSSE(await res.text());
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -6,10 +6,15 @@ import path from 'node:path';
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { pidsNaming, reapDir } from './helpers.mjs';
|
|
9
10
|
|
|
10
11
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
11
12
|
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
12
13
|
|
|
14
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
15
|
+
const made = [];
|
|
16
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
17
|
+
|
|
13
18
|
async function until(check, timeoutMs = 6000) {
|
|
14
19
|
const deadline = Date.now() + timeoutMs;
|
|
15
20
|
while (Date.now() < deadline) {
|
|
@@ -21,12 +26,15 @@ async function until(check, timeoutMs = 6000) {
|
|
|
21
26
|
|
|
22
27
|
async function fixture(t) {
|
|
23
28
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-goalcheck-'));
|
|
29
|
+
made.push(dir);
|
|
24
30
|
const adminToken = 'admin-secret-token-123';
|
|
25
31
|
fs.writeFileSync(path.join(dir, 'admin.token'), adminToken, { mode: 0o600 });
|
|
26
32
|
|
|
27
33
|
let nativeVerdict = { ok: true, reason: 'looks complete' };
|
|
28
34
|
let jevScores = { complete: 0.14, evidence: 0.84, unfinished: 0.63 };
|
|
29
35
|
let jevStatus = 200;
|
|
36
|
+
let nativeDrops = false;
|
|
37
|
+
const chatRequests = [];
|
|
30
38
|
|
|
31
39
|
// Mock native upstream (Anthropic format)
|
|
32
40
|
const nativeUp = http.createServer((req, res) => {
|
|
@@ -37,9 +45,20 @@ async function fixture(t) {
|
|
|
37
45
|
res.setHeader('content-type', 'application/json');
|
|
38
46
|
return res.end('{"data":[]}');
|
|
39
47
|
}
|
|
48
|
+
if (nativeDrops) return req.socket.destroy();
|
|
40
49
|
const body = JSON.parse(raw);
|
|
41
50
|
const isStream = Boolean(body.stream);
|
|
42
51
|
const respText = JSON.stringify(nativeVerdict);
|
|
52
|
+
// A text-only chat model: it refuses any image part.
|
|
53
|
+
if (req.url.endsWith('/chat/completions')) {
|
|
54
|
+
chatRequests.push(body);
|
|
55
|
+
res.setHeader('content-type', 'application/json');
|
|
56
|
+
if (body.messages.some(m => Array.isArray(m.content) && m.content.some(c => c?.type === 'image_url'))) {
|
|
57
|
+
res.statusCode = 400;
|
|
58
|
+
return res.end(JSON.stringify({ error: { message: 'Failed to deserialize the JSON body: messages[1]: unknown variant `image_url`, expected `text`' } }));
|
|
59
|
+
}
|
|
60
|
+
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: respText }, finish_reason: 'stop' }] }));
|
|
61
|
+
}
|
|
43
62
|
|
|
44
63
|
if (isStream) {
|
|
45
64
|
res.setHeader('content-type', 'text/event-stream');
|
|
@@ -91,14 +110,21 @@ async function fixture(t) {
|
|
|
91
110
|
});
|
|
92
111
|
await listen(jevUp);
|
|
93
112
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
const freePort = async () => {
|
|
114
|
+
const reserve = http.createServer();
|
|
115
|
+
await listen(reserve);
|
|
116
|
+
const p = reserve.address().port;
|
|
117
|
+
await new Promise(r => reserve.close(r));
|
|
118
|
+
return p;
|
|
119
|
+
};
|
|
120
|
+
const port = await freePort();
|
|
121
|
+
// Without its own port the interceptor the proxy starts takes the default 3457.
|
|
122
|
+
const blindfoldPort = await freePort();
|
|
98
123
|
|
|
99
124
|
const configPath = path.join(dir, 'config.json');
|
|
100
125
|
fs.writeFileSync(configPath, JSON.stringify({
|
|
101
126
|
port,
|
|
127
|
+
blindfold: { port: blindfoldPort },
|
|
102
128
|
activeProfile: 'p1',
|
|
103
129
|
activeProfiles: { claude: 'p1' },
|
|
104
130
|
profiles: {
|
|
@@ -110,6 +136,16 @@ async function fixture(t) {
|
|
|
110
136
|
outFormat: 'anthropic',
|
|
111
137
|
baseURL: `http://127.0.0.1:${nativeUp.address().port}`,
|
|
112
138
|
apiKey: 'sk-native-key'
|
|
139
|
+
},
|
|
140
|
+
chat: {
|
|
141
|
+
name: 'ChatMock',
|
|
142
|
+
mode: 'convert',
|
|
143
|
+
tool: 'claude',
|
|
144
|
+
inFormat: 'auto',
|
|
145
|
+
outFormat: 'openai-chat',
|
|
146
|
+
baseURL: `http://127.0.0.1:${nativeUp.address().port}/v1`,
|
|
147
|
+
apiKey: 'sk-chat-key',
|
|
148
|
+
defaultModels: { opus: 'up-textonly', sonnet: 'up-textonly', haiku: 'up-textonly', fable: 'up-textonly' }
|
|
113
149
|
}
|
|
114
150
|
},
|
|
115
151
|
goalCheck: {
|
|
@@ -150,7 +186,7 @@ async function fixture(t) {
|
|
|
150
186
|
new Promise(r => nativeUp.close(r)),
|
|
151
187
|
new Promise(r => jevUp.close(r))
|
|
152
188
|
]);
|
|
153
|
-
|
|
189
|
+
await reapDir(dir);
|
|
154
190
|
});
|
|
155
191
|
|
|
156
192
|
await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok);
|
|
@@ -166,11 +202,12 @@ async function fixture(t) {
|
|
|
166
202
|
body: body ? JSON.stringify(body) : undefined
|
|
167
203
|
});
|
|
168
204
|
|
|
169
|
-
const sendClaude = (body, stream = false) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
205
|
+
const sendClaude = (body, stream = false, headers = {}) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
170
206
|
method: 'POST',
|
|
171
207
|
headers: {
|
|
172
208
|
'content-type': 'application/json',
|
|
173
|
-
'user-agent': 'claude-cli/2.1.295'
|
|
209
|
+
'user-agent': 'claude-cli/2.1.295',
|
|
210
|
+
...headers
|
|
174
211
|
},
|
|
175
212
|
body: JSON.stringify({ ...body, stream })
|
|
176
213
|
});
|
|
@@ -182,7 +219,9 @@ async function fixture(t) {
|
|
|
182
219
|
setNativeVerdict: v => { nativeVerdict = v; },
|
|
183
220
|
setJevScores: s => { jevScores = s; },
|
|
184
221
|
setJevStatus: st => { jevStatus = st; },
|
|
222
|
+
setNativeDrops: v => { nativeDrops = v; },
|
|
185
223
|
jevRequests,
|
|
224
|
+
chatRequests,
|
|
186
225
|
logs: () => logs
|
|
187
226
|
};
|
|
188
227
|
}
|
|
@@ -314,3 +353,46 @@ test('goalcheck e2e: live interception, veto, and allow behaviors', async t => {
|
|
|
314
353
|
assert.ok(logsData.logs.length >= 4);
|
|
315
354
|
assert.equal(logsData.logs[0].verdictType, 'Unavailable');
|
|
316
355
|
});
|
|
356
|
+
|
|
357
|
+
test('goalcheck e2e: an openai-chat evaluator call that a text-only model refuses for an image is retried as text', async t => {
|
|
358
|
+
const f = await fixture(t);
|
|
359
|
+
await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
|
|
360
|
+
f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
|
|
361
|
+
f.setNativeVerdict({ ok: true, reason: 'screenshot shows green tests' });
|
|
362
|
+
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
363
|
+
const request = {
|
|
364
|
+
...evaluatorRequest,
|
|
365
|
+
messages: [
|
|
366
|
+
{ role: 'user', content: [image, { type: 'text', text: 'Please fix the failing tests in repo.' }] },
|
|
367
|
+
...evaluatorRequest.messages.slice(1)
|
|
368
|
+
]
|
|
369
|
+
};
|
|
370
|
+
const res = await f.sendClaude(request, false, { 'x-llm-profile': 'chat' });
|
|
371
|
+
assert.equal(res.status, 200);
|
|
372
|
+
const verdict = JSON.parse((await res.json()).content[0].text);
|
|
373
|
+
assert.deepEqual(verdict, { ok: true, reason: 'screenshot shows green tests' });
|
|
374
|
+
assert.equal(f.chatRequests.length, 2, 'one refusal, one retry');
|
|
375
|
+
assert.ok(JSON.stringify(f.chatRequests[1]).includes('[image omitted'), 'the retry carries text in place of the image');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// The gate fails closed: an evaluator call that throws never lets the goal through, whatever Jev says.
|
|
379
|
+
test('goalcheck e2e: a native evaluator call that throws fails closed on both upstream routes', async t => {
|
|
380
|
+
const f = await fixture(t);
|
|
381
|
+
await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
|
|
382
|
+
f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
|
|
383
|
+
f.setNativeVerdict({ ok: true, reason: 'claimed done' });
|
|
384
|
+
f.setNativeDrops(true);
|
|
385
|
+
for (const profile of ['p1', 'chat']) {
|
|
386
|
+
const res = await f.sendClaude(evaluatorRequest, false, { 'x-llm-profile': profile });
|
|
387
|
+
assert.equal(res.status, 200, profile);
|
|
388
|
+
const verdict = JSON.parse((await res.json()).content[0].text);
|
|
389
|
+
assert.equal(verdict.ok, false, profile);
|
|
390
|
+
assert.match(verdict.reason, /Native evaluator unavailable/, profile);
|
|
391
|
+
}
|
|
392
|
+
const logs = (await (await f.sendAdmin('/api/goal-check/logs')).json()).logs;
|
|
393
|
+
assert.deepEqual(logs.slice(0, 2).map(l => l.verdictType), ['Unavailable', 'Unavailable']);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('goalcheck e2e: the fixtures leave no process behind', () => {
|
|
397
|
+
assert.deepEqual(made.flatMap(pidsNaming), []);
|
|
398
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Actual proxy -> HTTP mock upstream; no external provider or official account is used.
|
|
2
|
-
import { test } from 'node:test';
|
|
2
|
+
import { test, after } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
@@ -28,8 +28,13 @@ function history() {
|
|
|
28
28
|
return messages;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
32
|
+
const made = [];
|
|
33
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
34
|
+
|
|
31
35
|
async function fixture(t, { model = '', codex = false } = {}) {
|
|
32
36
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-idle-e2e-'));
|
|
37
|
+
made.push(dir);
|
|
33
38
|
const received = []; const pendingSummaries = [];
|
|
34
39
|
const upstream = http.createServer((req, res) => {
|
|
35
40
|
let raw = '';
|
package/tests/lifecycle.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// gateway port, `switch on` saved config.json before it refused, and only the CLI ever started the
|
|
5
5
|
// interceptor, so the dashboard, `switch port` and a service start left HTTPS_PROXY pointing at nothing.
|
|
6
6
|
|
|
7
|
-
import test from 'node:test';
|
|
7
|
+
import test, { after } from 'node:test';
|
|
8
8
|
import assert from 'node:assert/strict';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
@@ -42,8 +42,12 @@ const freePort = () => new Promise(r => {
|
|
|
42
42
|
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
46
|
+
const made = [];
|
|
47
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
45
48
|
function makeWorkspace({ certs = false } = {}) {
|
|
46
49
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-life-'));
|
|
50
|
+
made.push(dir);
|
|
47
51
|
const certDir = path.join(dir, 'certs');
|
|
48
52
|
if (certs) execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), 'chatgpt.com', certDir], { stdio: 'ignore' });
|
|
49
53
|
return { dir, certDir, cfgPath: path.join(dir, 'config.json'), claudeDir: path.join(dir, 'claude') };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { compileCacheIsNew, removeQuietly, isMainModule } from '../scripts/run-tests.mjs';
|
|
8
|
+
|
|
9
|
+
const tmpDirs = [];
|
|
10
|
+
test.after(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
|
|
11
|
+
const tmpDir = () => {
|
|
12
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-runtests-'));
|
|
13
|
+
tmpDirs.push(d);
|
|
14
|
+
return d;
|
|
15
|
+
};
|
|
16
|
+
const npm = { npm_lifecycle_event: 'test' };
|
|
17
|
+
|
|
18
|
+
// npm writes its compile cache only when it exits, so the cache it made for this run has no file yet.
|
|
19
|
+
test('npm test removes only a compile cache that npm made for this run', () => {
|
|
20
|
+
const made = path.join(tmpDir(), 'node-compile-cache');
|
|
21
|
+
fs.mkdirSync(path.join(made, 'v26-x64-1000'), { recursive: true });
|
|
22
|
+
assert.equal(compileCacheIsNew(made, npm), true, 'empty: made at the start of this run');
|
|
23
|
+
|
|
24
|
+
const kept = path.join(tmpDir(), 'node-compile-cache');
|
|
25
|
+
fs.mkdirSync(path.join(kept, 'v26-x64-1000'), { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(kept, 'v26-x64-1000', '0c92995d'), 'x');
|
|
27
|
+
assert.equal(compileCacheIsNew(kept, npm), false, 'a file in it: it was there before this run, however new');
|
|
28
|
+
|
|
29
|
+
assert.equal(compileCacheIsNew(made, {}), false, 'run without npm: the cache is not ours');
|
|
30
|
+
assert.equal(compileCacheIsNew(path.join(tmpDir(), 'none'), npm), false, 'no cache, nothing to remove');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a failed cleanup never throws, so the exit status of the tests stays', () => {
|
|
34
|
+
const dir = tmpDir();
|
|
35
|
+
assert.doesNotThrow(() => removeQuietly(dir, () => { throw Object.assign(new Error('busy'), { code: 'EBUSY' }); }));
|
|
36
|
+
removeQuietly(dir);
|
|
37
|
+
assert.equal(fs.existsSync(dir), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// node resolves a symlinked script to its real path, so the guard must compare real paths or the tests never run.
|
|
41
|
+
test('the main-module guard sees the script through a symlink, and a false guard only returns false', () => {
|
|
42
|
+
const script = fileURLToPath(new URL('../scripts/run-tests.mjs', import.meta.url));
|
|
43
|
+
const link = path.join(tmpDir(), 'run-tests-link.mjs');
|
|
44
|
+
fs.symlinkSync(script, link);
|
|
45
|
+
assert.equal(isMainModule(script), true);
|
|
46
|
+
assert.equal(isMainModule(link), true, 'an absolute symlinked path is the script');
|
|
47
|
+
assert.equal(isMainModule(fileURLToPath(import.meta.url)), false, 'another file is not');
|
|
48
|
+
assert.equal(isMainModule(path.join(tmpDir(), 'missing.mjs')), false, 'a path that does not exist is not');
|
|
49
|
+
assert.equal(isMainModule(undefined), false);
|
|
50
|
+
});
|