@triscope/cli 0.4.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/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/triscope.mjs +141 -0
- package/package.json +38 -0
- package/src/adopt.mjs +127 -0
- package/src/auto-capture.mjs +74 -0
- package/src/dev.mjs +23 -0
- package/src/figma.mjs +124 -0
- package/src/gltf-scaffold.mjs +189 -0
- package/src/import-element.mjs +193 -0
- package/src/init.mjs +78 -0
- package/src/list.mjs +21 -0
- package/src/mcp.mjs +257 -0
- package/src/parse-flags.mjs +25 -0
- package/src/preflight.mjs +110 -0
- package/src/smoke-lib.mjs +230 -0
- package/src/smoke.mjs +182 -0
- package/src/state.mjs +46 -0
- package/src/wizard.mjs +101 -0
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// `triscope mcp install|uninstall` — wraps `claude mcp add/remove`.
|
|
2
|
+
//
|
|
3
|
+
// Resolves the absolute path of the @triscope/mcp bin via Node's resolver, so
|
|
4
|
+
// it works whether triscope is installed as a dep, linked, or run from this
|
|
5
|
+
// monorepo. Falls back to a sibling `packages/mcp/bin/triscope-mcp.mjs` for
|
|
6
|
+
// the monorepo case where the bin isn't on PATH.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const SERVER_NAME = 'triscope';
|
|
15
|
+
const DEFAULT_URL = 'http://localhost:5173';
|
|
16
|
+
|
|
17
|
+
export function resolveServerBin() {
|
|
18
|
+
// Try the workspace sibling first (monorepo dev case).
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const sibling = resolve(here, '../../mcp/bin/triscope-mcp.mjs');
|
|
21
|
+
if (existsSync(sibling)) return sibling;
|
|
22
|
+
|
|
23
|
+
// Fall back to Node resolution from the consumer project's cwd.
|
|
24
|
+
try {
|
|
25
|
+
const req = createRequire(join(process.cwd(), 'package.json'));
|
|
26
|
+
const pkgJson = req.resolve('@triscope/mcp/package.json');
|
|
27
|
+
return resolve(dirname(pkgJson), 'bin/triscope-mcp.mjs');
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveCliBin() {
|
|
34
|
+
// This module lives in packages/cli/src/mcp.mjs; the bin is at ../bin/triscope.mjs.
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
return resolve(here, '../bin/triscope.mjs');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function triscopeOnPath() {
|
|
40
|
+
const r = spawnSync('triscope', ['--help'], { stdio: 'ignore' });
|
|
41
|
+
return r.status === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function autoCaptureCommand() {
|
|
45
|
+
// Use bare `triscope` if it's on PATH (npm i -g'd), otherwise the
|
|
46
|
+
// absolute path to this CLI bin (monorepo / file-dep installs).
|
|
47
|
+
const cmd = triscopeOnPath() ? 'triscope' : `node ${resolveCliBin()}`;
|
|
48
|
+
return `${cmd} auto-capture --file "\${TOOL_INPUT_file_path:-}"`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasClaudeCli() {
|
|
52
|
+
const r = spawnSync('claude', ['--version'], { stdio: 'ignore' });
|
|
53
|
+
return r.status === 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function runClaude(args) {
|
|
57
|
+
const r = spawnSync('claude', args, { stdio: 'inherit' });
|
|
58
|
+
if (r.status !== 0) throw new Error(`claude ${args.join(' ')} exited ${r.status}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isTriscopeRegistered() {
|
|
62
|
+
const r = spawnSync('claude', ['mcp', 'list'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
|
+
if (r.status !== 0) return false;
|
|
64
|
+
return /^triscope:/m.test(r.stdout?.toString?.() ?? '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeProjectMcpJson(bin, url) {
|
|
68
|
+
const path = join(process.cwd(), '.mcp.json');
|
|
69
|
+
let existing = {};
|
|
70
|
+
if (existsSync(path)) {
|
|
71
|
+
try {
|
|
72
|
+
existing = JSON.parse(readFileSync(path, 'utf8'));
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
existing.mcpServers ??= {};
|
|
76
|
+
existing.mcpServers[SERVER_NAME] = {
|
|
77
|
+
command: 'node',
|
|
78
|
+
args: [bin],
|
|
79
|
+
env: { TRISCOPE_URL: url },
|
|
80
|
+
};
|
|
81
|
+
writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function removeFromProjectMcpJson() {
|
|
86
|
+
const path = join(process.cwd(), '.mcp.json');
|
|
87
|
+
if (!existsSync(path)) return null;
|
|
88
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
89
|
+
if (!data?.mcpServers?.[SERVER_NAME]) return null;
|
|
90
|
+
delete data.mcpServers[SERVER_NAME];
|
|
91
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
92
|
+
return path;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Hook config — same shape across user/project scope. The "_triscope" tag
|
|
96
|
+
// lets uninstall find and remove our entry without touching unrelated hooks.
|
|
97
|
+
export function triscopeHookSpec() {
|
|
98
|
+
return {
|
|
99
|
+
matcher: 'Edit|Write',
|
|
100
|
+
_triscope: true,
|
|
101
|
+
hooks: [{ type: 'command', command: autoCaptureCommand() }],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function settingsPathForScope(scope) {
|
|
106
|
+
if (scope === 'project') {
|
|
107
|
+
return join(process.cwd(), '.claude', 'settings.local.json');
|
|
108
|
+
}
|
|
109
|
+
return join(homedir(), '.claude', 'settings.json');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function mergeHook(scope) {
|
|
113
|
+
const path = settingsPathForScope(scope);
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
let data = {};
|
|
116
|
+
if (existsSync(path)) {
|
|
117
|
+
try {
|
|
118
|
+
data = JSON.parse(readFileSync(path, 'utf8'));
|
|
119
|
+
} catch {
|
|
120
|
+
// Settings file is malformed — refuse rather than silently overwrite.
|
|
121
|
+
throw new Error(`refusing to overwrite malformed JSON at ${path}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
data.hooks ??= {};
|
|
125
|
+
data.hooks.PostToolUse ??= [];
|
|
126
|
+
// Skip if our entry is already present (idempotent install).
|
|
127
|
+
const already = data.hooks.PostToolUse.some(
|
|
128
|
+
(e) =>
|
|
129
|
+
e?._triscope === true ||
|
|
130
|
+
e?.hooks?.some?.(
|
|
131
|
+
(h) => typeof h?.command === 'string' && h.command.includes('triscope auto-capture'),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
if (!already) data.hooks.PostToolUse.push(triscopeHookSpec());
|
|
135
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
136
|
+
return { path, added: !already };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function unmergeHook(scope) {
|
|
140
|
+
const path = settingsPathForScope(scope);
|
|
141
|
+
if (!existsSync(path)) return { path, removed: false };
|
|
142
|
+
let data;
|
|
143
|
+
try {
|
|
144
|
+
data = JSON.parse(readFileSync(path, 'utf8'));
|
|
145
|
+
} catch {
|
|
146
|
+
return { path, removed: false };
|
|
147
|
+
}
|
|
148
|
+
const arr = data?.hooks?.PostToolUse;
|
|
149
|
+
if (!Array.isArray(arr)) return { path, removed: false };
|
|
150
|
+
const before = arr.length;
|
|
151
|
+
data.hooks.PostToolUse = arr.filter(
|
|
152
|
+
(e) =>
|
|
153
|
+
!(
|
|
154
|
+
e?._triscope === true ||
|
|
155
|
+
e?.hooks?.some?.(
|
|
156
|
+
(h) => typeof h?.command === 'string' && h.command.includes('triscope auto-capture'),
|
|
157
|
+
)
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
if (data.hooks.PostToolUse.length === before) return { path, removed: false };
|
|
161
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
162
|
+
return { path, removed: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function runMcp({ action, scope = 'user', url = DEFAULT_URL, withHook = true }) {
|
|
166
|
+
if (!action || action === 'help') {
|
|
167
|
+
console.log(`triscope mcp <install|uninstall> [--project] [--no-hook] [--url <url>]
|
|
168
|
+
|
|
169
|
+
install Register the triscope MCP server with Claude Code AND
|
|
170
|
+
wire the PostToolUse auto-capture hook into settings
|
|
171
|
+
(default: user scope, so it's available in every chat).
|
|
172
|
+
install --project Write/merge .mcp.json + .claude/settings.local.json
|
|
173
|
+
in the current directory instead.
|
|
174
|
+
uninstall Remove the triscope MCP registration and the hook.
|
|
175
|
+
|
|
176
|
+
OPTIONS
|
|
177
|
+
--project Use project scope (cwd-local files) instead of user.
|
|
178
|
+
--no-hook Skip the PostToolUse hook (MCP-only install).
|
|
179
|
+
--url <url> Override TRISCOPE_URL env (default ${DEFAULT_URL}).
|
|
180
|
+
`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const bin = resolveServerBin();
|
|
185
|
+
if (!bin) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'Could not locate @triscope/mcp. Install triscope (e.g. `npm i @triscope/mcp`) or run from the monorepo.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
statSync(bin);
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error(`@triscope/mcp bin not found at ${bin}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (action === 'install') {
|
|
197
|
+
if (scope === 'project') {
|
|
198
|
+
const path = writeProjectMcpJson(bin, url);
|
|
199
|
+
console.log(`wrote project-scoped registration to ${path}`);
|
|
200
|
+
if (withHook) {
|
|
201
|
+
const r = mergeHook('project');
|
|
202
|
+
console.log(
|
|
203
|
+
r.added ? `added PostToolUse hook to ${r.path}` : `hook already present in ${r.path}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
console.log('restart Claude Code in this directory to pick it up.');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!hasClaudeCli()) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
'claude CLI not on PATH. Install Claude Code, or run `triscope mcp install --project`.',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (isTriscopeRegistered()) {
|
|
215
|
+
console.log('triscope MCP already registered (user scope), skipping mcp add.');
|
|
216
|
+
} else {
|
|
217
|
+
runClaude([
|
|
218
|
+
'mcp',
|
|
219
|
+
'add',
|
|
220
|
+
SERVER_NAME,
|
|
221
|
+
'--scope',
|
|
222
|
+
'user',
|
|
223
|
+
'--env',
|
|
224
|
+
`TRISCOPE_URL=${url}`,
|
|
225
|
+
'--',
|
|
226
|
+
'node',
|
|
227
|
+
bin,
|
|
228
|
+
]);
|
|
229
|
+
console.log(`\ntriscope registered (user scope). bin: ${bin}`);
|
|
230
|
+
}
|
|
231
|
+
if (withHook) {
|
|
232
|
+
const r = mergeHook('user');
|
|
233
|
+
console.log(
|
|
234
|
+
r.added ? `added PostToolUse hook to ${r.path}` : `hook already present in ${r.path}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (action === 'uninstall') {
|
|
241
|
+
if (scope === 'project') {
|
|
242
|
+
const path = removeFromProjectMcpJson();
|
|
243
|
+
if (path) console.log(`removed triscope from ${path}`);
|
|
244
|
+
else console.log('no project-scoped triscope entry found.');
|
|
245
|
+
const h = unmergeHook('project');
|
|
246
|
+
if (h.removed) console.log(`removed PostToolUse hook from ${h.path}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!hasClaudeCli()) throw new Error('claude CLI not on PATH.');
|
|
250
|
+
runClaude(['mcp', 'remove', SERVER_NAME, '--scope', 'user']);
|
|
251
|
+
const h = unmergeHook('user');
|
|
252
|
+
if (h.removed) console.log(`removed PostToolUse hook from ${h.path}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
throw new Error(`Unknown mcp action: ${action}`);
|
|
257
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Argv parser shared between the bin entry and its tests. Keeps the bin
|
|
2
|
+
// file small enough that the only thing left to cover via subprocess is
|
|
3
|
+
// the subcommand-dispatch table itself.
|
|
4
|
+
export function parseFlags(argv) {
|
|
5
|
+
const flags = {};
|
|
6
|
+
const positional = [];
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const a = argv[i];
|
|
9
|
+
if (a === '--help' || a === '-h') flags.help = true;
|
|
10
|
+
else if (a === '--project') flags.project = true;
|
|
11
|
+
else if (a === '--check') flags.check = true;
|
|
12
|
+
else if (a === '--quick') flags.quick = true;
|
|
13
|
+
else if (a === '--yes' || a === '-y') flags.yes = true;
|
|
14
|
+
else if (a === '--no-hook') flags['no-hook'] = true;
|
|
15
|
+
else if (a === '--no-install') flags['no-install'] = true;
|
|
16
|
+
else if (a === '--url') flags.url = argv[++i];
|
|
17
|
+
else if (a === '--file') flags.file = argv[++i];
|
|
18
|
+
else if (a === '--port') flags.port = argv[++i];
|
|
19
|
+
else if (a === '--screenshot') flags.screenshot = argv[++i];
|
|
20
|
+
else if (a === '--install') flags.install = true;
|
|
21
|
+
else if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
|
|
22
|
+
else positional.push(a);
|
|
23
|
+
}
|
|
24
|
+
return { flags, positional };
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `triscope dev --check` — preflight diagnostics. Turns the cryptic mid-session
|
|
2
|
+
// failures (no WebGPU, wrong Node, MCP not registered, port busy) into an
|
|
3
|
+
// up-front PASS/WARN/FAIL checklist. The pure predicates are unit-tested; the
|
|
4
|
+
// side-effecty ones (chromium spawn, port bind, claude CLI) are composed here.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function checkNode(version = process.versions.node) {
|
|
11
|
+
const major = Number.parseInt(String(version), 10);
|
|
12
|
+
return major >= 20
|
|
13
|
+
? { name: 'node', status: 'PASS', detail: `v${version}` }
|
|
14
|
+
: { name: 'node', status: 'FAIL', detail: `v${version} — triscope needs Node >= 20` };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function checkProjectDeps(cwd = process.cwd()) {
|
|
18
|
+
const vite = existsSync(join(cwd, 'node_modules', '.bin', 'vite'));
|
|
19
|
+
const core = existsSync(join(cwd, 'node_modules', '@triscope', 'core', 'package.json'));
|
|
20
|
+
return [
|
|
21
|
+
vite
|
|
22
|
+
? { name: 'vite', status: 'PASS', detail: 'installed' }
|
|
23
|
+
: { name: 'vite', status: 'FAIL', detail: 'not installed — run `npm install`' },
|
|
24
|
+
core
|
|
25
|
+
? { name: '@triscope/core', status: 'PASS', detail: 'installed' }
|
|
26
|
+
: { name: '@triscope/core', status: 'FAIL', detail: 'not installed — run `npm install`' },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function checkChromium(bin = process.env.CHROME_BIN ?? 'chromium') {
|
|
31
|
+
const r = spawnSync(bin, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
32
|
+
if (r.status === 0) {
|
|
33
|
+
return {
|
|
34
|
+
name: 'chromium',
|
|
35
|
+
status: 'PASS',
|
|
36
|
+
detail: (r.stdout?.toString?.() ?? '').trim() || bin,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name: 'chromium',
|
|
41
|
+
status: 'WARN',
|
|
42
|
+
detail: `"${bin}" not runnable — capture_views/smoke need Chrome/Chromium with WebGPU (set CHROME_BIN)`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function checkClaudeMcp() {
|
|
47
|
+
const has = spawnSync('claude', ['--version'], { stdio: 'ignore' }).status === 0;
|
|
48
|
+
if (!has) {
|
|
49
|
+
return {
|
|
50
|
+
name: 'mcp',
|
|
51
|
+
status: 'WARN',
|
|
52
|
+
detail: 'claude CLI not on PATH — MCP tools unavailable (optional)',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const list = spawnSync('claude', ['mcp', 'list'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
56
|
+
const registered = list.status === 0 && /^triscope:/m.test(list.stdout?.toString?.() ?? '');
|
|
57
|
+
return registered
|
|
58
|
+
? { name: 'mcp', status: 'PASS', detail: 'triscope registered' }
|
|
59
|
+
: {
|
|
60
|
+
name: 'mcp',
|
|
61
|
+
status: 'WARN',
|
|
62
|
+
detail: 'triscope not registered — run `triscope mcp install`',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function checkPortFree(port = 5173) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const srv = createServer();
|
|
69
|
+
srv.once('error', () => {
|
|
70
|
+
resolve({
|
|
71
|
+
name: `port ${port}`,
|
|
72
|
+
status: 'WARN',
|
|
73
|
+
detail: 'in use — `triscope dev` will pick another or fail',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
srv.once('listening', () => {
|
|
77
|
+
srv.close(() => resolve({ name: `port ${port}`, status: 'PASS', detail: 'free' }));
|
|
78
|
+
});
|
|
79
|
+
srv.listen(port, '127.0.0.1');
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function aggregatePreflight(results) {
|
|
84
|
+
const fails = results.filter((r) => r.status === 'FAIL').length;
|
|
85
|
+
const warns = results.filter((r) => r.status === 'WARN').length;
|
|
86
|
+
return { ok: fails === 0, fails, warns };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runPreflight({ cwd = process.cwd(), port = 5173, chromeBin } = {}) {
|
|
90
|
+
const results = [
|
|
91
|
+
checkNode(),
|
|
92
|
+
...checkProjectDeps(cwd),
|
|
93
|
+
checkChromium(chromeBin),
|
|
94
|
+
checkClaudeMcp(),
|
|
95
|
+
await checkPortFree(port),
|
|
96
|
+
];
|
|
97
|
+
return { results, ...aggregatePreflight(results) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ICON = { PASS: '✓', WARN: '!', FAIL: '✗' };
|
|
101
|
+
|
|
102
|
+
export function formatPreflight({ results, ok, fails, warns }) {
|
|
103
|
+
const lines = results.map(
|
|
104
|
+
(r) => ` ${ICON[r.status] ?? '?'} ${r.status.padEnd(4)} ${r.name} — ${r.detail}`,
|
|
105
|
+
);
|
|
106
|
+
const summary = ok
|
|
107
|
+
? `\npreflight OK${warns ? ` (${warns} warning${warns > 1 ? 's' : ''})` : ''}.`
|
|
108
|
+
: `\npreflight FAILED: ${fails} blocking issue${fails > 1 ? 's' : ''} (see ✗ above).`;
|
|
109
|
+
return ['triscope preflight:', ...lines, summary].join('\n');
|
|
110
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Shared smoke primitives: boot Vite, drive Chromium over CDP, read back via
|
|
2
|
+
// the harness's captureViews() (the only reliable WebGPU path), and an SSIM
|
|
3
|
+
// helper for visual-regression gating. Used by `triscope smoke` and reusable
|
|
4
|
+
// by project/example smokes.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { PNG } from 'pngjs';
|
|
9
|
+
|
|
10
|
+
export const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
|
|
12
|
+
export function readProjectName(cwd) {
|
|
13
|
+
try {
|
|
14
|
+
const p = join(cwd, 'package.json');
|
|
15
|
+
if (!existsSync(p)) return 'triscope-project';
|
|
16
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'));
|
|
17
|
+
return String(pkg.name ?? 'triscope-project').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
18
|
+
} catch {
|
|
19
|
+
return 'triscope-project';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function canFetch(url) {
|
|
24
|
+
try {
|
|
25
|
+
return (await fetch(url)).ok;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function waitForHttp(url, timeoutMs = 15000) {
|
|
32
|
+
const start = Date.now();
|
|
33
|
+
while (Date.now() - start < timeoutMs) {
|
|
34
|
+
if (await canFetch(url)) return true;
|
|
35
|
+
await wait(250);
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function waitForDevtools(port, baseUrl, timeoutMs = 15000) {
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
while (Date.now() - start < timeoutMs) {
|
|
43
|
+
try {
|
|
44
|
+
const pages = await fetch(`http://127.0.0.1:${port}/json`).then((r) => r.json());
|
|
45
|
+
const page = Array.isArray(pages)
|
|
46
|
+
? (pages.find((p) => p.type === 'page' && p.url?.startsWith(baseUrl)) ?? pages[0])
|
|
47
|
+
: null;
|
|
48
|
+
if (page?.webSocketDebuggerUrl) return page;
|
|
49
|
+
} catch {}
|
|
50
|
+
await wait(250);
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`DevTools endpoint not ready on 127.0.0.1:${port}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** CDP client + console/exception error collection. */
|
|
56
|
+
export function cdpFactory() {
|
|
57
|
+
let nextId = 0;
|
|
58
|
+
const pending = new Map();
|
|
59
|
+
const errors = [];
|
|
60
|
+
return {
|
|
61
|
+
errors,
|
|
62
|
+
bind(ws) {
|
|
63
|
+
ws.onmessage = (event) => {
|
|
64
|
+
const msg = JSON.parse(event.data);
|
|
65
|
+
if (msg.id && pending.has(msg.id)) {
|
|
66
|
+
pending.get(msg.id)(msg);
|
|
67
|
+
pending.delete(msg.id);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (msg.method === 'Runtime.exceptionThrown') {
|
|
71
|
+
errors.push(
|
|
72
|
+
msg.params?.exceptionDetails?.exception?.description ??
|
|
73
|
+
msg.params?.exceptionDetails?.text ??
|
|
74
|
+
'exception',
|
|
75
|
+
);
|
|
76
|
+
} else if (msg.method === 'Runtime.consoleAPICalled' && msg.params?.type === 'error') {
|
|
77
|
+
errors.push((msg.params.args ?? []).map((a) => a.value ?? a.description ?? '').join(' '));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
call(ws, method, params = {}) {
|
|
82
|
+
const id = ++nextId;
|
|
83
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
84
|
+
return new Promise((res, rej) => {
|
|
85
|
+
const t = setTimeout(() => {
|
|
86
|
+
pending.delete(id);
|
|
87
|
+
rej(new Error(`CDP timeout: ${method}`));
|
|
88
|
+
}, 20000);
|
|
89
|
+
pending.set(id, (msg) => {
|
|
90
|
+
clearTimeout(t);
|
|
91
|
+
if (msg.error) rej(new Error(`${method}: ${msg.error.message}`));
|
|
92
|
+
else res(msg);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function resolveSmokeLabUrl(baseUrl, element, cwd) {
|
|
100
|
+
if (!element) return baseUrl;
|
|
101
|
+
try {
|
|
102
|
+
const r = await fetch(`${baseUrl}/__manifest`);
|
|
103
|
+
if (r.ok) {
|
|
104
|
+
const entry = (await r.json())?.elements?.[element];
|
|
105
|
+
if (entry?.labUrl) return abs(baseUrl, entry.labUrl);
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
try {
|
|
109
|
+
const p = join(cwd, 'package.json');
|
|
110
|
+
if (existsSync(p)) {
|
|
111
|
+
const m = JSON.parse(readFileSync(p, 'utf8'))?.triscope?.labs;
|
|
112
|
+
if (m?.[element]) return abs(baseUrl, m[element]);
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
return `${baseUrl}/labs/${element}.html`;
|
|
116
|
+
}
|
|
117
|
+
const abs = (base, v) =>
|
|
118
|
+
/^https?:\/\//.test(v) ? v : `${base}${v.startsWith('/') ? '' : '/'}${v}`;
|
|
119
|
+
|
|
120
|
+
export function chromeLaunchArgs({ port, url, profileDir, headless }) {
|
|
121
|
+
const head = headless
|
|
122
|
+
? ['--headless=new', '--use-angle=vulkan', '--enable-features=Vulkan']
|
|
123
|
+
: ['--ozone-platform=x11'];
|
|
124
|
+
return [
|
|
125
|
+
...head,
|
|
126
|
+
'--enable-unsafe-webgpu',
|
|
127
|
+
'--ignore-gpu-blocklist',
|
|
128
|
+
`--user-data-dir=${profileDir}`,
|
|
129
|
+
`--remote-debugging-port=${port}`,
|
|
130
|
+
'--window-size=1600,900',
|
|
131
|
+
url,
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function spawnVite(cwd, port) {
|
|
136
|
+
// --host 127.0.0.1: bind the IPv4 address the smoke polls/attaches on (Vite 5
|
|
137
|
+
// otherwise binds localhost/::1 and a 127.0.0.1 fetch can be refused).
|
|
138
|
+
return spawn(
|
|
139
|
+
'npx',
|
|
140
|
+
['--no-install', 'vite', '--port', String(port), '--strictPort', '--host', '127.0.0.1'],
|
|
141
|
+
{
|
|
142
|
+
cwd,
|
|
143
|
+
env: { ...process.env, BROWSER: 'none' },
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
detached: true,
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function decodePng(buf) {
|
|
151
|
+
return PNG.sync.read(buf);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Mean SSIM over Rec.709 luminance, 256×256 downsample, 8×8 windows.
|
|
155
|
+
* Self-contained mirror of @triscope/core's ssim() for the regression gate. */
|
|
156
|
+
export function ssim(aBuf, bBuf) {
|
|
157
|
+
const A = resize256(decodePng(aBuf));
|
|
158
|
+
const B = resize256(decodePng(bBuf));
|
|
159
|
+
const W = 256;
|
|
160
|
+
const H = 256;
|
|
161
|
+
const lA = luma(A);
|
|
162
|
+
const lB = luma(B);
|
|
163
|
+
const L = 255;
|
|
164
|
+
const C1 = (0.01 * L) ** 2;
|
|
165
|
+
const C2 = (0.03 * L) ** 2;
|
|
166
|
+
const WIN = 8;
|
|
167
|
+
let total = 0;
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (let wy = 0; wy < H; wy += WIN) {
|
|
170
|
+
for (let wx = 0; wx < W; wx += WIN) {
|
|
171
|
+
let muA = 0;
|
|
172
|
+
let muB = 0;
|
|
173
|
+
for (let dy = 0; dy < WIN; dy++)
|
|
174
|
+
for (let dx = 0; dx < WIN; dx++) {
|
|
175
|
+
const i = (wy + dy) * W + (wx + dx);
|
|
176
|
+
muA += lA[i];
|
|
177
|
+
muB += lB[i];
|
|
178
|
+
}
|
|
179
|
+
muA /= WIN * WIN;
|
|
180
|
+
muB /= WIN * WIN;
|
|
181
|
+
let vA = 0;
|
|
182
|
+
let vB = 0;
|
|
183
|
+
let cov = 0;
|
|
184
|
+
for (let dy = 0; dy < WIN; dy++)
|
|
185
|
+
for (let dx = 0; dx < WIN; dx++) {
|
|
186
|
+
const i = (wy + dy) * W + (wx + dx);
|
|
187
|
+
const da = lA[i] - muA;
|
|
188
|
+
const db = lB[i] - muB;
|
|
189
|
+
vA += da * da;
|
|
190
|
+
vB += db * db;
|
|
191
|
+
cov += da * db;
|
|
192
|
+
}
|
|
193
|
+
vA /= WIN * WIN - 1;
|
|
194
|
+
vB /= WIN * WIN - 1;
|
|
195
|
+
cov /= WIN * WIN - 1;
|
|
196
|
+
total +=
|
|
197
|
+
((2 * muA * muB + C1) * (2 * cov + C2)) / ((muA * muA + muB * muB + C1) * (vA + vB + C2));
|
|
198
|
+
count++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return +(total / count).toFixed(4);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resize256(src) {
|
|
205
|
+
const W = 256;
|
|
206
|
+
const H = 256;
|
|
207
|
+
if (src.width === W && src.height === H) return src;
|
|
208
|
+
const dst = new PNG({ width: W, height: H });
|
|
209
|
+
for (let y = 0; y < H; y++) {
|
|
210
|
+
const sy = Math.min(src.height - 1, Math.floor((y * src.height) / H));
|
|
211
|
+
for (let x = 0; x < W; x++) {
|
|
212
|
+
const sx = Math.min(src.width - 1, Math.floor((x * src.width) / W));
|
|
213
|
+
const si = (sy * src.width + sx) * 4;
|
|
214
|
+
const di = (y * W + x) * 4;
|
|
215
|
+
dst.data[di] = src.data[si];
|
|
216
|
+
dst.data[di + 1] = src.data[si + 1];
|
|
217
|
+
dst.data[di + 2] = src.data[si + 2];
|
|
218
|
+
dst.data[di + 3] = 255;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return dst;
|
|
222
|
+
}
|
|
223
|
+
function luma(png) {
|
|
224
|
+
const out = new Float32Array(png.width * png.height);
|
|
225
|
+
for (let i = 0; i < out.length; i++) {
|
|
226
|
+
const j = i * 4;
|
|
227
|
+
out[i] = 0.2126 * png.data[j] + 0.7152 * png.data[j + 1] + 0.0722 * png.data[j + 2];
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|