llm-switcher 1.2.6 → 1.2.8
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/.impeccable/hook.cache.json +1 -0
- package/CHANGELOG.md +57 -0
- package/README.md +74 -4
- package/README.vi.md +75 -4
- package/blindfold/blindfold.mjs +2 -1
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/cross-platform.md +3 -1
- package/docs/response-matrix.json +1130 -1130
- package/hook-status.mjs +51 -0
- package/notify-route.ps1 +46 -0
- package/package.json +2 -2
- package/plugin.mjs +204 -0
- package/proxy.mjs +8 -3
- package/service.mjs +1 -1
- package/shim.mjs +28 -2
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +41 -5
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +53 -2
- package/tests/blindfold.test.mjs +1 -1
- package/tests/cdp.mjs +297 -0
- package/tests/dashboard-fixture.mjs +167 -0
- package/tests/dashboard.e2e.test.mjs +786 -0
- package/tests/gateway.e2e.test.mjs +1 -1
- package/tests/helpers.mjs +24 -24
- package/tests/hook-status.test.mjs +119 -0
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/plugin.test.mjs +132 -0
- package/tests/real-user-sim.test.mjs +2 -2
- package/tests/service.test.mjs +2 -0
- package/tests/shim.test.mjs +112 -0
- package/tests/state.test.mjs +130 -5
- package/tests/ui.test.mjs +1 -1
- package/ui.html +121 -20
package/state.mjs
CHANGED
|
@@ -134,6 +134,10 @@ export const paths = {
|
|
|
134
134
|
envClaudeSh: path.join(STATE_DIR, 'env-claude.sh'),
|
|
135
135
|
envCodexCmd: path.join(STATE_DIR, 'env-codex.cmd'),
|
|
136
136
|
envCodexSh: path.join(STATE_DIR, 'env-codex.sh'),
|
|
137
|
+
// One line per tool, written with that tool's env file: what the launcher shows the person at
|
|
138
|
+
// launch. Empty exactly when the tool is not routed, so the launcher needs no second condition.
|
|
139
|
+
routeClaude: path.join(STATE_DIR, 'route-claude.txt'),
|
|
140
|
+
routeCodex: path.join(STATE_DIR, 'route-codex.txt'),
|
|
137
141
|
// The gateway port of the last switch on. The shim scrubs a stale loopback base URL
|
|
138
142
|
// against it, so a shell opened before a port change still reaches the right gateway.
|
|
139
143
|
gatewayPort: path.join(STATE_DIR, 'gateway.port'),
|
|
@@ -454,6 +458,11 @@ export function validateProfileInput(p) {
|
|
|
454
458
|
return null;
|
|
455
459
|
}
|
|
456
460
|
|
|
461
|
+
/** True while a profile still carries the example config's placeholder baseURL or apiKey. */
|
|
462
|
+
export function hasPlaceholder(p) {
|
|
463
|
+
return !p?.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey ?? ''}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
457
466
|
export function needsMigration(cfg) {
|
|
458
467
|
if (!cfg || typeof cfg !== 'object') return false;
|
|
459
468
|
if (Object.hasOwn(cfg, 'activeProfile')) return true;
|
|
@@ -1227,8 +1236,9 @@ export function setTargetProfile(cfg, target, profileKey) {
|
|
|
1227
1236
|
if (!profileAcceptsTarget(p, tool)) {
|
|
1228
1237
|
return `Profile "${profileKey}" only accepts "${p.tool || p.inFormat}" input and cannot serve target "${tool}"`;
|
|
1229
1238
|
}
|
|
1239
|
+
// No legacy `activeProfile` here: needsMigration flags it, so every switch would re-run the
|
|
1240
|
+
// migration on the next load and leave another config.json.bak-* holding the API keys.
|
|
1230
1241
|
map[tool] = profileKey;
|
|
1231
|
-
cfg.activeProfile = profileKey;
|
|
1232
1242
|
return null;
|
|
1233
1243
|
}
|
|
1234
1244
|
|
|
@@ -1240,7 +1250,6 @@ export function activateProfile(cfg, profileKey) {
|
|
|
1240
1250
|
for (const t of TOOLS) {
|
|
1241
1251
|
if (profileAcceptsTarget(p, t)) map[t] = profileKey;
|
|
1242
1252
|
}
|
|
1243
|
-
cfg.activeProfile = profileKey;
|
|
1244
1253
|
return null;
|
|
1245
1254
|
}
|
|
1246
1255
|
|
|
@@ -1300,6 +1309,16 @@ export function primaryModel(profile) {
|
|
|
1300
1309
|
return '';
|
|
1301
1310
|
}
|
|
1302
1311
|
|
|
1312
|
+
// What a person needs at launch: which profile took the traffic, the host it goes to, the model it
|
|
1313
|
+
// maps to, and whether the 1M window is on, because that changes what the session costs. Never the
|
|
1314
|
+
// API key: a desktop notification reads this line, and so can any shell.
|
|
1315
|
+
function routeLine(tool, key, profile) {
|
|
1316
|
+
let host = '';
|
|
1317
|
+
try { host = new URL(profile.baseURL).host; } catch { /* a profile may carry no address yet */ }
|
|
1318
|
+
return [`${tool} -> ${key}`, host, primaryModel(profile), anyTier1M(profile) ? '1M' : '']
|
|
1319
|
+
.filter(Boolean).join(' | ');
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1303
1322
|
// Derive launcher state from activeProfiles (single source of truth).
|
|
1304
1323
|
export function computeLaunchState(cfg, port) {
|
|
1305
1324
|
const map = getActiveMap(cfg);
|
|
@@ -1310,7 +1329,15 @@ export function computeLaunchState(cfg, port) {
|
|
|
1310
1329
|
|
|
1311
1330
|
// One interceptor serves both tools (R3) and its port lives at the top level of config.json
|
|
1312
1331
|
// (R3b). The per-profile port went with the per-profile host and prefix.
|
|
1313
|
-
|
|
1332
|
+
// Precedence mirrors resolvePort for the gateway: the environment, then the file, then the
|
|
1333
|
+
// default. Without the variable a second instance could not move the interceptor, and a gateway
|
|
1334
|
+
// sent to 3457 by --port or by LLM_SWITCHER_PORT landed on the interceptor's own port.
|
|
1335
|
+
let bfPort = parsePort(process.env.LLM_SWITCHER_BLINDFOLD_PORT)
|
|
1336
|
+
|| parsePort(cfg?.blindfold?.port)
|
|
1337
|
+
|| DEFAULT_BLINDFOLD_PORT;
|
|
1338
|
+
if (bfPort === port) {
|
|
1339
|
+
bfPort = port === DEFAULT_BLINDFOLD_PORT ? DEFAULT_BLINDFOLD_PORT + 1 : DEFAULT_BLINDFOLD_PORT;
|
|
1340
|
+
}
|
|
1314
1341
|
const interceptor = `http://127.0.0.1:${bfPort}`;
|
|
1315
1342
|
const loopbackOnly = '127.0.0.1,localhost';
|
|
1316
1343
|
const proxyPairs = () => [
|
|
@@ -1339,7 +1366,10 @@ export function computeLaunchState(cfg, port) {
|
|
|
1339
1366
|
// no --config (F1, F3, F4). The shim of the tool may therefore never capture the traffic
|
|
1340
1367
|
// of the other tool, and a restart cannot leave a stale override behind.
|
|
1341
1368
|
envClaude: [],
|
|
1342
|
-
envCodex: []
|
|
1369
|
+
envCodex: [],
|
|
1370
|
+
// The launcher notice, one line per tool. Empty for a tool that is not routed (A1).
|
|
1371
|
+
routeClaude: claude ? routeLine('claude', map.claude, claude) : '',
|
|
1372
|
+
routeCodex: codex ? routeLine('codex', map.codex, codex) : ''
|
|
1343
1373
|
};
|
|
1344
1374
|
|
|
1345
1375
|
// NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
|
|
@@ -1446,6 +1476,7 @@ export function emptyToolEnvFiles(tool) {
|
|
|
1446
1476
|
const before = codex ? readOrEmpty(paths.envCodexSh) : '';
|
|
1447
1477
|
writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
|
|
1448
1478
|
writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
|
|
1479
|
+
writeAtomic(codex ? paths.routeCodex : paths.routeClaude, '');
|
|
1449
1480
|
return before !== '';
|
|
1450
1481
|
});
|
|
1451
1482
|
return { codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
|
|
@@ -1513,6 +1544,10 @@ function writeLaunchState(cfg, port) {
|
|
|
1513
1544
|
try {
|
|
1514
1545
|
writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
|
|
1515
1546
|
writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
|
|
1547
|
+
writeAtomic(paths.routeClaude, st.routeClaude ? `${st.routeClaude}
|
|
1548
|
+
` : '');
|
|
1549
|
+
writeAtomic(paths.routeCodex, st.routeCodex ? `${st.routeCodex}
|
|
1550
|
+
` : '');
|
|
1516
1551
|
st.codexRouteChanged = readOrEmpty(paths.envCodexSh) !== codexBefore;
|
|
1517
1552
|
// Recorded on switch on so a shell opened while the gateway ran can still recognize its own
|
|
1518
1553
|
// stale loopback URL later, after the port changed (R8).
|
|
@@ -1537,7 +1572,8 @@ export function clearLaunchState(port) {
|
|
|
1537
1572
|
// leave env.sh present and containing no export and no unset.
|
|
1538
1573
|
writeAtomic(paths.envCmd, STUB_CMD);
|
|
1539
1574
|
writeAtomic(paths.envSh, STUB_SH);
|
|
1540
|
-
for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh
|
|
1575
|
+
for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh,
|
|
1576
|
+
paths.routeClaude, paths.routeCodex]) {
|
|
1541
1577
|
writeAtomic(f, '');
|
|
1542
1578
|
}
|
|
1543
1579
|
return codexBefore !== '';
|
package/switch
CHANGED
|
File without changes
|
package/switch.cmd
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
@echo off
|
|
2
|
-
node "%~dp0switch.mjs" %*
|
|
1
|
+
@echo off
|
|
2
|
+
node "%~dp0switch.mjs" %*
|
package/switch.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { spawn, execFileSync } from 'node:child_process';
|
|
6
6
|
import {
|
|
7
|
-
ROOT_DIR, STATE_DIR, TOOLS, configPath, claudeSettingsPath, paths, certCoversHost, loadConfig, getConfigLoadError, saveConfig,
|
|
7
|
+
ROOT_DIR, STATE_DIR, TOOLS, configPath, hasPlaceholder, claudeSettingsPath, paths, certCoversHost, loadConfig, getConfigLoadError, saveConfig,
|
|
8
8
|
resolvePort, parsePort, findProfileKey, getActiveMap, setTargetProfile, activateProfile, deactivateAll,
|
|
9
9
|
applyLaunchState, clearLaunchState, computeLaunchState,
|
|
10
10
|
modelSlotsForProfile, modelForSlot, model1MForSlot, readAdminToken, adminTokenPath, openLog,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
|
|
15
15
|
} from './state.mjs';
|
|
16
16
|
import { runProbe, runCheck } from './contract.mjs';
|
|
17
|
+
import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
|
|
17
18
|
import {
|
|
18
19
|
SHIM_DIR, installShims, uninstallShims, shimStatus, pathExportLine, pathOrderHint,
|
|
19
20
|
suggestedRcFiles, auditRunningProcesses
|
|
@@ -391,6 +392,10 @@ async function turnOn(profileName, cliTarget) {
|
|
|
391
392
|
|
|
392
393
|
// Plan on a copy. Nothing is written until every check below passes.
|
|
393
394
|
let planned = planSwitch(config, key, cliTarget);
|
|
395
|
+
// The example config routes to YOUR-ROUTER-HOST; activating it silently makes every request fail.
|
|
396
|
+
if (hasPlaceholder(planned.profiles[key])) {
|
|
397
|
+
console.warn(`[WARN] Profile "${show(key)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
|
|
398
|
+
}
|
|
394
399
|
console.log(`Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
|
|
395
400
|
|
|
396
401
|
const gatewayState = await probeGateway(port);
|
|
@@ -777,6 +782,47 @@ async function manageService(action) {
|
|
|
777
782
|
console.log('Usage: switch service [install|uninstall]');
|
|
778
783
|
}
|
|
779
784
|
|
|
785
|
+
// The launch notice, as a hook inside each tool. Opt-in: the routing does not depend on it, and a
|
|
786
|
+
// person who installs nothing still gets the toast from the shim.
|
|
787
|
+
async function managePlugin(action = 'status') {
|
|
788
|
+
const act = (action || 'status').toLowerCase();
|
|
789
|
+
|
|
790
|
+
if (act === 'install' || act === 'on') {
|
|
791
|
+
const { CURRENT_VERSION } = await import('./version.mjs');
|
|
792
|
+
const { installed, failed, paths } = installPlugin({}, CURRENT_VERSION);
|
|
793
|
+
for (const tool of installed) console.log(`[OK] ${tool}: ${paths[tool]}`);
|
|
794
|
+
for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
|
|
795
|
+
if (installed.length) {
|
|
796
|
+
console.log('\nNeither settings.json nor config.toml was opened. Each tool reads a file of its own.');
|
|
797
|
+
console.log(`Claude Code loads it on the next session as ${PLUGIN_NAME}@skills-dir.`);
|
|
798
|
+
console.log('Codex asks you once to trust a new hook. Accept it, or the hook stays off.');
|
|
799
|
+
console.log('\nTo remove it: switch plugin uninstall');
|
|
800
|
+
}
|
|
801
|
+
if (failed.length) process.exit(1);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (act === 'uninstall' || act === 'off' || act === 'remove') {
|
|
806
|
+
const { installed, failed, paths } = uninstallPlugin();
|
|
807
|
+
for (const tool of installed) console.log(`[OK] ${tool}: removed from ${paths[tool]}`);
|
|
808
|
+
for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
|
|
809
|
+
if (failed.length) process.exit(1);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const st = pluginStatus();
|
|
814
|
+
console.log('=== Launch notice (hook inside each coding tool) ===\n');
|
|
815
|
+
console.log(`Claude Code: ${st.claude.installed ? `INSTALLED as ${st.claude.id}` : 'not installed'}`);
|
|
816
|
+
console.log(` ${st.claude.path}`);
|
|
817
|
+
console.log(`Codex : ${st.codex.installed ? 'INSTALLED' : 'not installed'}`);
|
|
818
|
+
console.log(` ${st.codex.path}`);
|
|
819
|
+
if (!st.claude.installed || !st.codex.installed) {
|
|
820
|
+
console.log(`\nThe notice is optional. Without it the gateway still routes every request,`);
|
|
821
|
+
console.log(`and the shim still raises its own notice at launch.`);
|
|
822
|
+
console.log(`To add it: switch plugin install`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
780
826
|
async function manageShim(action = 'status') {
|
|
781
827
|
const act = (action || 'status').toLowerCase();
|
|
782
828
|
|
|
@@ -862,7 +908,7 @@ async function runDoctor() {
|
|
|
862
908
|
if (!key) continue;
|
|
863
909
|
const p = config.profiles[key];
|
|
864
910
|
if (!p) warn(`[WARN] Target ${t} points to missing profile "${show(key)}".`);
|
|
865
|
-
else if (
|
|
911
|
+
else if (hasPlaceholder(p)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
|
|
866
912
|
if (p && t === 'codex') {
|
|
867
913
|
const codexWarning = codexPublicModelsWarning(show(key), p);
|
|
868
914
|
if (codexWarning) warn(`[WARN] ${codexWarning}`);
|
|
@@ -1109,6 +1155,8 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1109
1155
|
await manageService(subArg.toLowerCase() || 'status');
|
|
1110
1156
|
} else if (cmd === 'shim' || cmd === 'shims') {
|
|
1111
1157
|
await manageShim(subArg.toLowerCase() || 'status');
|
|
1158
|
+
} else if (cmd === 'plugin' || cmd === 'plugins') {
|
|
1159
|
+
await managePlugin(subArg.toLowerCase() || 'status');
|
|
1112
1160
|
} else if (Object.hasOwn(TARGET_ALIASES, cmd)) {
|
|
1113
1161
|
await turnOn(subArg, TARGET_ALIASES[cmd]);
|
|
1114
1162
|
} else if (cmd === 'ui' || cmd === 'web' || cmd === 'gui') {
|
|
@@ -1141,6 +1189,9 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1141
1189
|
console.log(' switch shim install # Auto-inject env into resumed sessions (claude --resume)');
|
|
1142
1190
|
console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
|
|
1143
1191
|
console.log(' switch shim uninstall # Remove launcher shims');
|
|
1192
|
+
console.log(' switch plugin install # Optional launch notice inside Claude Code & Codex');
|
|
1193
|
+
console.log(' switch plugin status # Is that notice installed');
|
|
1194
|
+
console.log(' switch plugin uninstall # Remove that notice');
|
|
1144
1195
|
console.log(' switch off [target] # Restore official endpoints (all, or one target)');
|
|
1145
1196
|
console.log(' switch models [--refresh] # Show discovered models catalog for Claude Code & Codex');
|
|
1146
1197
|
console.log(' switch contract-probe [--model m] # Drive the contract-lab variants through the gateway');
|
package/tests/blindfold.test.mjs
CHANGED
|
@@ -137,7 +137,7 @@ test('a compressed response body is decoded for the capture', () => {
|
|
|
137
137
|
assert.equal(decodeBody(zlib.gzipSync(Buffer.from(sse)), 'GZIP'), sse, 'must be case-insensitive');
|
|
138
138
|
assert.equal(decodeBody(zlib.brotliCompressSync(Buffer.from(sse)), 'br'), sse);
|
|
139
139
|
assert.equal(decodeBody(zlib.deflateSync(Buffer.from(sse)), 'deflate'), sse);
|
|
140
|
-
assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
|
|
140
|
+
if (typeof zlib.zstdCompressSync === 'function') assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
test('an unencoded body is passed through untouched', () => {
|
package/tests/cdp.mjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// A headless Chromium and one page, driven over the Chrome DevTools Protocol with no dependency.
|
|
2
|
+
// The tests use it to press the real dashboard the way a person does: a click is a mouse event at
|
|
3
|
+
// the pixel where the element is drawn, so a control that is hidden or covered fails the test.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const CANDIDATES = [
|
|
10
|
+
process.env.LLM_SWITCHER_CHROMIUM,
|
|
11
|
+
'/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable',
|
|
12
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
13
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
|
|
14
|
+
].filter(Boolean);
|
|
15
|
+
|
|
16
|
+
export function findChromium() {
|
|
17
|
+
return CANDIDATES.find(p => fs.existsSync(p)) || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// undefined when this machine can run the browser tests, otherwise the reason to skip them.
|
|
21
|
+
export function skipReason() {
|
|
22
|
+
if (typeof WebSocket === 'undefined') return 'needs a global WebSocket (Node 22 or newer)';
|
|
23
|
+
if (!findChromium()) return 'needs a Chromium or Chrome binary (set LLM_SWITCHER_CHROMIUM)';
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
28
|
+
|
|
29
|
+
const KEYS = {
|
|
30
|
+
Enter: { code: 'Enter', vk: 13, text: '\r' },
|
|
31
|
+
Escape: { code: 'Escape', vk: 27 },
|
|
32
|
+
Tab: { code: 'Tab', vk: 9 },
|
|
33
|
+
ArrowDown: { code: 'ArrowDown', vk: 40 },
|
|
34
|
+
ArrowUp: { code: 'ArrowUp', vk: 38 },
|
|
35
|
+
Backspace: { code: 'Backspace', vk: 8 }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function launchBrowser() {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-chromium-'));
|
|
40
|
+
const args = [
|
|
41
|
+
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
|
|
42
|
+
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-gpu',
|
|
43
|
+
'--disable-background-networking', '--window-size=1280,900',
|
|
44
|
+
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
45
|
+
'about:blank'
|
|
46
|
+
];
|
|
47
|
+
const child = spawn(findChromium(), args, { stdio: 'ignore', env: { ...process.env, HOME: dir } });
|
|
48
|
+
let port = 0;
|
|
49
|
+
for (let i = 0; i < 150 && !port; i++) {
|
|
50
|
+
try { port = Number(fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]) || 0; } catch { /* not written yet */ }
|
|
51
|
+
if (!port) await sleep(100);
|
|
52
|
+
}
|
|
53
|
+
if (!port) { child.kill(); throw new Error('Chromium did not open a debugging port'); }
|
|
54
|
+
|
|
55
|
+
const pages = [];
|
|
56
|
+
return {
|
|
57
|
+
async newPage() {
|
|
58
|
+
const list = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
|
|
59
|
+
const page = new Page(list.webSocketDebuggerUrl);
|
|
60
|
+
await page.ready();
|
|
61
|
+
pages.push(page);
|
|
62
|
+
return page;
|
|
63
|
+
},
|
|
64
|
+
async close() {
|
|
65
|
+
for (const p of pages) p.close();
|
|
66
|
+
child.kill();
|
|
67
|
+
await new Promise(r => { child.once('exit', r); setTimeout(r, 2000); });
|
|
68
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class Page {
|
|
74
|
+
constructor(wsUrl) {
|
|
75
|
+
this.ws = new WebSocket(wsUrl);
|
|
76
|
+
this.nextId = 1;
|
|
77
|
+
this.pending = new Map();
|
|
78
|
+
this.listeners = new Map();
|
|
79
|
+
// What a person would notice or what the code would report: kept so a test can assert on it.
|
|
80
|
+
this.problems = []; // console errors, uncaught exceptions, failed loads
|
|
81
|
+
this.badResponses = []; // { url, status } for every HTTP 4xx and 5xx
|
|
82
|
+
this.dialogs = []; // { type, message } for every alert, confirm and prompt
|
|
83
|
+
this.dialogPolicy = 'accept';
|
|
84
|
+
this.inflight = new Map(); // requestId -> url, so a stuck request can be named
|
|
85
|
+
this.requests = []; // { method, url } for every request, so a double submit is countable
|
|
86
|
+
this.ws.addEventListener('message', (ev) => this.#onMessage(JSON.parse(ev.data)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async ready() {
|
|
90
|
+
if (this.ws.readyState !== 1) await new Promise((res, rej) => { this.ws.addEventListener('open', res, { once: true }); this.ws.addEventListener('error', rej, { once: true }); });
|
|
91
|
+
await Promise.all(['Page', 'Runtime', 'Network', 'Log'].map(d => this.send(`${d}.enable`)));
|
|
92
|
+
this.on('Runtime.consoleAPICalled', (e) => {
|
|
93
|
+
if (e.type === 'error' || e.type === 'assert') this.problems.push(`console.${e.type}: ${e.args.map(a => a.value ?? a.description ?? '').join(' ')}`);
|
|
94
|
+
});
|
|
95
|
+
this.on('Runtime.exceptionThrown', (e) => this.problems.push(`exception: ${e.exceptionDetails.exception?.description || e.exceptionDetails.text}`));
|
|
96
|
+
this.on('Log.entryAdded', (e) => { if (e.entry.level === 'error') this.problems.push(`log: ${e.entry.text} ${e.entry.url || ''}`.trim()); });
|
|
97
|
+
this.on('Network.requestWillBeSent', (e) => {
|
|
98
|
+
this.inflight.set(e.requestId, e.request.url);
|
|
99
|
+
this.requests.push({ method: e.request.method, url: e.request.url });
|
|
100
|
+
});
|
|
101
|
+
// A new document ends every request of the old one, and no event says so for some of them.
|
|
102
|
+
this.on('Page.frameNavigated', (e) => { if (!e.frame.parentId) this.inflight.clear(); });
|
|
103
|
+
this.on('Network.loadingFinished', (e) => this.inflight.delete(e.requestId));
|
|
104
|
+
this.on('Network.responseReceived', (e) => { if (e.response.status >= 400) this.badResponses.push({ url: e.response.url, status: e.response.status }); });
|
|
105
|
+
this.on('Network.loadingFailed', (e) => {
|
|
106
|
+
this.inflight.delete(e.requestId);
|
|
107
|
+
if (!e.canceled) this.problems.push(`load failed: ${e.errorText} ${e.requestId}`);
|
|
108
|
+
});
|
|
109
|
+
this.on('Page.javascriptDialogOpening', (e) => {
|
|
110
|
+
this.dialogs.push({ type: e.type, message: e.message });
|
|
111
|
+
this.send('Page.handleJavaScriptDialog', { accept: this.dialogPolicy === 'accept' }).catch(() => {});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#onMessage(msg) {
|
|
116
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
117
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
118
|
+
this.pending.delete(msg.id);
|
|
119
|
+
if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`)); else resolve(msg.result);
|
|
120
|
+
} else if (msg.method) {
|
|
121
|
+
for (const cb of this.listeners.get(msg.method) || []) cb(msg.params);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
on(method, cb) {
|
|
126
|
+
if (!this.listeners.has(method)) this.listeners.set(method, []);
|
|
127
|
+
this.listeners.get(method).push(cb);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
send(method, params = {}) {
|
|
131
|
+
const id = this.nextId++;
|
|
132
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
133
|
+
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
close() { try { this.ws.close(); } catch { /* already closed */ } }
|
|
137
|
+
|
|
138
|
+
// Forget what happened so far, so a test asserts only on the action it just made.
|
|
139
|
+
clearProblems() {
|
|
140
|
+
this.problems.length = 0;
|
|
141
|
+
this.badResponses.length = 0;
|
|
142
|
+
this.dialogs.length = 0;
|
|
143
|
+
this.requests.length = 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Every request of one method whose URL holds `part`. A double submit is two entries here.
|
|
147
|
+
sent(method, part) { return this.requests.filter(r => r.method === method && r.url.includes(part)); }
|
|
148
|
+
|
|
149
|
+
// A real viewport change, the way a narrow window is, so a media query takes effect.
|
|
150
|
+
async resize(width, height) {
|
|
151
|
+
await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
|
152
|
+
// A layout pass runs on the next frame, so a measurement taken at once reads the old box.
|
|
153
|
+
await this.evaluate('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Wait until no request is in flight for `quiet` ms. A click starts a request, and the page then
|
|
157
|
+
// starts a second one to read the state back, so one quiet moment is not enough.
|
|
158
|
+
async idle(quiet = 250, timeout = 8000) {
|
|
159
|
+
const end = Date.now() + timeout;
|
|
160
|
+
let quietSince = Date.now();
|
|
161
|
+
while (Date.now() < end) {
|
|
162
|
+
if (this.inflight.size > 0) quietSince = Date.now();
|
|
163
|
+
else if (Date.now() - quietSince >= quiet) return;
|
|
164
|
+
await sleep(25);
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`the page still has ${this.inflight.size} request(s) in flight after ${timeout} ms: ${[...this.inflight.values()].join(', ')}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#loaded(timeout = 10000) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const timer = setTimeout(() => reject(new Error(`the page did not finish loading in ${timeout} ms`)), timeout);
|
|
172
|
+
const done = () => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
const handlers = this.listeners.get('Page.loadEventFired');
|
|
175
|
+
handlers.splice(handlers.indexOf(done), 1);
|
|
176
|
+
resolve();
|
|
177
|
+
};
|
|
178
|
+
this.on('Page.loadEventFired', done);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async goto(url) {
|
|
183
|
+
const loaded = this.#loaded();
|
|
184
|
+
const { errorText, loaderId } = await this.send('Page.navigate', { url });
|
|
185
|
+
if (errorText) throw new Error(`could not open ${url}: ${errorText}`);
|
|
186
|
+
// A change of the #hash alone stays in the same document: no load event will follow.
|
|
187
|
+
if (loaderId) await loaded; else loaded.catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async reload() {
|
|
191
|
+
const loaded = this.#loaded();
|
|
192
|
+
await this.send('Page.reload');
|
|
193
|
+
await loaded;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async evaluate(expression) {
|
|
197
|
+
const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
|
198
|
+
if (r.exceptionDetails) throw new Error(`evaluate failed: ${r.exceptionDetails.exception?.description || r.exceptionDetails.text}\n${expression}`);
|
|
199
|
+
return r.result.value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Poll until the page expression is truthy. The message says what the test was waiting for.
|
|
203
|
+
async waitFor(expression, message, timeout = 5000) {
|
|
204
|
+
const end = Date.now() + timeout;
|
|
205
|
+
let last;
|
|
206
|
+
while (Date.now() < end) {
|
|
207
|
+
try { last = await this.evaluate(expression); if (last) return last; } catch (e) { last = e.message; }
|
|
208
|
+
await sleep(50);
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`timed out after ${timeout} ms waiting for: ${message}\nlast value: ${JSON.stringify(last)}\nexpression: ${expression}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
q(selector) { return `document.querySelector(${JSON.stringify(selector)})`; }
|
|
214
|
+
text(selector) { return this.evaluate(`${this.q(selector)}?.textContent.trim() ?? null`); }
|
|
215
|
+
value(selector) { return this.evaluate(`${this.q(selector)}?.value ?? null`); }
|
|
216
|
+
count(selector) { return this.evaluate(`document.querySelectorAll(${JSON.stringify(selector)}).length`); }
|
|
217
|
+
exists(selector) { return this.evaluate(`Boolean(${this.q(selector)})`); }
|
|
218
|
+
// Visible means drawn with a size and not hidden by display, visibility or an ancestor.
|
|
219
|
+
visible(selector) {
|
|
220
|
+
return this.evaluate(`(() => { const e = ${this.q(selector)}; if (!e) return false; const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && e.getClientRects().length > 0; })()`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The centre of the element after it scrolled into view, and what the browser draws there.
|
|
224
|
+
async #target(selector) {
|
|
225
|
+
return this.evaluate(`(() => {
|
|
226
|
+
const e = ${this.q(selector)};
|
|
227
|
+
if (!e) return { missing: true };
|
|
228
|
+
e.scrollIntoView({ block: 'center', inline: 'center' });
|
|
229
|
+
const r = e.getBoundingClientRect();
|
|
230
|
+
if (r.width === 0 || r.height === 0) return { hidden: true };
|
|
231
|
+
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
232
|
+
const hit = document.elementFromPoint(x, y);
|
|
233
|
+
const label = e.closest('label');
|
|
234
|
+
const reachable = hit && (e.contains(hit) || hit.contains(e) || (label && label.contains(hit)));
|
|
235
|
+
return { x, y, reachable, hit: hit ? hit.tagName + (hit.id ? '#' + hit.id : '') + (hit.className && typeof hit.className === 'string' ? '.' + hit.className.split(' ').join('.') : '') : null };
|
|
236
|
+
})()`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async click(selector) {
|
|
240
|
+
const t = await this.#target(selector);
|
|
241
|
+
if (t.missing) throw new Error(`click: no element matches ${selector}`);
|
|
242
|
+
if (t.hidden) throw new Error(`click: ${selector} has no size, a person cannot press it`);
|
|
243
|
+
if (!t.reachable) throw new Error(`click: ${selector} is covered by ${t.hit}, a person cannot press it`);
|
|
244
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: t.x, y: t.y });
|
|
245
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: t.x, y: t.y, button: 'left', clickCount: 1 });
|
|
246
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: t.x, y: t.y, button: 'left', clickCount: 1 });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A press at a pixel, for a target that has no element of its own: the dark area outside a dialog.
|
|
250
|
+
async clickAt(x, y) {
|
|
251
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
252
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
253
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Press the visible button or link whose label is `text`, the way a person finds it by reading.
|
|
257
|
+
async clickText(scope, text) {
|
|
258
|
+
const marked = await this.evaluate(`(() => {
|
|
259
|
+
document.querySelectorAll('[data-test-target]').forEach(e => e.removeAttribute('data-test-target'));
|
|
260
|
+
const hit = [...document.querySelectorAll(${JSON.stringify(scope)})].filter(e => e.textContent.trim() === ${JSON.stringify(text)} && e.getClientRects().length > 0);
|
|
261
|
+
if (hit.length !== 1) return hit.length;
|
|
262
|
+
hit[0].setAttribute('data-test-target', '1');
|
|
263
|
+
return 1;
|
|
264
|
+
})()`);
|
|
265
|
+
if (marked !== 1) throw new Error(`clickText: ${marked} visible match(es) for "${text}" in ${scope}, expected exactly 1`);
|
|
266
|
+
await this.click('[data-test-target="1"]');
|
|
267
|
+
await this.evaluate(`document.querySelector('[data-test-target]')?.removeAttribute('data-test-target')`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Focus the field with a click, drop the old text, then type like a person: one input event.
|
|
271
|
+
async type(selector, text) {
|
|
272
|
+
await this.click(selector);
|
|
273
|
+
await this.evaluate(`${this.q(selector)}.select()`);
|
|
274
|
+
if (text === '') await this.key('Backspace'); else await this.send('Input.insertText', { text });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async key(name) {
|
|
278
|
+
const k = KEYS[name];
|
|
279
|
+
if (!k) throw new Error(`key: unknown key ${name}`);
|
|
280
|
+
const base = { key: name, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk };
|
|
281
|
+
await this.send('Input.dispatchKeyEvent', { type: k.text ? 'keyDown' : 'rawKeyDown', ...base, ...(k.text ? { text: k.text } : {}) });
|
|
282
|
+
await this.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A native <select> opens an operating system popup that no protocol call can press, so this sets
|
|
286
|
+
// the value the way the popup would and fires the same events.
|
|
287
|
+
async select(selector, value) {
|
|
288
|
+
await this.click(selector);
|
|
289
|
+
await this.evaluate(`(() => { const s = ${this.q(selector)}; s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw new Error('select: no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async screenshot(file) {
|
|
293
|
+
const { data } = await this.send('Page.captureScreenshot', { format: 'png' });
|
|
294
|
+
fs.writeFileSync(file, Buffer.from(data, 'base64'));
|
|
295
|
+
return file;
|
|
296
|
+
}
|
|
297
|
+
}
|