@pro-vi/designer 0.3.5 → 0.3.7
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 +56 -18
- 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 +38 -12
- package/package.json +12 -4
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.
|
|
@@ -542,18 +561,37 @@ async function checkOnDesignSurface() {
|
|
|
542
561
|
const port = process.env.DESIGNER_CDP || '9222';
|
|
543
562
|
try {
|
|
544
563
|
const res = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
545
|
-
if (!res.ok)
|
|
546
|
-
return {
|
|
564
|
+
if (!res.ok) {
|
|
565
|
+
return {
|
|
566
|
+
name: 'claude.ai/design tab',
|
|
567
|
+
status: 'fail',
|
|
568
|
+
detail: `CDP HTTP ${res.status} — debug Chrome may have died. Run \`designer setup\` to relaunch it.`
|
|
569
|
+
};
|
|
570
|
+
}
|
|
547
571
|
const tabs = await res.json();
|
|
548
572
|
const onDesign = tabs.find((t) => t.url && /claude\.ai\/design/.test(t.url));
|
|
549
|
-
if (!onDesign)
|
|
550
|
-
return {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
573
|
+
if (!onDesign) {
|
|
574
|
+
return {
|
|
575
|
+
name: 'claude.ai/design tab',
|
|
576
|
+
status: 'warn',
|
|
577
|
+
detail: 'no tab on claude.ai/design in the debug Chrome window. Open https://claude.ai/design THERE (not in your normal Chrome — they are separate profiles with separate cookies).'
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (/login|sign in/i.test(onDesign.title || '')) {
|
|
581
|
+
return {
|
|
582
|
+
name: 'signed in to claude.ai/design',
|
|
583
|
+
status: 'fail',
|
|
584
|
+
detail: 'on a login page. Sign in INSIDE the debug Chrome window (not your normal Chrome — that profile is signed in but its cookies are not shared).'
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return { name: 'signed in to claude.ai/design', status: 'ok', detail: onDesign.url };
|
|
554
588
|
}
|
|
555
589
|
catch {
|
|
556
|
-
return {
|
|
590
|
+
return {
|
|
591
|
+
name: 'claude.ai/design tab',
|
|
592
|
+
status: 'fail',
|
|
593
|
+
detail: 'debug Chrome unreachable. Run `designer setup` to launch it.'
|
|
594
|
+
};
|
|
557
595
|
}
|
|
558
596
|
}
|
|
559
597
|
function checkSelectors() {
|
|
@@ -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
|
+
}
|