@pro-vi/designer 0.3.10 → 0.3.11

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.
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env -S node --import tsx
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { DesignerController } from "../designer-controller.js";
5
+ import { CdpTraceRecorder } from "../cdp-trace.js";
6
+ import { artifactsRoot } from "../artifact-store.js";
7
+ import { getSession } from "../session-store.js";
8
+ const USAGE = `Usage:
9
+ trace-spike.ts quota [--seconds 60] capture quota banner + short idle trace
10
+ trace-spike.ts idle [--minutes 3] baseline noise trace
11
+ trace-spike.ts success "<prompt>" [--key K] [--name N] [--fidelity highfi|wireframe] [--decisive] [--sample-ms 1500]
12
+ trace-spike.ts noop ["<prompt>"] [--key K] chat-only prompt (expects no file change)
13
+ trace-spike.ts watch [--key K] record until Ctrl-C (opportunistic capture)
14
+
15
+ Traces land in artifacts/trace/<scenario>-<ts>/{trace.jsonl,manifest.json}.`;
16
+ function parseArgv(argv) {
17
+ const positional = [];
18
+ const flags = {};
19
+ for (let i = 0; i < argv.length; i++) {
20
+ const a = argv[i];
21
+ if (!a)
22
+ continue;
23
+ if (a.startsWith('--')) {
24
+ const name = a.slice(2);
25
+ const next = argv[i + 1];
26
+ if (next !== undefined && !next.startsWith('--')) {
27
+ flags[name] = next;
28
+ i++;
29
+ }
30
+ else {
31
+ flags[name] = true;
32
+ }
33
+ }
34
+ else {
35
+ positional.push(a);
36
+ }
37
+ }
38
+ return { positional, flags };
39
+ }
40
+ function escapeRegExp(s) {
41
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ }
43
+ const NOOP_PROMPT = 'Answer in chat only — do not create, modify, or delete any files: briefly describe what the current design does.';
44
+ function buildSampleJs(c) {
45
+ const sel = c.selectors;
46
+ return `(() => {
47
+ const q = (s) => { try { return document.querySelector(s); } catch { return null; } };
48
+ const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
49
+ const composer = q(${JSON.stringify(sel.composer.promptTextarea)});
50
+ const send = q(${JSON.stringify(sel.composer.sendButton)});
51
+ const iframe = q(${JSON.stringify(sel.preview.iframeOrContainer)});
52
+ const msgs = q(${JSON.stringify(sel.messages.chatMessagesContainer)});
53
+ let chatTurnCount = 0; let lastTurnRole = null;
54
+ if (msgs) {
55
+ const turns = msgs.querySelectorAll('[data-index]');
56
+ chatTurnCount = turns.length;
57
+ const last = turns[turns.length - 1];
58
+ if (last) {
59
+ const t = (last.innerText || '').trim();
60
+ lastTurnRole = t.startsWith('Claude') ? 'assistant' : t.startsWith('You') ? 'user' : 'unknown';
61
+ }
62
+ }
63
+ // selectors.json has composer.stopButton: null — probe generically so the
64
+ // trace doubles as selector discovery for the real stop button.
65
+ let stopProbe = null;
66
+ for (const b of Array.from(document.querySelectorAll('button'))) {
67
+ const label = ((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '')).trim();
68
+ if (/\\b(stop|cancel)\\b/i.test(label) && vis(b)) {
69
+ stopProbe = {
70
+ label: label.slice(0, 80),
71
+ testid: b.getAttribute('data-testid'),
72
+ outerHTML: b.outerHTML.slice(0, 400)
73
+ };
74
+ break;
75
+ }
76
+ }
77
+ return {
78
+ url: location.href,
79
+ iframeSrc: iframe && iframe.src ? iframe.src : null,
80
+ composerVisible: vis(composer),
81
+ sendVisible: vis(send),
82
+ sendDisabled: send ? (send.disabled === true || send.getAttribute('aria-disabled') === 'true') : null,
83
+ chatTurnCount,
84
+ lastTurnRole,
85
+ stopProbe
86
+ };
87
+ })()`;
88
+ }
89
+ const QUOTA_BANNER_JS = `(() => {
90
+ // Find the smallest element whose text mentions a percentage AND
91
+ // weekly-limit language — that's the usage banner.
92
+ const all = Array.from(document.querySelectorAll('div, section, aside'));
93
+ let best = null;
94
+ for (const el of all) {
95
+ const t = (el.innerText || '').trim();
96
+ if (!t || t.length > 600) continue;
97
+ if (!/\\d+\\s*%/.test(t)) continue;
98
+ if (!/week|usage|limit|resets/i.test(t)) continue;
99
+ if (!best || t.length < (best.innerText || '').trim().length) best = el;
100
+ }
101
+ if (!best) return { found: false, text: null, outerHTML: null };
102
+ return { found: true, text: (best.innerText || '').trim(), outerHTML: best.outerHTML.slice(0, 8000) };
103
+ })()`;
104
+ function sleep(ms) {
105
+ return new Promise((r) => setTimeout(r, ms));
106
+ }
107
+ async function main() {
108
+ const { positional, flags } = parseArgv(process.argv.slice(2));
109
+ const scenario = positional[0] || '';
110
+ if (!['quota', 'idle', 'success', 'noop', 'watch'].includes(scenario)) {
111
+ console.log(USAGE);
112
+ process.exit(scenario ? 1 : 0);
113
+ }
114
+ const key = String(flags.key || 'trace-spike');
115
+ const sampleMs = Number(flags['sample-ms'] || (scenario === 'idle' || scenario === 'watch' ? 5000 : 1500));
116
+ const controller = new DesignerController({ key });
117
+ const ready = await controller.ensureReady();
118
+ console.log(`ready: ${ready.url}`);
119
+ let prompt = null;
120
+ if (scenario === 'success' || scenario === 'noop') {
121
+ prompt = positional[1] || (scenario === 'noop' ? NOOP_PROMPT : null);
122
+ if (!prompt) {
123
+ console.error('success requires a prompt argument');
124
+ process.exit(1);
125
+ }
126
+ const stored = getSession(key);
127
+ if (stored?.designUrl) {
128
+ await controller.resumeSession();
129
+ }
130
+ else if (scenario === 'noop') {
131
+ console.error(`No stored session for key=${key} — run a success scenario first so noop has a design to ask about.`);
132
+ process.exit(1);
133
+ }
134
+ else {
135
+ const name = String(flags.name || 'aurora-trace');
136
+ const fidelity = flags.fidelity === 'wireframe' ? 'wireframe' : 'highfi';
137
+ const created = await controller.createSession(name, fidelity);
138
+ console.log(`created session: ${created.url}`);
139
+ }
140
+ }
141
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
142
+ const outDir = path.join(artifactsRoot(), 'trace', `${scenario}-${stamp}`);
143
+ fs.mkdirSync(outDir, { recursive: true });
144
+ const targetUrlFlag = typeof flags['target-url'] === 'string' ? String(flags['target-url']) : null;
145
+ const recorder = await CdpTraceRecorder.attach({
146
+ outFile: path.join(outDir, 'trace.jsonl'),
147
+ preferUrlPrefix: getSession(key)?.designUrl?.split('?')[0] || null,
148
+ ...(targetUrlFlag ? { urlPattern: new RegExp('^' + escapeRegExp(targetUrlFlag)) } : {})
149
+ });
150
+ await recorder.start();
151
+ console.log(`recording → ${outDir}`);
152
+ const manifest = {
153
+ scenario,
154
+ key,
155
+ prompt,
156
+ startedAt: new Date().toISOString(),
157
+ endedAt: null,
158
+ aborted: false,
159
+ node: process.version,
160
+ cdp: recorder.targetInfo(),
161
+ iterate: null,
162
+ quota: null,
163
+ summary: null
164
+ };
165
+ const sampleJs = buildSampleJs(controller);
166
+ let samplerInFlight = false;
167
+ const sampler = setInterval(() => {
168
+ if (samplerInFlight)
169
+ return;
170
+ samplerInFlight = true;
171
+ controller.browser
172
+ .evalValue(sampleJs)
173
+ .then((sample) => recorder.record({ ts: Date.now(), kind: 'dom-sample', sample }))
174
+ .catch(() => null)
175
+ .finally(() => {
176
+ samplerInFlight = false;
177
+ });
178
+ }, sampleMs);
179
+ let finished = false;
180
+ const finalize = async (aborted) => {
181
+ if (finished)
182
+ return;
183
+ finished = true;
184
+ clearInterval(sampler);
185
+ manifest.aborted = aborted;
186
+ manifest.endedAt = new Date().toISOString();
187
+ manifest.summary = await recorder.stop();
188
+ fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
189
+ console.log(`\ntrace: ${path.join(outDir, 'trace.jsonl')}`);
190
+ console.log(`manifest: ${path.join(outDir, 'manifest.json')}`);
191
+ console.log(`events: ${manifest.summary.total} | bodies: ${manifest.summary.bodyCaptures} | reconnects: ${manifest.summary.reconnects}`);
192
+ };
193
+ process.on('SIGINT', () => {
194
+ void finalize(true).then(() => process.exit(130));
195
+ });
196
+ try {
197
+ if (scenario === 'quota') {
198
+ const screenshotPath = path.join(outDir, 'quota-banner.png');
199
+ await controller.browser.screenshot(screenshotPath, { full: true }).catch((e) => {
200
+ console.warn(`screenshot failed: ${e.message}`);
201
+ return '';
202
+ });
203
+ const banner = await controller.browser
204
+ .evalValue(QUOTA_BANNER_JS)
205
+ .catch(() => ({ found: false, text: null, outerHTML: null }));
206
+ let bannerHtmlPath = null;
207
+ if (banner.found && banner.outerHTML) {
208
+ bannerHtmlPath = path.join(outDir, 'quota-banner.html');
209
+ fs.writeFileSync(bannerHtmlPath, banner.outerHTML);
210
+ }
211
+ manifest.quota = {
212
+ bannerText: banner.text,
213
+ bannerHtmlPath,
214
+ screenshotPath: fs.existsSync(screenshotPath) ? screenshotPath : null
215
+ };
216
+ console.log(banner.found ? `banner: ${banner.text}` : 'banner: NOT FOUND (see screenshot)');
217
+ const seconds = Number(flags.seconds || 60);
218
+ recorder.marker('quota-idle-start', { seconds });
219
+ await sleep(seconds * 1000);
220
+ recorder.marker('quota-idle-end');
221
+ }
222
+ else if (scenario === 'idle') {
223
+ const minutes = Number(flags.minutes || 3);
224
+ recorder.marker('idle-start', { minutes });
225
+ await sleep(minutes * 60_000);
226
+ recorder.marker('idle-end');
227
+ }
228
+ else if (scenario === 'success' || scenario === 'noop') {
229
+ recorder.marker('iterate-start', { prompt, decisive: flags.decisive === true });
230
+ const result = await controller.iterate(prompt, { decisive: flags.decisive === true });
231
+ recorder.marker('iterate-done', {
232
+ failureMode: result.done.failureMode,
233
+ elapsedMs: result.done.elapsedMs,
234
+ changed: result.changed,
235
+ newFiles: result.newFiles
236
+ });
237
+ manifest.iterate = {
238
+ failureMode: result.done.failureMode,
239
+ ok: result.done.ok,
240
+ elapsedMs: result.done.elapsedMs,
241
+ changed: result.changed,
242
+ newFiles: result.newFiles,
243
+ removedFiles: result.removedFiles,
244
+ activeFile: result.activeFile,
245
+ htmlBytes: result.htmlBytes,
246
+ chatReplyBytes: result.chatReply ? result.chatReply.length : 0
247
+ };
248
+ console.log(`iterate: ok=${result.done.ok} failureMode=${result.done.failureMode} elapsed=${Math.round(result.done.elapsedMs / 1000)}s newFiles=[${result.newFiles.join(', ')}]`);
249
+ const banner = await controller.browser
250
+ .evalValue(QUOTA_BANNER_JS)
251
+ .catch(() => ({ found: false, text: null, outerHTML: null }));
252
+ if (banner.found && banner.outerHTML) {
253
+ const bannerHtmlPath = path.join(outDir, 'quota-banner.html');
254
+ fs.writeFileSync(bannerHtmlPath, banner.outerHTML);
255
+ manifest.quota = { bannerText: banner.text, bannerHtmlPath, screenshotPath: null };
256
+ console.log(`quota banner captured: ${banner.text}`);
257
+ }
258
+ await sleep(5000);
259
+ }
260
+ else if (scenario === 'watch') {
261
+ recorder.marker('watch-start');
262
+ console.log('watching — Ctrl-C to stop');
263
+ await new Promise(() => {
264
+ });
265
+ }
266
+ }
267
+ finally {
268
+ await finalize(false);
269
+ }
270
+ }
271
+ main().catch(async (e) => {
272
+ console.error(e.message);
273
+ process.exit(1);
274
+ });
@@ -1,3 +1,4 @@
1
+ import { RunStateObserver } from "./run-state.js";
1
2
  async function hasSelector(browser, sel) {
2
3
  return !!(await browser
3
4
  .evalValue(`!!document.querySelector(${JSON.stringify(sel)})`)
@@ -8,6 +9,92 @@ async function hasButtonMatching(browser, pattern) {
8
9
  .evalValue(`(() => { const re = new RegExp(${JSON.stringify(pattern.source)}, ${JSON.stringify(pattern.flags)}); return Array.from(document.querySelectorAll('button')).some(b => re.test((b.textContent || '').trim())); })()`)
9
10
  .catch(() => false));
10
11
  }
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ async function submitTurnRpcCanary(browser) {
16
+ const prompt = 'Health check: answer in chat only with the single word ok. Do not create, modify, or delete files.';
17
+ const filled = await browser
18
+ .evalValue(`(() => {
19
+ const el = document.querySelector('[data-testid="chat-composer-input"]');
20
+ if (!el) return false;
21
+ const text = ${JSON.stringify(prompt)};
22
+ if (el instanceof HTMLTextAreaElement) {
23
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
24
+ setter.call(el, text);
25
+ el.dispatchEvent(new Event('input', { bubbles: true }));
26
+ el.focus();
27
+ return true;
28
+ }
29
+ if (el.isContentEditable) {
30
+ el.focus();
31
+ const sel = window.getSelection();
32
+ const range = document.createRange();
33
+ range.selectNodeContents(el);
34
+ sel.removeAllRanges();
35
+ sel.addRange(range);
36
+ const dt = new DataTransfer();
37
+ dt.setData('text/plain', text);
38
+ const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
39
+ if (unhandled) document.execCommand('insertText', false, text);
40
+ return true;
41
+ }
42
+ return false;
43
+ })()`)
44
+ .catch(() => false);
45
+ if (!filled)
46
+ return { ok: false, detail: 'composer not fillable for canary prompt' };
47
+ for (let i = 0; i < 30; i++) {
48
+ const disabled = await browser
49
+ .evalValue(`(() => {
50
+ const b = document.querySelector('[data-testid="chat-send-button"], button[title^="Send ("]');
51
+ return !b || b.disabled || b.getAttribute('aria-disabled') === 'true';
52
+ })()`)
53
+ .catch(() => true);
54
+ if (!disabled)
55
+ break;
56
+ await sleep(150);
57
+ }
58
+ const clicked = await browser
59
+ .evalValue(`(() => {
60
+ const b = document.querySelector('[data-testid="chat-send-button"], button[title^="Send ("]');
61
+ if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
62
+ b.click();
63
+ return true;
64
+ })()`)
65
+ .catch(() => false);
66
+ return clicked ? { ok: true } : { ok: false, detail: 'send button unavailable for canary prompt' };
67
+ }
68
+ async function checkTurnRpcContract(_browser, currentUrl) {
69
+ if (process.env.DESIGNER_TURN_RPC_CANARY !== '1') {
70
+ return { ok: true, status: 'skip', detail: 'turn-RPC canary disabled (DESIGNER_TURN_RPC_CANARY!=1)' };
71
+ }
72
+ if ((process.env.DESIGNER_CDP ?? '9222') === '') {
73
+ return { ok: true, status: 'skip', detail: "CDP disabled (DESIGNER_CDP=''); turn-RPC canary not probed" };
74
+ }
75
+ const observer = await RunStateObserver.attach({ preferUrlPrefix: currentUrl.split('?')[0] || null });
76
+ if (!observer) {
77
+ return { ok: true, status: 'skip', detail: 'CDP observer unavailable; turn-RPC canary not probed' };
78
+ }
79
+ try {
80
+ observer.beginRun();
81
+ const submitted = await submitTurnRpcCanary(_browser);
82
+ if (!submitted.ok)
83
+ return { ok: true, status: 'skip', detail: submitted.detail };
84
+ const terminal = await observer.awaitTerminal({ stallMs: 25_000, hardTimeoutMs: 75_000 });
85
+ const summary = observer.signalSummary();
86
+ const detail = `heartbeat x${summary.heartbeat}, release ${summary.release > 0 ? 'seen' : 'missing'}, ` +
87
+ `chat x${summary.chatOpen}, chunks x${summary.chatChunk}, terminal=${terminal.terminal}` +
88
+ (summary.observedRpcPaths.length ? `, observed=[${summary.observedRpcPaths.join(', ')}]` : ', observed=[]');
89
+ return {
90
+ ok: terminal.terminal === 'finished' && summary.release > 0 && summary.chatOpen > 0,
91
+ detail
92
+ };
93
+ }
94
+ finally {
95
+ observer.close();
96
+ }
97
+ }
11
98
  export const UI_ANCHORS = [
12
99
  {
13
100
  id: 'login.signedIn',
@@ -134,6 +221,13 @@ export const UI_ANCHORS = [
134
221
  requires: 'session',
135
222
  check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-messages"]') })
136
223
  },
224
+ {
225
+ id: 'network.turnRpcContract',
226
+ category: 'pattern',
227
+ description: 'OmeletteService Chat/RenewTurn/ReleaseTurn network contract',
228
+ requires: 'session',
229
+ check: checkTurnRpcContract
230
+ },
137
231
  {
138
232
  id: 'session.iframeSrcPattern',
139
233
  category: 'pattern',
@@ -272,7 +366,7 @@ export async function runHealth(browser, opts = {}) {
272
366
  };
273
367
  try {
274
368
  const r = await a.check(browser, currentUrl);
275
- results.push({ ...base, status: r.ok ? 'ok' : 'fail', detail: r.detail });
369
+ results.push({ ...base, status: r.status ?? (r.ok ? 'ok' : 'fail'), detail: r.detail });
276
370
  }
277
371
  catch (e) {
278
372
  results.push({ ...base, status: 'fail', detail: `threw: ${e.message}` });
@@ -295,7 +389,7 @@ export async function runHealth(browser, opts = {}) {
295
389
  }
296
390
  try {
297
391
  const r = await a.check(browser, currentUrl);
298
- results.push({ ...base, status: r.ok ? 'ok' : 'fail', detail: r.detail });
392
+ results.push({ ...base, status: r.status ?? (r.ok ? 'ok' : 'fail'), detail: r.detail });
299
393
  }
300
394
  catch (e) {
301
395
  results.push({ ...base, status: 'fail', detail: `threw: ${e.message}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pro-vi/designer",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
5
  "description": "MCP + CLI for autonomous iteration of claude.ai/design — drives the design surface via agent-browser, downloads handoff bundles, and exposes a tasting harness for full-viewport variant comparison.",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "cli": "tsx cli.ts",
31
31
  "setup": "tsx cli.ts setup",
32
32
  "doctor": "tsx cli.ts doctor",
33
- "test": "npm run build && node --test tests/cli-metadata.test.mjs",
33
+ "test": "npm run build && node --test tests/cli-metadata.test.mjs && node --import tsx --test tests/run-state.*.test.mjs tests/cdp-trace.*.test.mjs",
34
34
  "check": "tsc --noEmit",
35
35
  "build": "tsc -p tsconfig.build.json",
36
36
  "prepack": "npm run check && npm run build",
@@ -38,7 +38,9 @@
38
38
  "probe": "tsx scripts/probe.ts",
39
39
  "probe:health": "tsx scripts/ci-health.ts",
40
40
  "auto-heal": "tsx scripts/auto-heal.ts",
41
- "smoke": "bash scripts/install-smoke.sh"
41
+ "smoke": "bash scripts/install-smoke.sh",
42
+ "trace": "tsx scripts/trace-spike.ts",
43
+ "trace:analyze": "tsx scripts/trace-analyze.ts"
42
44
  },
43
45
  "dependencies": {
44
46
  "@anthropic-ai/sdk": "^0.102.0",
@@ -53,7 +55,7 @@
53
55
  "typescript": "^6.0.3"
54
56
  },
55
57
  "engines": {
56
- "node": ">=20"
58
+ "node": ">=22"
57
59
  },
58
60
  "os": [
59
61
  "darwin",