@selvakumaresra/specship 0.3.0 → 0.5.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.
Files changed (97) hide show
  1. package/README.md +11 -1
  2. package/commands/ss-brainstorm.md +68 -0
  3. package/commands/ss-design-implement.md +5 -0
  4. package/commands/ss-design-loop.md +125 -0
  5. package/dist/analytics/specship-impact.d.ts +72 -0
  6. package/dist/analytics/specship-impact.d.ts.map +1 -0
  7. package/dist/analytics/specship-impact.js +216 -0
  8. package/dist/analytics/specship-impact.js.map +1 -0
  9. package/dist/bin/specship.js +70 -4
  10. package/dist/bin/specship.js.map +1 -1
  11. package/dist/db/migrations.d.ts +1 -1
  12. package/dist/db/migrations.d.ts.map +1 -1
  13. package/dist/db/migrations.js +15 -1
  14. package/dist/db/migrations.js.map +1 -1
  15. package/dist/db/schema.sql +8 -0
  16. package/dist/designer/artifact-store.js +54 -0
  17. package/dist/designer/browser.js +141 -0
  18. package/dist/designer/cdp-ensure.js +60 -0
  19. package/dist/designer/cdp-env.js +18 -0
  20. package/dist/designer/cdp-trace.js +599 -0
  21. package/dist/designer/cross-platform.js +74 -0
  22. package/dist/designer/designer-controller.js +1413 -0
  23. package/dist/designer/file-panel.js +39 -0
  24. package/dist/designer/interstitials.js +97 -0
  25. package/dist/designer/oopif-reader.js +176 -0
  26. package/dist/designer/package-meta.js +18 -0
  27. package/dist/designer/preview-host.js +50 -0
  28. package/dist/designer/repo-root.js +31 -0
  29. package/dist/designer/run-state.js +353 -0
  30. package/dist/designer/session-store.js +59 -0
  31. package/dist/designer/ui-anchors.js +651 -0
  32. package/dist/index.d.ts +27 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +48 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/installer/index.d.ts +7 -2
  37. package/dist/installer/index.d.ts.map +1 -1
  38. package/dist/installer/index.js +3 -2
  39. package/dist/installer/index.js.map +1 -1
  40. package/dist/installer/instructions-template.d.ts +17 -0
  41. package/dist/installer/instructions-template.d.ts.map +1 -1
  42. package/dist/installer/instructions-template.js +31 -1
  43. package/dist/installer/instructions-template.js.map +1 -1
  44. package/dist/installer/targets/claude.d.ts +19 -0
  45. package/dist/installer/targets/claude.d.ts.map +1 -1
  46. package/dist/installer/targets/claude.js +100 -1
  47. package/dist/installer/targets/claude.js.map +1 -1
  48. package/dist/installer/targets/shared.d.ts +14 -0
  49. package/dist/installer/targets/shared.d.ts.map +1 -1
  50. package/dist/installer/targets/shared.js +49 -0
  51. package/dist/installer/targets/shared.js.map +1 -1
  52. package/dist/installer/targets/types.d.ts +8 -0
  53. package/dist/installer/targets/types.d.ts.map +1 -1
  54. package/dist/mcp/designer-tools.d.ts +33 -0
  55. package/dist/mcp/designer-tools.d.ts.map +1 -0
  56. package/dist/mcp/designer-tools.js +313 -0
  57. package/dist/mcp/designer-tools.js.map +1 -0
  58. package/dist/mcp/tools.d.ts.map +1 -1
  59. package/dist/mcp/tools.js +22 -1
  60. package/dist/mcp/tools.js.map +1 -1
  61. package/dist/server/ingest/impact-backfill.js +69 -0
  62. package/dist/server/ingest/impact-query.js +343 -0
  63. package/dist/server/ingest/index.js +2 -1
  64. package/dist/server/ingest/ingestor.js +41 -6
  65. package/dist/server/ingest/specship-classify.js +153 -0
  66. package/dist/server/routes/claude.js +32 -0
  67. package/dist/server/routes/spec.js +94 -0
  68. package/dist/server/server.js +26 -2
  69. package/dist/web/{chunk-JN6W7HCN.js → chunk-45QHGCB4.js} +1 -1
  70. package/dist/web/{chunk-RAAMPHPJ.js → chunk-A5R3MJMO.js} +1 -1
  71. package/dist/web/{chunk-2DHIGIOI.js → chunk-ASZ77FMZ.js} +1 -1
  72. package/dist/web/chunk-D5OCNEJA.js +2 -0
  73. package/dist/web/{chunk-3SEJX2BK.js → chunk-FHZHD2ZG.js} +1 -1
  74. package/dist/web/chunk-GR72OOCN.js +1 -0
  75. package/dist/web/{chunk-YAWCRPHV.js → chunk-NZEZCT65.js} +1 -1
  76. package/dist/web/chunk-O7434ZMN.js +1 -0
  77. package/dist/web/chunk-ODX6CT3I.js +6 -0
  78. package/dist/web/chunk-RASJHUXS.js +1 -0
  79. package/dist/web/chunk-TQ3P2QZO.js +1 -0
  80. package/dist/web/{chunk-BCZM5AXU.js → chunk-UBOZGQNK.js} +1 -1
  81. package/dist/web/chunk-WCHGDXWC.js +1 -0
  82. package/dist/web/{chunk-BPECIDVO.js → chunk-WCKHQIYN.js} +1 -1
  83. package/dist/web/{chunk-JFYVCXK3.js → chunk-WLIMNDS3.js} +1 -1
  84. package/dist/web/{chunk-LV4G6QFG.js → chunk-YAMRN47K.js} +1 -1
  85. package/dist/web/index.html +1 -1
  86. package/dist/web/main-X2KCYXZ4.js +1 -0
  87. package/dist/web/sw.js +69 -0
  88. package/dist/workflows/defaults/claude-design-implement.yaml +138 -49
  89. package/hooks/hooks.json +11 -0
  90. package/package.json +7 -3
  91. package/selectors.json +41 -0
  92. package/dist/web/chunk-2OKMB4KX.js +0 -2
  93. package/dist/web/chunk-4N5DWG46.js +0 -1
  94. package/dist/web/chunk-DA6SNNAF.js +0 -1
  95. package/dist/web/chunk-JT7P3DEK.js +0 -6
  96. package/dist/web/chunk-TWXZK6XM.js +0 -1
  97. package/dist/web/main-WVI3YTDU.js +0 -1
@@ -0,0 +1,651 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UI_ANCHORS = void 0;
4
+ exports.runHealth = runHealth;
5
+ const run_state_1 = require("./run-state");
6
+ const preview_host_1 = require("./preview-host");
7
+ const cdp_env_1 = require("./cdp-env");
8
+ const oopif_reader_1 = require("./oopif-reader");
9
+ const file_panel_1 = require("./file-panel");
10
+ async function hasSelector(browser, sel) {
11
+ return !!(await browser
12
+ .evalValue(`!!document.querySelector(${JSON.stringify(sel)})`)
13
+ .catch(() => false));
14
+ }
15
+ async function hasButtonMatching(browser, pattern) {
16
+ return !!(await browser
17
+ .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())); })()`)
18
+ .catch(() => false));
19
+ }
20
+ // The design-preview iframe's src. Shared by the preview anchors below
21
+ // (iframeSrcPattern / previewBootstrap / oopifPreviewRead) so they read the
22
+ // element the same way. '' when absent (caller decides skip vs fail).
23
+ async function getPreviewIframeSrc(browser) {
24
+ return ((await browser
25
+ .evalValue(`(() => { const el = document.querySelector('[data-testid="html-viewer-iframe"]'); return (el && el.src) || ''; })()`)
26
+ .catch(() => '')) || '');
27
+ }
28
+ function sleep(ms) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+ async function submitTurnRpcCanary(browser) {
32
+ const prompt = 'Health check: answer in chat only with the single word ok. Do not create, modify, or delete files.';
33
+ const filled = await browser
34
+ .evalValue(`(() => {
35
+ const el = document.querySelector('[data-testid="chat-composer-input"]');
36
+ if (!el) return false;
37
+ const text = ${JSON.stringify(prompt)};
38
+ if (el instanceof HTMLTextAreaElement) {
39
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
40
+ setter.call(el, text);
41
+ el.dispatchEvent(new Event('input', { bubbles: true }));
42
+ el.focus();
43
+ return true;
44
+ }
45
+ if (el.isContentEditable) {
46
+ el.focus();
47
+ const sel = window.getSelection();
48
+ const range = document.createRange();
49
+ range.selectNodeContents(el);
50
+ sel.removeAllRanges();
51
+ sel.addRange(range);
52
+ const dt = new DataTransfer();
53
+ dt.setData('text/plain', text);
54
+ const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
55
+ if (unhandled) document.execCommand('insertText', false, text);
56
+ return true;
57
+ }
58
+ return false;
59
+ })()`)
60
+ .catch(() => false);
61
+ if (!filled)
62
+ return { ok: false, detail: 'composer not fillable for canary prompt' };
63
+ for (let i = 0; i < 30; i++) {
64
+ const disabled = await browser
65
+ .evalValue(`(() => {
66
+ const b = document.querySelector('[data-testid="chat-send-button"], button[title^="Send ("]');
67
+ return !b || b.disabled || b.getAttribute('aria-disabled') === 'true';
68
+ })()`)
69
+ .catch(() => true);
70
+ if (!disabled)
71
+ break;
72
+ await sleep(150);
73
+ }
74
+ const clicked = await browser
75
+ .evalValue(`(() => {
76
+ const b = document.querySelector('[data-testid="chat-send-button"], button[title^="Send ("]');
77
+ if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
78
+ b.click();
79
+ return true;
80
+ })()`)
81
+ .catch(() => false);
82
+ return clicked ? { ok: true } : { ok: false, detail: 'send button unavailable for canary prompt' };
83
+ }
84
+ async function checkTurnRpcContract(_browser, currentUrl) {
85
+ if (process.env.DESIGNER_TURN_RPC_CANARY !== '1') {
86
+ return { ok: true, status: 'skip', detail: 'turn-RPC canary disabled (DESIGNER_TURN_RPC_CANARY!=1)' };
87
+ }
88
+ if (!(0, cdp_env_1.isCdpEnabled)()) {
89
+ return { ok: true, status: 'skip', detail: "CDP disabled (DESIGNER_CDP=''); turn-RPC canary not probed" };
90
+ }
91
+ const observer = await run_state_1.RunStateObserver.attach({ preferUrlPrefix: currentUrl.split('?')[0] || null });
92
+ if (!observer) {
93
+ return { ok: true, status: 'skip', detail: 'CDP observer unavailable; turn-RPC canary not probed' };
94
+ }
95
+ try {
96
+ observer.beginRun();
97
+ const submitted = await submitTurnRpcCanary(_browser);
98
+ if (!submitted.ok)
99
+ return { ok: true, status: 'skip', detail: submitted.detail };
100
+ const terminal = await observer.awaitTerminal({ stallMs: 25_000, hardTimeoutMs: 75_000 });
101
+ const summary = observer.signalSummary();
102
+ const detail = `heartbeat x${summary.heartbeat}, release ${summary.release > 0 ? 'seen' : 'missing'}, ` +
103
+ `chat x${summary.chatOpen}, chunks x${summary.chatChunk}, terminal=${terminal.terminal}` +
104
+ (summary.observedRpcPaths.length ? `, observed=[${summary.observedRpcPaths.join(', ')}]` : ', observed=[]');
105
+ return {
106
+ // A healthy fast chat-only turn can finish before the first RenewTurn
107
+ // (~14.5s in, per trace findings), so heartbeat>0 is not a contract
108
+ // requirement — gate on the discrete signals (chat opened + released +
109
+ // finished). heartbeat count stays visible in `detail` as soft signal.
110
+ ok: terminal.terminal === 'finished' && summary.release > 0 && summary.chatOpen > 0,
111
+ detail
112
+ };
113
+ }
114
+ finally {
115
+ observer.close();
116
+ }
117
+ }
118
+ exports.UI_ANCHORS = [
119
+ // --- login state (first so a signed-out session tops the report) ---
120
+ {
121
+ // Issue #32: signed out, `designer health` showed only skips/cryptic
122
+ // fails and read as "everything OK". This anchor calls the signed-out
123
+ // state out explicitly.
124
+ //
125
+ // A URL-only check is not enough: a logged-out visit to claude.ai/design
126
+ // sometimes redirects to /login, but sometimes renders the login wall AT
127
+ // the /design URL with no /login substring (the #16 false positive that
128
+ // setup's DOM-based verifier exists to catch — see setup.ts). So gate on
129
+ // the DOM app-shell marker setup uses, not the URL alone.
130
+ id: 'login.signedIn',
131
+ category: 'pattern',
132
+ description: 'signed in (claude.ai is rendering the app shell, not the login wall)',
133
+ requires: 'any',
134
+ check: async (b, url) => {
135
+ // Explicit login wall in the URL — unambiguously signed out.
136
+ if (/claude\.ai\/login/.test(url)) {
137
+ return { ok: false, detail: `signed out — Chrome is on the login wall (${url.slice(0, 80)}). Run: designer setup` };
138
+ }
139
+ // On a design surface, the signed-in app shell renders the chat composer
140
+ // (chat-composer-input) on BOTH the home (creation composer, post-2026-06
141
+ // redesign #61) and inside a session. Its absence here means the login
142
+ // wall is being served at the /design URL — fail loudly.
143
+ if (/claude\.ai\/design/.test(url)) {
144
+ const signedIn = await hasSelector(b, '[data-testid="chat-composer-input"]');
145
+ return signedIn
146
+ ? { ok: true }
147
+ : { ok: false, detail: `login wall rendered at ${url.slice(0, 80)} (no app shell) — signed out. Run: designer setup` };
148
+ }
149
+ // Off the claude.ai/design surface entirely (e.g. an unrelated tab) —
150
+ // sign-in can't be judged from this tab, so don't false-fail.
151
+ return { ok: true, detail: `not on a claude.ai/design surface (url=${url.slice(0, 60)}) — sign-in not checked here` };
152
+ }
153
+ },
154
+ // --- home page ---
155
+ // 2026-06 redesign (#61): the home is composer-driven — no project-name input
156
+ // and no wireframe/high-fi toggle. Creation = seed the chat composer
157
+ // (chat-composer-input) + click "Start project" (chat-send-button, same
158
+ // testids as the in-session composer/send). The old home.nameInput anchor was
159
+ // dropped (no equivalent); home.wireframeButton/highFiButton are repurposed to
160
+ // the surviving creation-type cards (text-only buttons) so they still detect
161
+ // drift of the creation UI. Captured live from Chrome 149.
162
+ {
163
+ id: 'home.creator',
164
+ category: 'home',
165
+ description: 'creation composer (chat-composer-input)',
166
+ requires: 'home',
167
+ check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-composer-input"]') })
168
+ },
169
+ {
170
+ id: 'home.wireframeButton',
171
+ category: 'home',
172
+ description: 'Product wireframe creation-type card',
173
+ requires: 'home',
174
+ check: async (b) => ({ ok: await hasButtonMatching(b, /^Product wireframe/) })
175
+ },
176
+ {
177
+ id: 'home.highFiButton',
178
+ category: 'home',
179
+ // Renamed 'Prototype' → 'Product prototype' in the 2026-06-19 home build
180
+ // (auto-heal PR #75/#76). Off the create path — a drift sentinel only.
181
+ description: 'Product prototype creation-type card',
182
+ requires: 'home',
183
+ check: async (b) => ({ ok: await hasButtonMatching(b, /^Product prototype/) })
184
+ },
185
+ {
186
+ id: 'home.createButton',
187
+ category: 'home',
188
+ description: '"Start project" create button (chat-send-button)',
189
+ requires: 'home',
190
+ check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-send-button"], button[title^="Send ("]') })
191
+ },
192
+ {
193
+ id: 'home.projectsList',
194
+ category: 'home',
195
+ description: 'project list (>=1 /design/p/ link)',
196
+ requires: 'home',
197
+ check: async (b) => ({ ok: await hasSelector(b, 'a[href*="/design/p/"]') })
198
+ },
199
+ {
200
+ id: 'home.projectCard',
201
+ category: 'home',
202
+ description: 'project card (a[href*="/design/p/"])',
203
+ requires: 'home',
204
+ check: async (b) => ({ ok: await hasSelector(b, 'a[href*="/design/p/"]') })
205
+ },
206
+ // --- inside a session (after /design/p/{uuid}) ---
207
+ {
208
+ id: 'session.promptTextarea',
209
+ category: 'session',
210
+ description: 'chat composer textarea',
211
+ requires: 'session',
212
+ check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-composer-input"]') })
213
+ },
214
+ {
215
+ // Existence (above) isn't enough — _submitPrompt can only fill a composer
216
+ // that is a <textarea> or a contenteditable element, and it branches on
217
+ // exactly that. The 2026-06 build shipped the composer as a ProseMirror
218
+ // contenteditable <div>; if it drifts to a shape that's neither (a bare
219
+ // wrapper, a web component, a readonly node), submission silently stalls
220
+ // and callers fall back to driving the page by hand. That's the regression
221
+ // fract-ai hit on a pre-0.3.9 build (designer/.inbox 2026-06-10). This
222
+ // anchor asserts the composer is in a shape _submitPrompt actually handles.
223
+ //
224
+ // Scope: this checks the composer's SHAPE, not that a paste actually lands
225
+ // (verifying that would mean typing into a live session). A contenteditable
226
+ // whose editor rejects synthetic paste would still pass here.
227
+ //
228
+ // Maintenance: this is a block-bodied evalValue check, so it is NOT
229
+ // auto-heal-patchable (anchor-patcher's canPatch only rewrites the simple
230
+ // `hasSelector(b, '<sel>')` shape). The chat-composer-input selector is
231
+ // duplicated from session.promptTextarea above — if it drifts, auto-heal
232
+ // will self-heal promptTextarea but skip this one; update the selector in
233
+ // the eval below by hand to match. (Same limitation as the other rich
234
+ // anchors here: hasButtonMatching, iframeSrcPattern, fileListScrape.)
235
+ id: 'session.composerFillable',
236
+ category: 'session',
237
+ description: 'composer is fillable (textarea or contenteditable, per _submitPrompt)',
238
+ requires: 'session',
239
+ check: async (b) => {
240
+ const shape = await b
241
+ .evalValue(`(() => {
242
+ const el = document.querySelector('[data-testid="chat-composer-input"]');
243
+ if (!el) return { found: false };
244
+ const fillable = el instanceof HTMLTextAreaElement || el.isContentEditable;
245
+ return { found: true, tag: el.tagName, contentEditable: el.isContentEditable, fillable };
246
+ })()`)
247
+ .catch(() => ({ found: false }));
248
+ if (!shape.found)
249
+ return { ok: false, detail: 'composer not found' };
250
+ if (shape.fillable) {
251
+ return { ok: true, detail: shape.contentEditable ? 'contenteditable' : `<${(shape.tag || '').toLowerCase()}>` };
252
+ }
253
+ return {
254
+ ok: false,
255
+ detail: `composer is <${(shape.tag || '?').toLowerCase()}> — neither textarea nor contenteditable; _submitPrompt cannot fill it (composer shape drifted)`
256
+ };
257
+ }
258
+ },
259
+ {
260
+ id: 'session.sendButton',
261
+ category: 'session',
262
+ description: 'send button',
263
+ requires: 'session',
264
+ // The 2026-06 build dropped data-testid="chat-send-button"; the button is
265
+ // now only identifiable by its title="Send (Enter)". Match either.
266
+ check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-send-button"], button[title^="Send ("]') })
267
+ },
268
+ {
269
+ id: 'session.htmlViewerIframe',
270
+ category: 'session',
271
+ description: 'html-viewer-iframe (design preview)',
272
+ requires: 'session',
273
+ check: async (b, url) => {
274
+ // The iframe only renders when a file is open. Without ?file= in the URL,
275
+ // its absence is expected, not a regression.
276
+ if (!/[?&]file=/.test(url))
277
+ return { ok: true, detail: '(no file open — iframe not expected)' };
278
+ return { ok: await hasSelector(b, '[data-testid="html-viewer-iframe"]') };
279
+ }
280
+ },
281
+ {
282
+ id: 'session.chatMessages',
283
+ category: 'session',
284
+ description: 'chat-messages container',
285
+ requires: 'session',
286
+ check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-messages"]') })
287
+ },
288
+ {
289
+ id: 'network.turnRpcContract',
290
+ category: 'pattern',
291
+ description: 'OmeletteService Chat/RenewTurn/ReleaseTurn network contract',
292
+ requires: 'session',
293
+ check: checkTurnRpcContract
294
+ },
295
+ {
296
+ id: 'session.iframeSrcPattern',
297
+ category: 'pattern',
298
+ description: 'iframe src serves from claudeusercontent.com (signed-token or bootstrap-subdomain)',
299
+ requires: 'session',
300
+ check: async (b, url) => {
301
+ if (!/[?&]file=/.test(url))
302
+ return { ok: true, detail: '(no file open — iframe not expected)' };
303
+ const src = await getPreviewIframeSrc(b);
304
+ if (!src)
305
+ return { ok: false, detail: 'file param present but iframe missing src' };
306
+ const ok = (0, preview_host_1.isPreviewIframeSrc)(src);
307
+ return { ok, detail: ok ? `variant=${(0, preview_host_1.previewIframeVariant)(src)}` : `src=${src.slice(0, 120)}...` };
308
+ }
309
+ },
310
+ {
311
+ // Drift sentinel for the OOPIF preview-HTML capture path (issue #61 / review
312
+ // #4). fetchServedHtml branches on previewIframeVariant: signed-token keeps
313
+ // the legacy node fetch; bootstrap-subdomain reads the cross-origin OOPIF's
314
+ // rendered DOM over CDP. This anchor records which regime the live preview
315
+ // is in so a swing back to signed-token (or to an unrecognized 'other'
316
+ // shape) — which would silently route capture down the wrong path — is
317
+ // visible in the daily health probe. This anchor records the regime only;
318
+ // the sibling `session.oopifPreviewRead` below actually attaches CDP and
319
+ // verifies the bootstrap-subdomain capture returns rendered HTML.
320
+ id: 'network.previewBootstrap',
321
+ category: 'pattern',
322
+ description: 'preview iframe regime (bootstrap-subdomain => OOPIF CDP capture; signed-token => node fetch)',
323
+ requires: 'session',
324
+ check: async (b, url) => {
325
+ if (!/[?&]file=/.test(url))
326
+ return { ok: true, detail: '(no file open — preview regime not checked)' };
327
+ const src = await getPreviewIframeSrc(b);
328
+ if (!src)
329
+ return { ok: false, detail: 'file param present but iframe missing src' };
330
+ if (!(0, preview_host_1.isPreviewIframeSrc)(src))
331
+ return { ok: false, detail: `preview left claudeusercontent.com: ${src.slice(0, 120)}` };
332
+ const variant = (0, preview_host_1.previewIframeVariant)(src);
333
+ return {
334
+ ok: variant === 'bootstrap-subdomain' || variant === 'signed-token',
335
+ detail: variant === 'bootstrap-subdomain'
336
+ ? 'variant=bootstrap-subdomain (OOPIF CDP capture path)'
337
+ : variant === 'signed-token'
338
+ ? 'variant=signed-token (legacy node-fetch path)'
339
+ : `variant=other — unrecognized preview src shape (${src.slice(0, 120)}); capture path may be wrong`
340
+ };
341
+ }
342
+ },
343
+ {
344
+ // End-to-end check of the OOPIF capture itself. iframeSrcPattern /
345
+ // previewBootstrap only inspect the src STRING — the CDP auto-attach read
346
+ // could silently return the ~1.1KB loader shell (or null) while both pass,
347
+ // handing snapshot/fetch/iterate empty HTML (inbox finding #3). This anchor
348
+ // attaches its own OopifHtmlReader (like checkTurnRpcContract attaches a
349
+ // RunStateObserver) and asserts the read returns rendered HTML, not the
350
+ // shell. Only the bootstrap-subdomain regime uses the OOPIF path; the
351
+ // signed-token / 'other' regimes use a node fetch, so they skip here.
352
+ id: 'session.oopifPreviewRead',
353
+ category: 'pattern',
354
+ description: 'OOPIF CDP read returns rendered preview HTML (not the bootstrap loader shell)',
355
+ requires: 'session',
356
+ check: async (b, url) => {
357
+ // Gate on a RENDERED preview iframe, not on ?file= in the URL: the daily-
358
+ // health canary (DESIGNER_PROBE_PROJECT_URL) is a BARE project URL, and
359
+ // claude.ai auto-opens a default file + renders its preview there — so a
360
+ // ?file= gate would skip the OOPIF check in exactly the CI run it exists to
361
+ // protect (PR #77 Codex P2). Wait briefly for the iframe to paint after nav.
362
+ let src = await getPreviewIframeSrc(b);
363
+ for (let i = 0; i < 6 && !(0, preview_host_1.isPreviewIframeSrc)(src); i++) {
364
+ await sleep(500);
365
+ src = await getPreviewIframeSrc(b);
366
+ }
367
+ if (!(0, preview_host_1.isPreviewIframeSrc)(src))
368
+ return { ok: true, status: 'skip', detail: 'no preview iframe rendered (no file open)' };
369
+ const variant = (0, preview_host_1.previewIframeVariant)(src);
370
+ if (variant !== 'bootstrap-subdomain')
371
+ return { ok: true, status: 'skip', detail: `variant=${variant} — node-fetch path, OOPIF read not used` };
372
+ if (!(0, cdp_env_1.isCdpEnabled)())
373
+ return { ok: true, status: 'skip', detail: "CDP disabled (DESIGNER_CDP=''); OOPIF read not probed" };
374
+ // By here CDP is enabled AND the preview is on the bootstrap-subdomain
375
+ // (OOPIF) path — so an attach failure is NOT inconclusive. Production
376
+ // fetchServedHtml uses the same reader and falls back to EMPTY html on
377
+ // attach failure, so snapshot/fetch/iterate would silently get no content.
378
+ // Fail the probe (don't skip) — this is the exact regression it exists to
379
+ // catch (PR #77 Codex P2).
380
+ const reader = await oopif_reader_1.OopifHtmlReader.attach({ preferUrlPrefix: url || null }).catch(() => null);
381
+ if (!reader)
382
+ return { ok: false, detail: 'OOPIF reader attach failed while CDP is enabled on the bootstrap-subdomain path — snapshot/fetch/iterate would get empty HTML' };
383
+ try {
384
+ const html = await reader.readPreviewHtml().catch(() => null);
385
+ if (!html)
386
+ return { ok: false, detail: 'OOPIF read returned null — CDP capture path broken (snapshot/fetch/iterate would get empty HTML)' };
387
+ if ((0, preview_host_1.isBootstrapShellHtml)(html))
388
+ return { ok: false, detail: `OOPIF read returned the bootstrap loader shell (${html.length}B), not rendered HTML` };
389
+ return { ok: true, detail: `read ${html.length}B of rendered HTML via OOPIF CDP capture` };
390
+ }
391
+ finally {
392
+ reader.close();
393
+ }
394
+ }
395
+ },
396
+ {
397
+ // Legacy id (kept to avoid resetting the persisted streak counter). The
398
+ // original check asserted a 'You\n' / 'Claude\n' text prefix on each
399
+ // chat turn, but Claude's May 2026 chat redesign removed the in-text
400
+ // speaker label — turns are now distinguished by Claude's intentional
401
+ // `data-index="N"` API on each turn row.
402
+ //
403
+ // It originally matched the SPECIFIC `[data-index="1"]`, but the chat list
404
+ // is VIRTUALIZED: once a conversation grows past the render window, only a
405
+ // sliding window of rows is in the DOM (live-probed indices were 8–15 with
406
+ // 0/1 evicted), so `[data-index="1"]` vanishes even though there are clearly
407
+ // >=2 turns — a recurring false drift, same class as fileListScrape (#69).
408
+ // Assert the COUNT of `[data-index]` rows instead: any window of a >=2-turn
409
+ // chat renders >=2 rows, so count>=2 confirms both "the indexing API exists"
410
+ // and ">=2 turns" without depending on which window is visible. Soft anchor:
411
+ // a 1-turn chat (count 1) is a short conversation, not drift -> skip; a
412
+ // missing API/testid after settle is the real drift signal -> fail.
413
+ id: 'session.chatTurnPrefix',
414
+ category: 'pattern',
415
+ description: 'chat-messages renders >=2 turn rows (data-index API)',
416
+ requires: 'session',
417
+ check: async (b) => {
418
+ const countRows = () => b
419
+ .evalValue(`(() => { const cm = document.querySelector('[data-testid="chat-messages"]'); if (!cm) return -1; return cm.querySelectorAll('[data-index]').length; })()`)
420
+ .catch(() => -1);
421
+ // The chat renders progressively after navigation; settle before judging.
422
+ let n = -1;
423
+ for (let attempt = 0; attempt < 6; attempt++) {
424
+ n = await countRows();
425
+ if (n >= 2)
426
+ break;
427
+ if (attempt < 5)
428
+ await sleep(1000);
429
+ }
430
+ if (n >= 2)
431
+ return { ok: true };
432
+ if (n === 1)
433
+ return { ok: true, status: 'skip', detail: 'only 1 turn row (short conversation) — data-index API present, >=2 unverifiable' };
434
+ if (n === 0)
435
+ return { ok: false, detail: 'chat-messages present but 0 [data-index] rows after ~5s settle — turn-row data-index API drifted' };
436
+ return { ok: false, detail: 'chat-messages testid not found after ~5s settle — testid drifted' };
437
+ }
438
+ },
439
+ // --- share dialog (formerly the Export dropdown; moved under Share ~2026-04-19) ---
440
+ {
441
+ id: 'share.shareButton',
442
+ category: 'share',
443
+ description: 'Share button (opens the dropdown containing handoff/export actions)',
444
+ requires: 'session',
445
+ check: async (b) => ({ ok: await hasButtonMatching(b, /^Share$/) })
446
+ },
447
+ {
448
+ // Id kept (not renamed) to preserve the persisted health-streak counter.
449
+ // Validates the path `designer handoff` actually takes: the same-origin
450
+ // project export endpoint returns a zip. The old check clicked the Share
451
+ // dialog and asserted "claude code" TEXT existed — which false-passed (it
452
+ // stayed green while handoff threw) because it never exercised the real
453
+ // mechanism (PR: handoff Share-redesign rework).
454
+ id: 'share.handoffMenuItem',
455
+ category: 'share',
456
+ description: 'Project export endpoint (/design/v1/design/projects/<id>/download) returns a zip — the path designer handoff fetches',
457
+ requires: 'session',
458
+ check: async (b, url) => {
459
+ const m = url.match(/\/design\/p\/([a-f0-9-]+)/i);
460
+ if (!m || !m[1])
461
+ return { ok: true, status: 'skip', detail: 'not in a /design/p/<uuid> session' };
462
+ const projectId = m[1];
463
+ // In-page GET (auth + Cloudflare just work there); read headers, cancel the
464
+ // body so health doesn't pull the multi-MB zip every run. Bounded by an
465
+ // abort deadline so a hung endpoint can't stall the whole health sweep.
466
+ const res = await b
467
+ .evalValue(`(async () => {
468
+ const ctrl = new AbortController();
469
+ const to = setTimeout(() => ctrl.abort(), 15000);
470
+ try {
471
+ const r = await fetch('/design/v1/design/projects/' + ${JSON.stringify(projectId)} + '/download', { headers: { Accept: '*/*' }, signal: ctrl.signal });
472
+ const o = { status: r.status, ct: r.headers.get('content-type') || '' };
473
+ try { await r.body.cancel(); } catch {}
474
+ return o;
475
+ } catch (e) { return { status: 0, ct: '', err: String((e && e.message) || e) }; }
476
+ finally { clearTimeout(to); }
477
+ })()`)
478
+ .catch(() => ({ status: 0, ct: '', err: 'eval failed' }));
479
+ // Accept zip OR octet-stream: _downloadProjectZip validates by PK magic and
480
+ // ignores content-type, so a 200 octet-stream is a real success — don't go
481
+ // red where the download would succeed (the inverse of the old false-pass).
482
+ const ok = res.status === 200 && /(zip|octet-stream)/i.test(res.ct);
483
+ return {
484
+ ok,
485
+ detail: ok ? `200 ${res.ct}` : `download endpoint status=${res.status} ct=${res.ct}${res.err ? ' err=' + res.err : ''}`
486
+ };
487
+ }
488
+ },
489
+ // --- URL / pattern anchors ---
490
+ {
491
+ id: 'pattern.sessionUrl',
492
+ category: 'pattern',
493
+ description: 'session URL matches /design/p/<uuid>',
494
+ requires: 'any',
495
+ check: async (_b, url) => {
496
+ const inSession = /\/design\/p\/[a-f0-9-]+/i.test(url);
497
+ return { ok: inSession || /claude\.ai\/design\/?(\?|$)/.test(url), detail: `url=${url.slice(0, 100)}` };
498
+ }
499
+ },
500
+ {
501
+ id: 'pattern.fileQueryParam',
502
+ category: 'pattern',
503
+ description: '?file=<name> opens a specific file (URL-based file switching)',
504
+ requires: 'session',
505
+ check: async (_b, url) => {
506
+ const ok = /[?&]file=/.test(url);
507
+ return { ok: true, detail: ok ? 'file param present' : '(no file open — not a regression)' };
508
+ }
509
+ },
510
+ {
511
+ id: 'session.fileListScrape',
512
+ category: 'session',
513
+ description: 'filename text nodes detectable (listFiles scrape still works)',
514
+ requires: 'session',
515
+ check: async (b, url) => {
516
+ const scrape = () => b
517
+ .evalValue(`(() => {
518
+ const seen = new Set();
519
+ const files = [];
520
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
521
+ let node;
522
+ while ((node = walker.nextNode())) {
523
+ const t = (node.textContent || '').trim();
524
+ if (!/^[A-Za-z0-9 _.()\\-]+\\.(html|js|css|jsx|tsx|ts|md|json|svg)$/i.test(t)) continue;
525
+ if (t.length > 80 || seen.has(t)) continue;
526
+ seen.add(t);
527
+ files.push(t);
528
+ }
529
+ return { files };
530
+ })()`)
531
+ .catch(() => ({ files: [] }));
532
+ // Production listFilesDetailed OPENS the "Design Files" panel before
533
+ // scraping; this anchor used to scrape the bare page, so on a project whose
534
+ // panel wasn't already rendered (e.g. a single-file standalone — PR #75/#76
535
+ // hit "Signup Wireframes (standalone)") it found 0 and false-failed while
536
+ // `designer files` worked. Open the panel first so the anchor exercises the
537
+ // same path. Idempotent + best-effort (matches listFilesDetailed).
538
+ // Shared, idempotent opener (file-panel.ts) — identical to the production
539
+ // listFilesDetailed opener so this probe exercises the real path.
540
+ const openFilesPanel = () => b.evalValue(file_panel_1.OPEN_FILES_PANEL_EXPR).catch(() => false);
541
+ // Open the panel ONCE up front. Clicking it on every retry would toggle an
542
+ // already-open panel closed mid-settle (oscillation — review below-gate);
543
+ // the panel header renders immediately, its file rows a beat later, so one
544
+ // click + the retry-scrape settle covers the late render. The file-list
545
+ // panel renders a few seconds after navigation; scraping immediately races
546
+ // it — the recurring false "0 filenames" the daily probe filed
547
+ // (#64/#65/#68), even though `designer files` and a live scrape find the
548
+ // files once the panel is up. Retry with a bounded settle before concluding
549
+ // a regression.
550
+ await openFilesPanel();
551
+ let files = [];
552
+ for (let attempt = 0; attempt < 6; attempt++) {
553
+ await sleep(attempt === 0 ? 300 : 700);
554
+ const result = await scrape();
555
+ files = Array.isArray(result.files) ? result.files : [];
556
+ if (files.length > 0)
557
+ break;
558
+ }
559
+ if (files.length === 0) {
560
+ // With no file open the file-list panel may legitimately be absent — don't
561
+ // hard-fail a soft anchor on an inconclusive state; only a populated
562
+ // session (a file open) is expected to list filenames.
563
+ if (!/[?&]file=/.test(url)) {
564
+ return { ok: true, status: 'skip', detail: 'no file open; file-list panel not rendered — inconclusive' };
565
+ }
566
+ return { ok: false, detail: 'found 0 filenames after ~5s settle — scraper regex or DOM layout regressed' };
567
+ }
568
+ // The anchor's invariant is "the scraper still detects filenames" — ≥1
569
+ // filename means the regex + DOM walk work. Whether the URL's ?file=
570
+ // appears among them is NOT a reliable sub-assertion: the panel lists the
571
+ // authoritative project files, and the active ?file= can legitimately be
572
+ // absent from it (a stale/virtual URL file — observed live: ?file=
573
+ // direction-dock.html while the panel lists casefile-*.html). So treat an
574
+ // active-file mismatch as informational, not a failure.
575
+ const match = url.match(/[?&]file=([^&]+)/);
576
+ if (match && match[1]) {
577
+ // Claude Design's URL bar form-encodes spaces as '+'. decodeURIComponent
578
+ // only handles %xx, so normalize '+' → ' ' first before comparing
579
+ // against the scraper's text-node output (which uses real spaces).
580
+ const activeFile = decodeURIComponent(match[1].replace(/\+/g, ' '));
581
+ if (!files.includes(activeFile)) {
582
+ return {
583
+ ok: true,
584
+ detail: `${files.length} file(s) detected; active "${activeFile}" not among them (URL file may be stale/virtual)`
585
+ };
586
+ }
587
+ }
588
+ return { ok: true, detail: `${files.length} file(s) detected` };
589
+ }
590
+ }
591
+ ];
592
+ async function runHealth(browser, opts = {}) {
593
+ const currentUrl = (await browser.url().catch(() => '')) || '';
594
+ // When `opts.phase` is supplied the caller has already navigated to the
595
+ // matching surface — filter strictly by that phase, tag every result with
596
+ // it, and suppress skips (a `home`-only anchor probed during a `session`
597
+ // phase isn't a skip-with-detail, it's just not part of this phase's run).
598
+ // When omitted, fall back to URL-inferred state for back-compat with
599
+ // single-phase callers (cli.ts `designer health`).
600
+ if (opts.phase) {
601
+ const phase = opts.phase;
602
+ const results = [];
603
+ for (const a of exports.UI_ANCHORS) {
604
+ const applicable = a.requires === 'any' ||
605
+ (phase === 'home' && a.requires === 'home') ||
606
+ (phase === 'session' && a.requires === 'session');
607
+ if (!applicable)
608
+ continue;
609
+ const base = {
610
+ id: a.id,
611
+ category: a.category,
612
+ description: a.description,
613
+ requires: a.requires,
614
+ phase
615
+ };
616
+ try {
617
+ const r = await a.check(browser, currentUrl);
618
+ results.push({ ...base, status: r.status ?? (r.ok ? 'ok' : 'fail'), detail: r.detail });
619
+ }
620
+ catch (e) {
621
+ results.push({ ...base, status: 'fail', detail: `threw: ${e.message}` });
622
+ }
623
+ }
624
+ return results;
625
+ }
626
+ // Legacy URL-inferred path. Single-phase callers see the same behavior as
627
+ // before — skips emitted for anchors that don't match the inferred state,
628
+ // no `phase` field on results.
629
+ const inSession = /\/design\/p\/[a-f0-9-]+/i.test(currentUrl);
630
+ const onHome = /\/design\/?$/.test(currentUrl) || currentUrl.endsWith('/design');
631
+ const state = inSession ? 'session' : onHome ? 'home' : 'other';
632
+ const results = [];
633
+ for (const a of exports.UI_ANCHORS) {
634
+ const base = { id: a.id, category: a.category, description: a.description, requires: a.requires };
635
+ const applicable = a.requires === 'any' ||
636
+ (a.requires === 'home' && state === 'home') ||
637
+ (a.requires === 'session' && state === 'session');
638
+ if (!applicable) {
639
+ results.push({ ...base, status: 'skip', detail: `needs ${a.requires} state; current=${state}` });
640
+ continue;
641
+ }
642
+ try {
643
+ const r = await a.check(browser, currentUrl);
644
+ results.push({ ...base, status: r.status ?? (r.ok ? 'ok' : 'fail'), detail: r.detail });
645
+ }
646
+ catch (e) {
647
+ results.push({ ...base, status: 'fail', detail: `threw: ${e.message}` });
648
+ }
649
+ }
650
+ return results;
651
+ }