@pro-vi/designer 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/cli.js +29 -10
- package/dist/designer-controller.js +58 -9
- package/dist/scripts/anchor-patcher.js +118 -0
- package/dist/scripts/auto-heal.js +660 -0
- package/dist/scripts/ci-health.js +277 -0
- package/dist/setup.js +40 -28
- package/dist/ui-anchors.js +46 -12
- package/package.json +12 -4
- package/selectors.json +1 -1
package/README.md
CHANGED
|
@@ -118,7 +118,8 @@ Writes `tasting.html` with variant tabs + 1/2/3 shortcuts + persistent notes, se
|
|
|
118
118
|
## Operations
|
|
119
119
|
|
|
120
120
|
- `designer doctor` — diagnose setup state. Exits 2 on failure.
|
|
121
|
-
- `designer health` — probe 17 UI anchors. Wire into cron to catch claude.ai UI regressions.
|
|
121
|
+
- `designer health [--json]` — probe 17 UI anchors. Wire into cron to catch claude.ai UI regressions.
|
|
122
|
+
- **Daily CI** in `.github/workflows/`: `daily-health.yml` runs the auth-required UI probe on a self-hosted macOS runner once per day; `ci.yml` typechecks + builds + does a Docker clean-room install smoke on every PR; `release-please.yml` opens a release PR on conventional commits, merging it tags + publishes via `release-publish.yml`. Selector regressions land as auto-opened PRs under the `selectors-drift` label.
|
|
122
123
|
|
|
123
124
|
## Known quirks
|
|
124
125
|
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,9 @@ function parseFlags(args) {
|
|
|
22
22
|
if (a.startsWith('--')) {
|
|
23
23
|
const parts = a.slice(2).split('=');
|
|
24
24
|
const k = parts[0] ?? '';
|
|
25
|
-
const
|
|
25
|
+
const next = args[i + 1];
|
|
26
|
+
const v = parts[1] ??
|
|
27
|
+
(next !== undefined && !next.startsWith('--') ? args[++i] : true);
|
|
26
28
|
if (k)
|
|
27
29
|
out[k] = v;
|
|
28
30
|
}
|
|
@@ -183,18 +185,31 @@ async function main() {
|
|
|
183
185
|
case 'health': {
|
|
184
186
|
const browser = createBrowser({ session: `designer-${key}` });
|
|
185
187
|
const results = await runHealth(browser);
|
|
186
|
-
const worst = results.some((r) => r.status === 'fail') ? 'fail' : 'ok';
|
|
187
|
-
const icon = (s) => (s === 'ok' ? '✓' : s === 'fail' ? '✗' : '·');
|
|
188
|
-
for (const r of results) {
|
|
189
|
-
const line = `${icon(r.status)} [${r.category}] ${r.id} — ${r.description}${r.detail ? ' (' + r.detail + ')' : ''}`;
|
|
190
|
-
console.log(line);
|
|
191
|
-
}
|
|
192
188
|
const counts = results.reduce((acc, r) => {
|
|
193
189
|
acc[r.status] = (acc[r.status] || 0) + 1;
|
|
194
190
|
return acc;
|
|
195
191
|
}, {});
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
const fail = results.some((r) => r.status === 'fail');
|
|
193
|
+
const url = (await browser.url().catch(() => '')) || '';
|
|
194
|
+
if (flags.json === true || flags.json === '') {
|
|
195
|
+
const payload = {
|
|
196
|
+
ok: !fail,
|
|
197
|
+
generatedAt: new Date().toISOString(),
|
|
198
|
+
url,
|
|
199
|
+
counts: { ok: counts['ok'] || 0, fail: counts['fail'] || 0, skip: counts['skip'] || 0 },
|
|
200
|
+
results
|
|
201
|
+
};
|
|
202
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const icon = (s) => (s === 'ok' ? '✓' : s === 'fail' ? '✗' : '·');
|
|
206
|
+
for (const r of results) {
|
|
207
|
+
const line = `${icon(r.status)} [${r.category}] ${r.id} — ${r.description}${r.detail ? ' (' + r.detail + ')' : ''}`;
|
|
208
|
+
console.log(line);
|
|
209
|
+
}
|
|
210
|
+
console.log(`\n${counts['ok'] || 0} ok, ${counts['fail'] || 0} fail, ${counts['skip'] || 0} skip`);
|
|
211
|
+
}
|
|
212
|
+
if (fail)
|
|
198
213
|
process.exit(2);
|
|
199
214
|
break;
|
|
200
215
|
}
|
|
@@ -427,11 +442,15 @@ Checks: agent-browser on PATH, CDP reachable at DESIGNER_CDP port, a /design tab
|
|
|
427
442
|
selectors.json present, designer-loop skill installed at ~/.claude/skills/, MCP registration.
|
|
428
443
|
|
|
429
444
|
Exits with code 2 if any check fails.`,
|
|
430
|
-
health: `designer health — probe every UI anchor this MCP depends on.
|
|
445
|
+
health: `designer health [--json] — probe every UI anchor this MCP depends on.
|
|
431
446
|
|
|
432
447
|
Walks the current Chrome state (home / session) and checks each selector / button / URL /
|
|
433
448
|
DOM pattern we rely on. Reports pass / fail / skip per anchor with actionable detail.
|
|
434
449
|
|
|
450
|
+
Flags:
|
|
451
|
+
--json Emit a structured { ok, generatedAt, url, counts, results } JSON payload
|
|
452
|
+
on stdout instead of icons. Exit code is unchanged.
|
|
453
|
+
|
|
435
454
|
Exit code 2 on any fail — wire into cron or CI to catch UI regressions (e.g., claude.ai
|
|
436
455
|
moving the Share button) before users do.`,
|
|
437
456
|
'mcp': `designer mcp serve — start the MCP stdio server.
|
|
@@ -195,17 +195,42 @@ export class DesignerController {
|
|
|
195
195
|
const { promptTextarea, sendButton } = this.selectors.composer;
|
|
196
196
|
await this.browser.waitFor(promptTextarea);
|
|
197
197
|
await this.browser.evalValue(`(() => {
|
|
198
|
-
const
|
|
199
|
-
if (!
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
198
|
+
const el = document.querySelector(${JSON.stringify(promptTextarea)});
|
|
199
|
+
if (!el) throw new Error('composer input not found');
|
|
200
|
+
const text = ${JSON.stringify(prompt)};
|
|
201
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
202
|
+
// Bypass React's value ownership via the native setter, then fire a
|
|
203
|
+
// bubbling input event.
|
|
204
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
205
|
+
setter.call(el, text);
|
|
206
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
207
|
+
el.focus();
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (el.isContentEditable) {
|
|
211
|
+
// Deliver the text as a synthetic paste so the editor's own paste
|
|
212
|
+
// pipeline updates its internal state; execCommand('insertText')
|
|
213
|
+
// flattens multi-line prompts into one paragraph.
|
|
214
|
+
el.focus();
|
|
215
|
+
const sel = window.getSelection();
|
|
216
|
+
const range = document.createRange();
|
|
217
|
+
range.selectNodeContents(el);
|
|
218
|
+
sel.removeAllRanges();
|
|
219
|
+
sel.addRange(range);
|
|
220
|
+
const dt = new DataTransfer();
|
|
221
|
+
dt.setData('text/plain', text);
|
|
222
|
+
const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
223
|
+
if (unhandled) {
|
|
224
|
+
// No editor intercepted the paste — plain contenteditable fallback.
|
|
225
|
+
document.execCommand('insertText', false, text);
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
throw new Error('composer input is neither textarea nor contenteditable: ' + el.tagName);
|
|
205
230
|
})()`);
|
|
206
231
|
for (let i = 0; i < 30; i++) {
|
|
207
232
|
await new Promise((r) => setTimeout(r, 150));
|
|
208
|
-
const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled; })()`);
|
|
233
|
+
const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled || b.getAttribute('aria-disabled') === 'true'; })()`);
|
|
209
234
|
if (!disabled)
|
|
210
235
|
break;
|
|
211
236
|
}
|
|
@@ -520,7 +545,9 @@ export class DesignerController {
|
|
|
520
545
|
if (!opened)
|
|
521
546
|
await this._clickButtonByText(/^Export$/);
|
|
522
547
|
await new Promise((r) => setTimeout(r, 400));
|
|
523
|
-
await this.
|
|
548
|
+
const viaSendTo = await this._clickClaudeCodeSendTo().catch(() => false);
|
|
549
|
+
if (!viaSendTo)
|
|
550
|
+
await this._clickButtonByText(/handoff to claude code/i);
|
|
524
551
|
let handoffUrl = '';
|
|
525
552
|
for (let i = 0; i < 30; i++) {
|
|
526
553
|
await new Promise((r) => setTimeout(r, 300));
|
|
@@ -583,6 +610,28 @@ export class DesignerController {
|
|
|
583
610
|
return true;
|
|
584
611
|
})()`);
|
|
585
612
|
}
|
|
613
|
+
async _clickClaudeCodeSendTo() {
|
|
614
|
+
const tabClicked = await this.browser.evalValue(`(() => {
|
|
615
|
+
const tab = Array.from(document.querySelectorAll('button[role="tab"]')).find(t => /send to/i.test(t.textContent || ''));
|
|
616
|
+
if (!tab) return false;
|
|
617
|
+
tab.click();
|
|
618
|
+
return true;
|
|
619
|
+
})()`);
|
|
620
|
+
if (!tabClicked)
|
|
621
|
+
return false;
|
|
622
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
623
|
+
return this.browser.evalValue(`(() => {
|
|
624
|
+
const sends = Array.from(document.querySelectorAll('button')).filter(b => (b.textContent || '').trim() === 'Send');
|
|
625
|
+
const target = sends.find(b => {
|
|
626
|
+
let row = b;
|
|
627
|
+
for (let i = 0; i < 3 && row.parentElement; i++) row = row.parentElement;
|
|
628
|
+
return /claude code/i.test(row.textContent || '');
|
|
629
|
+
});
|
|
630
|
+
if (!target) return false;
|
|
631
|
+
target.click();
|
|
632
|
+
return true;
|
|
633
|
+
})()`);
|
|
634
|
+
}
|
|
586
635
|
async _dialogText() {
|
|
587
636
|
return ((await this.browser
|
|
588
637
|
.evalValue(`(() => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export function findAnchor(source, id) {
|
|
3
|
+
const sf = ts.createSourceFile('ui-anchors.ts', source, ts.ScriptTarget.Latest, true);
|
|
4
|
+
let result = null;
|
|
5
|
+
const visit = (node) => {
|
|
6
|
+
if (result)
|
|
7
|
+
return;
|
|
8
|
+
if (ts.isObjectLiteralExpression(node) && matchesAnchorWithId(node, id)) {
|
|
9
|
+
const checkProp = findProperty(node, 'check');
|
|
10
|
+
if (checkProp) {
|
|
11
|
+
const sel = extractSimpleHasSelectorArg(checkProp.initializer, sf);
|
|
12
|
+
if (sel)
|
|
13
|
+
result = sel;
|
|
14
|
+
}
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
ts.forEachChild(node, visit);
|
|
18
|
+
};
|
|
19
|
+
visit(sf);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
export function canPatch(source, id) {
|
|
23
|
+
return findAnchor(source, id) !== null;
|
|
24
|
+
}
|
|
25
|
+
export function patchSelector(source, id, newSelector) {
|
|
26
|
+
const match = findAnchor(source, id);
|
|
27
|
+
if (!match) {
|
|
28
|
+
throw new Error(`anchor-patcher: id "${id}" is not patchable — either not found or its check is not the simple hasSelector(b, '...') shape`);
|
|
29
|
+
}
|
|
30
|
+
const escaped = escapeForQuote(newSelector, match.quote);
|
|
31
|
+
return (source.slice(0, match.literalStart) +
|
|
32
|
+
match.quote +
|
|
33
|
+
escaped +
|
|
34
|
+
match.quote +
|
|
35
|
+
source.slice(match.literalEnd));
|
|
36
|
+
}
|
|
37
|
+
function matchesAnchorWithId(obj, id) {
|
|
38
|
+
const idProp = findProperty(obj, 'id');
|
|
39
|
+
if (!idProp)
|
|
40
|
+
return false;
|
|
41
|
+
const init = idProp.initializer;
|
|
42
|
+
if (ts.isStringLiteralLike(init)) {
|
|
43
|
+
return init.text === id;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function findProperty(obj, name) {
|
|
48
|
+
for (const p of obj.properties) {
|
|
49
|
+
if (!ts.isPropertyAssignment(p))
|
|
50
|
+
continue;
|
|
51
|
+
const n = p.name;
|
|
52
|
+
if (ts.isIdentifier(n) && n.text === name)
|
|
53
|
+
return p;
|
|
54
|
+
if (ts.isStringLiteral(n) && n.text === name)
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function extractSimpleHasSelectorArg(expr, sf) {
|
|
60
|
+
if (!ts.isArrowFunction(expr))
|
|
61
|
+
return null;
|
|
62
|
+
let body = expr.body;
|
|
63
|
+
if (ts.isParenthesizedExpression(body))
|
|
64
|
+
body = body.expression;
|
|
65
|
+
if (!ts.isObjectLiteralExpression(body))
|
|
66
|
+
return null;
|
|
67
|
+
if (body.properties.length !== 1)
|
|
68
|
+
return null;
|
|
69
|
+
const okProp = body.properties[0];
|
|
70
|
+
if (!okProp || !ts.isPropertyAssignment(okProp))
|
|
71
|
+
return null;
|
|
72
|
+
if (!ts.isIdentifier(okProp.name) || okProp.name.text !== 'ok')
|
|
73
|
+
return null;
|
|
74
|
+
const okValue = okProp.initializer;
|
|
75
|
+
if (!ts.isAwaitExpression(okValue))
|
|
76
|
+
return null;
|
|
77
|
+
const call = okValue.expression;
|
|
78
|
+
if (!ts.isCallExpression(call))
|
|
79
|
+
return null;
|
|
80
|
+
if (!ts.isIdentifier(call.expression) || call.expression.text !== 'hasSelector')
|
|
81
|
+
return null;
|
|
82
|
+
if (call.arguments.length !== 2)
|
|
83
|
+
return null;
|
|
84
|
+
const [arg0, arg1] = call.arguments;
|
|
85
|
+
if (!arg0 || !arg1)
|
|
86
|
+
return null;
|
|
87
|
+
if (!ts.isIdentifier(arg0) || arg0.text !== 'b')
|
|
88
|
+
return null;
|
|
89
|
+
if (!ts.isStringLiteralLike(arg1))
|
|
90
|
+
return null;
|
|
91
|
+
const raw = arg1.getText(sf);
|
|
92
|
+
const quote = detectQuote(raw);
|
|
93
|
+
if (!quote)
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
literalStart: arg1.getStart(sf),
|
|
97
|
+
literalEnd: arg1.getEnd(),
|
|
98
|
+
quote,
|
|
99
|
+
currentSelector: arg1.text
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function detectQuote(literalText) {
|
|
103
|
+
const first = literalText[0];
|
|
104
|
+
if (first === "'" || first === '"' || first === '`')
|
|
105
|
+
return first;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function escapeForQuote(s, quote) {
|
|
109
|
+
let out = s.replace(/\\/g, '\\\\');
|
|
110
|
+
if (quote === "'")
|
|
111
|
+
out = out.replace(/'/g, "\\'");
|
|
112
|
+
else if (quote === '"')
|
|
113
|
+
out = out.replace(/"/g, '\\"');
|
|
114
|
+
else {
|
|
115
|
+
out = out.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|