@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/smoke.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// `triscope smoke [<element>]` — headed-Chromium smoke test against a lab page.
|
|
2
|
+
//
|
|
3
|
+
// Boots Vite (if not already up), drives Chromium with WebGPU via CDP, and reads
|
|
4
|
+
// back through the harness's captureViews() — the ONLY reliable WebGPU readback
|
|
5
|
+
// path (Page.captureScreenshot misses the swap-chain surface, gpuweb#1781).
|
|
6
|
+
// Asserts: harness mounts, every camera renders, none black, fps stepping, no
|
|
7
|
+
// console/runtime errors. Headed by default; SMOKE_HEADLESS=1 for CI/xvfb.
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
canFetch,
|
|
14
|
+
cdpFactory,
|
|
15
|
+
chromeLaunchArgs,
|
|
16
|
+
readProjectName,
|
|
17
|
+
resolveSmokeLabUrl,
|
|
18
|
+
spawnVite,
|
|
19
|
+
wait,
|
|
20
|
+
waitForDevtools,
|
|
21
|
+
waitForHttp,
|
|
22
|
+
} from './smoke-lib.mjs';
|
|
23
|
+
|
|
24
|
+
const BLACK = 0.004;
|
|
25
|
+
|
|
26
|
+
export async function runSmoke({ element, url, screenshot } = {}) {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const project = readProjectName(cwd);
|
|
29
|
+
const baseUrl = (url ?? 'http://localhost:5173/').replace(/\/$/, '');
|
|
30
|
+
const targetUrl = url ? url : await resolveSmokeLabUrl(baseUrl, element, cwd);
|
|
31
|
+
const OUT = screenshot ?? join(tmpdir(), `${project}-smoke-${element ?? 'default'}.png`);
|
|
32
|
+
const PORT = Number(process.env.TRISCOPE_DEBUG_PORT ?? 9230);
|
|
33
|
+
const CHROME = process.env.CHROME_BIN ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? 'chromium';
|
|
34
|
+
const headless = !!process.env.SMOKE_HEADLESS;
|
|
35
|
+
|
|
36
|
+
let vite = null;
|
|
37
|
+
let chrome = null;
|
|
38
|
+
let ws = null;
|
|
39
|
+
let exitCode = 0;
|
|
40
|
+
const cdp = cdpFactory();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (!(await canFetch(baseUrl))) {
|
|
44
|
+
vite = spawnVite(cwd, new URL(baseUrl).port || 5173);
|
|
45
|
+
if (!(await waitForHttp(baseUrl, 15000)))
|
|
46
|
+
throw new Error(`Vite did not respond at ${baseUrl}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const profileDir = join(tmpdir(), `triscope-smoke-${Date.now()}`);
|
|
50
|
+
chrome = spawn(CHROME, chromeLaunchArgs({ port: PORT, url: targetUrl, profileDir, headless }), {
|
|
51
|
+
stdio: 'ignore',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const page = await waitForDevtools(PORT, baseUrl, 15000);
|
|
55
|
+
const WSCtor = globalThis.WebSocket ?? (await import('ws')).WebSocket;
|
|
56
|
+
ws = new WSCtor(page.webSocketDebuggerUrl);
|
|
57
|
+
await new Promise((res, rej) => {
|
|
58
|
+
ws.onopen = res;
|
|
59
|
+
ws.onerror = (e) => rej(new Error(`ws open failed: ${e?.message ?? e}`));
|
|
60
|
+
});
|
|
61
|
+
cdp.bind(ws);
|
|
62
|
+
await cdp.call(ws, 'Runtime.enable');
|
|
63
|
+
|
|
64
|
+
let mounted = false;
|
|
65
|
+
for (let i = 0; i < 60; i++) {
|
|
66
|
+
const p = await cdp.call(ws, 'Runtime.evaluate', {
|
|
67
|
+
expression: '!!(window.__TRISCOPE__ && window.__TRISCOPE__.captureViews)',
|
|
68
|
+
returnByValue: true,
|
|
69
|
+
});
|
|
70
|
+
if (p.result.result.value) {
|
|
71
|
+
mounted = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
await wait(500);
|
|
75
|
+
}
|
|
76
|
+
if (!mounted) {
|
|
77
|
+
const boot = await cdp.call(ws, 'Runtime.evaluate', {
|
|
78
|
+
expression: 'document.getElementById("boot")?.textContent ?? ""',
|
|
79
|
+
returnByValue: true,
|
|
80
|
+
});
|
|
81
|
+
throw new Error(
|
|
82
|
+
`window.__TRISCOPE__ did not mount. boot="${boot.result.result.value}" errors=${JSON.stringify(cdp.errors.slice(0, 3))}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Warm up: let the RAF loop ramp before measuring fps / capturing (a fresh
|
|
87
|
+
// page reads ~1 fps until the WebGPU pipeline settles). Poll up to 6s.
|
|
88
|
+
let fps = 0;
|
|
89
|
+
{
|
|
90
|
+
const t0 = Date.now();
|
|
91
|
+
while (Date.now() - t0 < 6000) {
|
|
92
|
+
const r = await cdp.call(ws, 'Runtime.evaluate', {
|
|
93
|
+
expression: '(window.__TRISCOPE__.sampleTelemetry().perf||{}).fps || 0',
|
|
94
|
+
returnByValue: true,
|
|
95
|
+
});
|
|
96
|
+
fps = Math.max(fps, Number(r.result.result.value ?? 0));
|
|
97
|
+
if (fps >= 5) break;
|
|
98
|
+
await wait(300);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const capR = await cdp.call(ws, 'Runtime.evaluate', {
|
|
103
|
+
expression: 'window.__TRISCOPE__.captureViews()',
|
|
104
|
+
awaitPromise: true,
|
|
105
|
+
returnByValue: true,
|
|
106
|
+
});
|
|
107
|
+
const views = capR.result.result.value ?? {};
|
|
108
|
+
const cams = Object.keys(views);
|
|
109
|
+
if (cams.length === 0) throw new Error('captureViews returned no cameras');
|
|
110
|
+
mkdirSync(dirname(OUT), { recursive: true });
|
|
111
|
+
let wroteShot = false;
|
|
112
|
+
let fullSize = 0;
|
|
113
|
+
for (const c of cams) {
|
|
114
|
+
const du = views[c];
|
|
115
|
+
if (typeof du !== 'string' || !du.startsWith('data:image/png;base64,')) continue;
|
|
116
|
+
const buf = Buffer.from(du.slice('data:image/png;base64,'.length), 'base64');
|
|
117
|
+
if (!wroteShot) {
|
|
118
|
+
writeFileSync(OUT, buf);
|
|
119
|
+
wroteShot = true;
|
|
120
|
+
}
|
|
121
|
+
if (buf.readUInt32BE(16) >= 200 && buf.readUInt32BE(20) >= 100) fullSize++;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const probesR = await cdp.call(ws, 'Runtime.evaluate', {
|
|
125
|
+
expression: 'JSON.stringify(window.__TRISCOPE__.lastGpuProbes ?? {})',
|
|
126
|
+
returnByValue: true,
|
|
127
|
+
});
|
|
128
|
+
const gpuProbes = JSON.parse(probesR.result.result.value || '{}');
|
|
129
|
+
const black = Object.entries(gpuProbes)
|
|
130
|
+
.filter(([, s]) => s?.blackFrame || s?.luminance < BLACK)
|
|
131
|
+
.map(([c]) => c);
|
|
132
|
+
|
|
133
|
+
// The teardown "WebGPU Device Lost: ... destroyed" is cosmetic (documented
|
|
134
|
+
// in the README) and favicon 404s are noise — never fail on those.
|
|
135
|
+
const COSMETIC = /Device Lost|Device was destroyed|favicon\.ico/i;
|
|
136
|
+
const realErrors = cdp.errors.filter((e) => !COSMETIC.test(e));
|
|
137
|
+
|
|
138
|
+
console.log(
|
|
139
|
+
JSON.stringify(
|
|
140
|
+
{
|
|
141
|
+
url: targetUrl,
|
|
142
|
+
screenshot: OUT,
|
|
143
|
+
cameras: cams.length,
|
|
144
|
+
fullSize,
|
|
145
|
+
fps: +fps.toFixed(1),
|
|
146
|
+
blackFrames: black,
|
|
147
|
+
errors: realErrors.slice(0, 5),
|
|
148
|
+
headless,
|
|
149
|
+
},
|
|
150
|
+
null,
|
|
151
|
+
2,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Always: the harness mounted, declared cameras, and captureViews returned
|
|
156
|
+
// (asserted above) with no REAL console/runtime errors. Render-quality
|
|
157
|
+
// (non-black) is hard-gated only when HEADED — headless WebGPU readback is
|
|
158
|
+
// unreliable on Linux (black frames), per the project's own caveat.
|
|
159
|
+
if (realErrors.length > 0)
|
|
160
|
+
throw new Error(`console/runtime errors: ${realErrors.join(' | ').slice(0, 300)}`);
|
|
161
|
+
if (!headless && black.length > 0)
|
|
162
|
+
throw new Error(`camera(s) rendered black: ${black.join(', ')}`);
|
|
163
|
+
if (fps !== 0 && fps < 5) throw new Error(`fps ${fps} < 5 — renderer not stepping`);
|
|
164
|
+
|
|
165
|
+
await cdp.call(ws, 'Browser.close').catch(() => {});
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('smoke failed:', err.message);
|
|
168
|
+
exitCode = 1;
|
|
169
|
+
} finally {
|
|
170
|
+
try {
|
|
171
|
+
ws?.close();
|
|
172
|
+
} catch {}
|
|
173
|
+
if (chrome && !chrome.killed) chrome.kill();
|
|
174
|
+
if (vite && !vite.killed) {
|
|
175
|
+
try {
|
|
176
|
+
process.kill(-vite.pid, 'SIGTERM');
|
|
177
|
+
} catch {}
|
|
178
|
+
vite.kill('SIGTERM');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
process.exit(exitCode);
|
|
182
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `triscope state [<jq-style path>]` — read /tmp/<project>-state.json.
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function readProjectName(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
const pkgPath = join(cwd, 'package.json');
|
|
9
|
+
if (!existsSync(pkgPath)) return 'triscope-project';
|
|
10
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
11
|
+
return String(pkg.name ?? 'triscope-project').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
12
|
+
} catch {
|
|
13
|
+
return 'triscope-project';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function applyPath(data, path) {
|
|
18
|
+
if (!path) return data;
|
|
19
|
+
const segs = path.replace(/^\./, '').split('.').filter(Boolean);
|
|
20
|
+
let cur = data;
|
|
21
|
+
for (const s of segs) {
|
|
22
|
+
if (cur == null) return undefined;
|
|
23
|
+
cur = cur[s];
|
|
24
|
+
}
|
|
25
|
+
return cur;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runState({ path }) {
|
|
29
|
+
const project = readProjectName(process.cwd());
|
|
30
|
+
const statePath = join(tmpdir(), `${project}-state.json`);
|
|
31
|
+
if (!existsSync(statePath)) {
|
|
32
|
+
console.error(`No telemetry found at ${statePath}. Is the dev server running?`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const data = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
36
|
+
const slice = applyPath(data, path);
|
|
37
|
+
if (slice === undefined) {
|
|
38
|
+
console.error(`Path "${path}" not found in telemetry.`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
if (slice === null || typeof slice !== 'object') {
|
|
42
|
+
console.log(slice);
|
|
43
|
+
} else {
|
|
44
|
+
console.log(JSON.stringify(slice, null, 2));
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/wizard.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `triscope init --quick` post-scaffold setup wizard.
|
|
2
|
+
//
|
|
3
|
+
// The cardinal rule: NEVER block on stdin in a non-interactive context. A
|
|
4
|
+
// readline prompt in CI / a piped invocation would hang forever, so we resolve
|
|
5
|
+
// the mode up front and only prompt on a real TTY.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
/** off | interactive | auto | skip — decided before any prompt is shown. */
|
|
12
|
+
export function resolveQuickMode({ quick, yes, isTTY }) {
|
|
13
|
+
if (!quick) return 'off';
|
|
14
|
+
if (yes) return 'auto';
|
|
15
|
+
if (isTTY) return 'interactive';
|
|
16
|
+
return 'skip'; // non-TTY without --yes: do not prompt, do not hang
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Safe, non-browser setup steps. (smoke/open need a browser → left to the user.) */
|
|
20
|
+
export function quickSteps() {
|
|
21
|
+
return [
|
|
22
|
+
{ id: 'install', label: 'Install dependencies (npm install)', default: true },
|
|
23
|
+
{
|
|
24
|
+
id: 'mcp',
|
|
25
|
+
label: 'Register the triscope MCP server + auto-capture hook (project scope)',
|
|
26
|
+
default: true,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ask(rl, question, defaultYes) {
|
|
32
|
+
const suffix = defaultYes ? '[Y/n]' : '[y/N]';
|
|
33
|
+
return new Promise((res) => {
|
|
34
|
+
rl.question(` ${question} ${suffix} `, (answer) => {
|
|
35
|
+
const a = answer.trim().toLowerCase();
|
|
36
|
+
if (a === '') res(defaultYes);
|
|
37
|
+
else res(a === 'y' || a === 'yes');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function spawnAndWait(cmd, args, opts = {}) {
|
|
43
|
+
return new Promise((res, rej) => {
|
|
44
|
+
const p = spawn(cmd, args, { stdio: 'inherit', ...opts });
|
|
45
|
+
p.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}`))));
|
|
46
|
+
p.on('error', rej);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nextStepsMessage() {
|
|
51
|
+
// The base scaffolder already printed the cd/install/dev steps; add only the
|
|
52
|
+
// quick-setup-specific pointer so we don't duplicate the whole block.
|
|
53
|
+
return [
|
|
54
|
+
'',
|
|
55
|
+
'Tip: run setup non-interactively with: triscope init <dir> --quick --yes',
|
|
56
|
+
' (installs deps + registers the MCP server + auto-capture hook)',
|
|
57
|
+
' then `triscope dev --check` to verify.',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the quick-setup steps. `mode` comes from resolveQuickMode(); in 'skip'
|
|
63
|
+
* we only print guidance. The cli bin path is resolved so we can invoke
|
|
64
|
+
* `triscope mcp install` even when triscope isn't on PATH.
|
|
65
|
+
*/
|
|
66
|
+
export async function runQuickSetup({ target, mode }) {
|
|
67
|
+
if (mode === 'skip' || mode === 'off') {
|
|
68
|
+
console.log(nextStepsMessage());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const steps = quickSteps();
|
|
72
|
+
let decisions;
|
|
73
|
+
if (mode === 'auto') {
|
|
74
|
+
decisions = Object.fromEntries(steps.map((s) => [s.id, s.default]));
|
|
75
|
+
} else {
|
|
76
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
77
|
+
decisions = {};
|
|
78
|
+
try {
|
|
79
|
+
for (const s of steps) decisions[s.id] = await ask(rl, `${s.label}?`, s.default);
|
|
80
|
+
} finally {
|
|
81
|
+
rl.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (decisions.install) {
|
|
86
|
+
console.log('\n→ npm install…');
|
|
87
|
+
await spawnAndWait('npm', ['install'], { cwd: target });
|
|
88
|
+
}
|
|
89
|
+
if (decisions.mcp) {
|
|
90
|
+
const cliBin = resolve(fileURLToPath(import.meta.url), '../../bin/triscope.mjs');
|
|
91
|
+
console.log('\n→ triscope mcp install --project…');
|
|
92
|
+
try {
|
|
93
|
+
await spawnAndWait(process.execPath, [cliBin, 'mcp', 'install', '--project'], {
|
|
94
|
+
cwd: target,
|
|
95
|
+
});
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.warn(` (mcp install skipped: ${e?.message ?? e})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
console.log(`\n✓ ${target} ready. Run \`npm run dev\` then \`triscope dev --check\`.`);
|
|
101
|
+
}
|