llm-switcher 1.1.0
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/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// CLI tests: switch.mjs runs as a child with its own config, launch files and Claude dir
|
|
2
|
+
// (audit F06, BR-02, F27, F30, F46).
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const POSIX = process.platform !== 'win32';
|
|
14
|
+
|
|
15
|
+
const freePort = () => new Promise(r => {
|
|
16
|
+
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function workspace(t, port, profiles) {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cli-'));
|
|
21
|
+
const cfgPath = path.join(dir, 'config.json');
|
|
22
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
23
|
+
port, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
|
|
24
|
+
profiles: profiles || { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
|
|
25
|
+
}, null, 2), { mode: 0o600 });
|
|
26
|
+
// HOME too: `switch on` installs shims under the home directory, and a test must never rewrite the real ones.
|
|
27
|
+
const home = path.join(dir, 'home');
|
|
28
|
+
const ws = { dir, cfgPath, home, env: { ...process.env, HOME: home, USERPROFILE: home, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: path.join(dir, 'claude'), LLM_SWITCHER_PORT: '', PORT: '' } };
|
|
29
|
+
t.after(async () => {
|
|
30
|
+
await run(ws, ['off']).catch(() => {});
|
|
31
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
return ws;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// onLine lets a test act at a precise moment of the run, for example while the CLI waits.
|
|
37
|
+
function run(ws, args, { onLine, env = {} } = {}) {
|
|
38
|
+
return new Promise(resolve => {
|
|
39
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'switch.mjs'), ...args], { env: { ...ws.env, ...env } });
|
|
40
|
+
let stdout = '';
|
|
41
|
+
let stderr = '';
|
|
42
|
+
child.stdout.on('data', d => {
|
|
43
|
+
stdout += d;
|
|
44
|
+
if (onLine) for (const line of String(d).split('\n')) onLine(line);
|
|
45
|
+
});
|
|
46
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
47
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 30000);
|
|
48
|
+
child.on('close', code => { clearTimeout(timer); resolve({ status: code, stdout, stderr }); });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const readCfg = (ws) => JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
53
|
+
|
|
54
|
+
test('profile names are printed without terminal control sequences', { skip: !POSIX && 'posix' }, async (t) => {
|
|
55
|
+
const ws = workspace(t, await freePort(), {
|
|
56
|
+
evil: { name: 'Evil\u001b[2J\u001b]0;pwned\u0007', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1\u001b[31m', apiKey: 'k', defaultModels: { opus: 'o' } }
|
|
57
|
+
});
|
|
58
|
+
const r = await run(ws, ['status']);
|
|
59
|
+
assert.equal(r.status, 0, r.stderr);
|
|
60
|
+
assert.ok(!/[\u0000-\u0008\u000b-\u001f\u007f]/.test(r.stdout), JSON.stringify(r.stdout));
|
|
61
|
+
assert.match(r.stdout, /Evil/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('-p is the global port option at any position, never the port command', { skip: !POSIX && 'posix' }, async (t) => {
|
|
65
|
+
const port = await freePort();
|
|
66
|
+
const other = await freePort();
|
|
67
|
+
const ws = workspace(t, port);
|
|
68
|
+
const r = await run(ws, ['-p', String(other), 'status']);
|
|
69
|
+
assert.equal(r.status, 0, r.stderr);
|
|
70
|
+
assert.match(r.stdout, /=== LLM Switcher Status ===/);
|
|
71
|
+
assert.match(r.stdout, new RegExp(`/ui`));
|
|
72
|
+
assert.ok(r.stdout.includes(`:${other}/ui`), r.stdout);
|
|
73
|
+
assert.equal(readCfg(ws).port, port, 'config.json keeps its port');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('switch on keeps a change that another writer saved while it waited for the gateway', { skip: !POSIX && 'posix' }, async (t) => {
|
|
77
|
+
const ws = workspace(t, await freePort());
|
|
78
|
+
let edited = false;
|
|
79
|
+
const r = await run(ws, ['on', 'plain'], {
|
|
80
|
+
onLine: (line) => {
|
|
81
|
+
if (edited || !line.startsWith('Starting proxy service')) return;
|
|
82
|
+
edited = true;
|
|
83
|
+
const cfg = readCfg(ws);
|
|
84
|
+
cfg.profiles.plain.name = 'Renamed while waiting';
|
|
85
|
+
fs.writeFileSync(`${ws.cfgPath}.x`, JSON.stringify(cfg));
|
|
86
|
+
fs.renameSync(`${ws.cfgPath}.x`, ws.cfgPath);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
90
|
+
assert.ok(edited, 'the CLI started a gateway');
|
|
91
|
+
const cfg = readCfg(ws);
|
|
92
|
+
assert.equal(cfg.profiles.plain.name, 'Renamed while waiting');
|
|
93
|
+
assert.equal(cfg.activeProfiles.anthropic, 'plain');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('switch port refuses a port that another process holds and leaves the gateway where it was', { skip: !POSIX && 'posix' }, async (t) => {
|
|
97
|
+
const port = await freePort();
|
|
98
|
+
const ws = workspace(t, port);
|
|
99
|
+
assert.equal((await run(ws, ['on', 'plain'])).status, 0);
|
|
100
|
+
const squatter = net.createServer(s => s.end('HTTP/1.1 200 OK\r\n\r\nnot the switcher'));
|
|
101
|
+
const held = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
102
|
+
t.after(() => squatter.close());
|
|
103
|
+
const r = await run(ws, ['port', String(held)]);
|
|
104
|
+
assert.notEqual(r.status, 0);
|
|
105
|
+
assert.match(r.stderr, /held by another process/);
|
|
106
|
+
assert.equal(readCfg(ws).port, port);
|
|
107
|
+
const status = await run(ws, ['status']);
|
|
108
|
+
assert.match(status.stdout, new RegExp(`RUNNING \\(port ${port}\\)`));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('switch port puts config.json and the gateway back when the new gateway does not come up', { skip: !POSIX && 'posix' }, async (t) => {
|
|
112
|
+
const port = await freePort();
|
|
113
|
+
const next = await freePort();
|
|
114
|
+
const ws = workspace(t, port);
|
|
115
|
+
assert.equal((await run(ws, ['on', 'plain'])).status, 0);
|
|
116
|
+
// The new port is free at the check and taken right after the old gateway stops.
|
|
117
|
+
const squatter = net.createServer(s => s.destroy());
|
|
118
|
+
t.after(() => squatter.close());
|
|
119
|
+
const r = await run(ws, ['port', String(next)], {
|
|
120
|
+
onLine: (line) => { if (line.startsWith('Stopping gateway')) squatter.listen(next, '127.0.0.1'); }
|
|
121
|
+
});
|
|
122
|
+
assert.notEqual(r.status, 0, r.stdout);
|
|
123
|
+
assert.match(r.stderr, new RegExp(`back on port ${port}`));
|
|
124
|
+
assert.equal(readCfg(ws).port, port);
|
|
125
|
+
assert.match((await run(ws, ['status'])).stdout, new RegExp(`RUNNING \\(port ${port}\\)`));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// A shim bakes in the state dir. A temp state dir, deleted later, would make every shim source a path that
|
|
129
|
+
// another account can re-create (audit follow-up attacker-1).
|
|
130
|
+
test('switch on installs no shims when the launch files live outside the checkout', { skip: !POSIX && 'posix' }, async (t) => {
|
|
131
|
+
const ws = workspace(t, await freePort());
|
|
132
|
+
const r = await run(ws, ['on', 'plain']);
|
|
133
|
+
assert.equal(r.status, 0, r.stderr);
|
|
134
|
+
assert.match(r.stdout, /\[Shim\] Not installed automatically/);
|
|
135
|
+
assert.equal(fs.existsSync(path.join(ws.home, '.llm-switcher', 'bin', 'claude')), false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Only the named script starts late: the CLI itself runs at normal speed.
|
|
139
|
+
const slowStart = (script, ms) => ({ NODE_OPTIONS: `--import=data:text/javascript,${encodeURIComponent(`if ((process.argv[1] || '').endsWith('${script}')) { const end = Date.now() + ${ms}; while (Date.now() < end) {} }`)}` });
|
|
140
|
+
|
|
141
|
+
// A gateway that comes up after the 5 s wait must not stay on the new port after the rollback
|
|
142
|
+
// (follow-up RACER-5).
|
|
143
|
+
test('switch port stops the new gateway it started when it rolls back', { skip: !POSIX && 'posix' }, async (t) => {
|
|
144
|
+
const port = await freePort();
|
|
145
|
+
const next = await freePort();
|
|
146
|
+
const ws = workspace(t, port);
|
|
147
|
+
assert.equal((await run(ws, ['on', 'plain'])).status, 0);
|
|
148
|
+
const r = await run(ws, ['port', String(next)], { env: slowStart('proxy.mjs', 7000) });
|
|
149
|
+
assert.notEqual(r.status, 0, r.stdout);
|
|
150
|
+
await new Promise(res => setTimeout(res, 8000));
|
|
151
|
+
const probe = await new Promise(res => {
|
|
152
|
+
const s = net.connect(next, '127.0.0.1', () => { s.destroy(); res('listening'); });
|
|
153
|
+
s.on('error', () => res('free'));
|
|
154
|
+
});
|
|
155
|
+
assert.equal(probe, 'free', 'nothing listens on the abandoned port');
|
|
156
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Tests for blindfold/wsframe.mjs — the frame reader that a capture uses.
|
|
2
|
+
//
|
|
3
|
+
// Why it exists (2026-09-20): a Codex completion does not travel over HTTP. The
|
|
4
|
+
// blindfold log shows `upgrade passthrough /backend-api/codex/responses`, so the
|
|
5
|
+
// request that matters is a WebSocket and a capture of HTTP alone records the
|
|
6
|
+
// handshake and nothing else.
|
|
7
|
+
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import zlib from 'node:zlib';
|
|
11
|
+
import { createFrameReader, negotiatesDeflate, OPCODE } from '../blindfold/wsframe.mjs';
|
|
12
|
+
|
|
13
|
+
// Build a frame the way a client does: masked, because RFC 6455 requires every
|
|
14
|
+
// client frame to be masked and an unmasking bug is invisible without a test.
|
|
15
|
+
function frame(opcode, payload, { fin = true, mask = false, rsv1 = false } = {}) {
|
|
16
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
17
|
+
const head = [(fin ? 0x80 : 0) | (rsv1 ? 0x40 : 0) | opcode];
|
|
18
|
+
let lengthBytes = [];
|
|
19
|
+
if (body.length < 126) {
|
|
20
|
+
lengthBytes = [];
|
|
21
|
+
head.push((mask ? 0x80 : 0) | body.length);
|
|
22
|
+
} else if (body.length < 65536) {
|
|
23
|
+
head.push((mask ? 0x80 : 0) | 126);
|
|
24
|
+
const b = Buffer.alloc(2); b.writeUInt16BE(body.length); lengthBytes = [...b];
|
|
25
|
+
} else {
|
|
26
|
+
head.push((mask ? 0x80 : 0) | 127);
|
|
27
|
+
const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(body.length)); lengthBytes = [...b];
|
|
28
|
+
}
|
|
29
|
+
const key = mask ? Buffer.from([0x01, 0x02, 0x03, 0x04]) : Buffer.alloc(0);
|
|
30
|
+
const masked = Buffer.from(body);
|
|
31
|
+
if (mask) for (let i = 0; i < masked.length; i++) masked[i] ^= key[i & 3];
|
|
32
|
+
return Buffer.concat([Buffer.from(head), Buffer.from(lengthBytes), key, masked]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('a masked client frame is unmasked', () => {
|
|
36
|
+
const read = createFrameReader();
|
|
37
|
+
const out = read(frame(OPCODE.text, '{"type":"response.create"}', { mask: true }));
|
|
38
|
+
assert.equal(out.length, 1);
|
|
39
|
+
assert.equal(out[0].type, 'text');
|
|
40
|
+
assert.equal(out[0].payload.toString(), '{"type":"response.create"}');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an unmasked server frame is read as it is', () => {
|
|
44
|
+
const read = createFrameReader();
|
|
45
|
+
const out = read(frame(OPCODE.text, 'hello'));
|
|
46
|
+
assert.equal(out[0].payload.toString(), 'hello');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A TCP chunk boundary has nothing to do with a frame boundary. Both directions
|
|
50
|
+
// of this split must work, or a capture loses whole messages at random.
|
|
51
|
+
test('one frame split across chunks is reassembled', () => {
|
|
52
|
+
const read = createFrameReader();
|
|
53
|
+
const f = frame(OPCODE.text, 'split me please', { mask: true });
|
|
54
|
+
assert.deepEqual(read(f.subarray(0, 3)), []);
|
|
55
|
+
assert.deepEqual(read(f.subarray(3, 7)), []);
|
|
56
|
+
const out = read(f.subarray(7));
|
|
57
|
+
assert.equal(out[0].payload.toString(), 'split me please');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('several frames in one chunk are all returned', () => {
|
|
61
|
+
const read = createFrameReader();
|
|
62
|
+
const out = read(Buffer.concat([
|
|
63
|
+
frame(OPCODE.text, 'one', { mask: true }),
|
|
64
|
+
frame(OPCODE.text, 'two', { mask: true }),
|
|
65
|
+
frame(OPCODE.text, 'three', { mask: true })
|
|
66
|
+
]));
|
|
67
|
+
assert.deepEqual(out.map((m) => m.payload.toString()), ['one', 'two', 'three']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a fragmented message is joined, and only the final frame is emitted', () => {
|
|
71
|
+
const read = createFrameReader();
|
|
72
|
+
const first = read(frame(OPCODE.text, 'part one ', { fin: false, mask: true }));
|
|
73
|
+
assert.deepEqual(first, [], 'a non-final frame must not be emitted on its own');
|
|
74
|
+
const out = read(frame(OPCODE.continuation, 'part two', { mask: true }));
|
|
75
|
+
assert.equal(out.length, 1);
|
|
76
|
+
assert.equal(out[0].payload.toString(), 'part one part two');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// 126 and 127 select a 2-byte and an 8-byte length. Getting either offset wrong
|
|
80
|
+
// shifts every later frame, so the whole stream decodes as noise.
|
|
81
|
+
test('the extended length forms are read', () => {
|
|
82
|
+
const read = createFrameReader();
|
|
83
|
+
const medium = 'x'.repeat(300);
|
|
84
|
+
assert.equal(read(frame(OPCODE.text, medium, { mask: true }))[0].payload.toString(), medium);
|
|
85
|
+
const large = 'y'.repeat(70000);
|
|
86
|
+
assert.equal(read(frame(OPCODE.text, large, { mask: true }))[0].payload.toString(), large);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a control frame is reported and does not break a fragmented message', () => {
|
|
90
|
+
const read = createFrameReader();
|
|
91
|
+
read(frame(OPCODE.text, 'start ', { fin: false, mask: true }));
|
|
92
|
+
const ping = read(frame(OPCODE.ping, '', { mask: true }));
|
|
93
|
+
assert.equal(ping[0].type, 'ping');
|
|
94
|
+
assert.equal(ping[0].control, true);
|
|
95
|
+
const out = read(frame(OPCODE.continuation, 'end', { mask: true }));
|
|
96
|
+
assert.equal(out[0].payload.toString(), 'start end', 'the ping must not join the message');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a permessage-deflate payload is inflated when the handshake negotiated it', () => {
|
|
100
|
+
const text = '{"type":"response.output_text.delta","delta":"PONG"}';
|
|
101
|
+
// A permessage-deflate sender ends the message with Z_SYNC_FLUSH, which leaves
|
|
102
|
+
// the empty block 00 00 ff ff, and then strips those four bytes. Z_FINISH, the
|
|
103
|
+
// default of deflateRawSync, does not produce that tail at all.
|
|
104
|
+
const deflated = zlib.deflateRawSync(Buffer.from(text), { finishFlush: zlib.constants.Z_SYNC_FLUSH });
|
|
105
|
+
assert.deepEqual([...deflated.subarray(deflated.length - 4)], [0x00, 0x00, 0xff, 0xff]);
|
|
106
|
+
const body = deflated.subarray(0, deflated.length - 4);
|
|
107
|
+
const read = createFrameReader({ inflate: true });
|
|
108
|
+
const out = read(frame(OPCODE.text, body, { mask: true, rsv1: true }));
|
|
109
|
+
assert.equal(out[0].compressed, true);
|
|
110
|
+
assert.equal(out[0].payload.toString(), text);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Measured on a real Codex stream (2026-09-20): inflating each message on its own
|
|
114
|
+
// decoded the first and lost the next 20 with "invalid distance too far back".
|
|
115
|
+
// permessage-deflate keeps the compression window between messages by default.
|
|
116
|
+
test('a second compressed message decodes, because the inflate context is kept', async () => {
|
|
117
|
+
// One sender stream, flushed per message: that is what context takeover means.
|
|
118
|
+
const sender = zlib.createDeflateRaw({ windowBits: 15 });
|
|
119
|
+
const chunks = [];
|
|
120
|
+
sender.on('data', (c) => chunks.push(c));
|
|
121
|
+
const deflate = (text) => new Promise((resolve) => {
|
|
122
|
+
const before = Buffer.concat(chunks).length;
|
|
123
|
+
sender.write(Buffer.from(text));
|
|
124
|
+
sender.flush(zlib.constants.Z_SYNC_FLUSH, () => {
|
|
125
|
+
const all = Buffer.concat(chunks);
|
|
126
|
+
resolve(all.subarray(before, all.length - 4));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
const first = '{"type":"codex.rate_limits","rate_limits":{"used_percent":2}}';
|
|
130
|
+
const second = '{"type":"codex.rate_limits","rate_limits":{"used_percent":3}}';
|
|
131
|
+
const payloads = [await deflate(first), await deflate(second)];
|
|
132
|
+
|
|
133
|
+
const read = createFrameReader({ inflate: true });
|
|
134
|
+
const a = read(frame(OPCODE.text, payloads[0], { rsv1: true }));
|
|
135
|
+
const b = read(frame(OPCODE.text, payloads[1], { rsv1: true }));
|
|
136
|
+
assert.equal(a[0].payload.toString(), first);
|
|
137
|
+
assert.equal(b[0].payload.toString(), second, 'the second message must decode as well');
|
|
138
|
+
assert.equal(b[0].note, undefined, 'no decode failure is expected');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a payload that cannot be inflated keeps a reason instead of noise', () => {
|
|
142
|
+
const read = createFrameReader({ inflate: true });
|
|
143
|
+
const out = read(frame(OPCODE.text, Buffer.from([0xff, 0xfe, 0xfd]), { mask: true, rsv1: true }));
|
|
144
|
+
assert.equal(out[0].compressed, true);
|
|
145
|
+
assert.match(out[0].note, /cannot inflate permessage-deflate payload/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('deflate is only used when the handshake asked for it', () => {
|
|
149
|
+
assert.equal(negotiatesDeflate('permessage-deflate; client_max_window_bits'), true);
|
|
150
|
+
assert.equal(negotiatesDeflate('PERMESSAGE-DEFLATE'), true);
|
|
151
|
+
assert.equal(negotiatesDeflate('x-webkit-deflate-frame'), false);
|
|
152
|
+
assert.equal(negotiatesDeflate(undefined), false);
|
|
153
|
+
assert.equal(negotiatesDeflate(''), false);
|
|
154
|
+
});
|