@tandryukha/aidemo 0.8.0 → 0.14.1
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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +609 -45
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +70 -6
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +580 -22
- package/src/compose.ts +1006 -93
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +9 -3
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +156 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +159 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +629 -0
- package/src/inspect.ts +307 -0
- package/src/lint.ts +601 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +4 -1
- package/src/mcp/server.ts +403 -26
- package/src/player.ts +975 -32
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/recorder.ts +252 -15
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +643 -5
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page inspection — selector discovery without an LLM in the loop.
|
|
3
|
+
*
|
|
4
|
+
* `inspect_page` / `aidemo inspect <url>` opens the URL in the recording
|
|
5
|
+
* profile (logged-in state included), waits for it to settle, and returns
|
|
6
|
+
* every visible interactive element with a ranked list of selectors that
|
|
7
|
+
* are UNIQUE on the page right now (data-testid → id → aria-label →
|
|
8
|
+
* role/text → name/placeholder → tag.class → structural path). The same scan
|
|
9
|
+
* feeds the drift suggestions written next to a failed take, so an agent can
|
|
10
|
+
* fix a selector from the failure artifact instead of guessing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { chromium, type Frame, type Page } from "playwright";
|
|
14
|
+
import { chromeProfileDir } from "./config.js";
|
|
15
|
+
import { ensureProfileUnlocked } from "./login.js";
|
|
16
|
+
|
|
17
|
+
export interface InspectElement {
|
|
18
|
+
/** ARIA role (explicit or implied by the tag). */
|
|
19
|
+
role: string;
|
|
20
|
+
/** Accessible-ish name: aria-label, text, placeholder, alt, title, value. */
|
|
21
|
+
name: string;
|
|
22
|
+
tag: string;
|
|
23
|
+
testid?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
href?: string;
|
|
26
|
+
/** Best unique selector (first of `selectors`). */
|
|
27
|
+
selector: string;
|
|
28
|
+
/** All unique selectors, best first. Empty when nothing unique was found. */
|
|
29
|
+
selectors: string[];
|
|
30
|
+
/** Viewport-relative box in CSS px. */
|
|
31
|
+
rect: { x: number; y: number; w: number; h: number };
|
|
32
|
+
inViewport: boolean;
|
|
33
|
+
/** Storyboard `frames` name when the element lives in an iframe. */
|
|
34
|
+
frame?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InspectResult {
|
|
38
|
+
url: string;
|
|
39
|
+
finalUrl: string;
|
|
40
|
+
title: string;
|
|
41
|
+
viewport: { width: number; height: number };
|
|
42
|
+
headings: Array<{ level: number; text: string }>;
|
|
43
|
+
elements: InspectElement[];
|
|
44
|
+
/** iframes on the page (name/src), so `frames` can be authored. */
|
|
45
|
+
iframes: Array<{ name: string; src: string; selector: string }>;
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
screenshot?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InspectOptions {
|
|
51
|
+
url: string;
|
|
52
|
+
profileDir?: string;
|
|
53
|
+
headless?: boolean;
|
|
54
|
+
viewport?: { width: number; height: number };
|
|
55
|
+
/** Max elements returned (default 80). */
|
|
56
|
+
limit?: number;
|
|
57
|
+
/** Write a viewport screenshot here. */
|
|
58
|
+
screenshotPath?: string;
|
|
59
|
+
/** Storyboard `frames` map (name → iframe selector) to scan as well. */
|
|
60
|
+
frames?: Record<string, string>;
|
|
61
|
+
/** Settle time after load (ms, default 1200). */
|
|
62
|
+
settleMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_LIMIT = 80;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* In-page scan. Kept as a STRING (not a function) on purpose: tsx's keepNames
|
|
69
|
+
* transform breaks named inner functions inside page.evaluate callbacks
|
|
70
|
+
* (see AGENTS.md); a string is evaluated verbatim by the browser.
|
|
71
|
+
*/
|
|
72
|
+
const SCAN_JS = String.raw`(() => {
|
|
73
|
+
const LIMIT = __LIMIT__;
|
|
74
|
+
const IMPLIED = { a: "link", button: "button", input: "textbox", select: "combobox", textarea: "textbox", summary: "button", option: "option" };
|
|
75
|
+
const sel = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [role=radio], [role=switch], [role=option], [role=textbox], [role=combobox], [contenteditable=true], [tabindex]:not([tabindex="-1"]), [onclick]';
|
|
76
|
+
const all = Array.from(document.querySelectorAll(sel));
|
|
77
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
78
|
+
const cssEsc = (s) => (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/["\\]/g, "\\$&");
|
|
79
|
+
const q = (s) => s.replace(/["\\]/g, "\\$&");
|
|
80
|
+
const norm = (s) => (s || "").replace(/\s+/g, " ").trim();
|
|
81
|
+
const textOf = (el) => norm(el.innerText || el.textContent || "");
|
|
82
|
+
const nameOf = (el) => {
|
|
83
|
+
const aria = el.getAttribute("aria-label");
|
|
84
|
+
if (aria) return norm(aria);
|
|
85
|
+
const lab = el.getAttribute("aria-labelledby");
|
|
86
|
+
if (lab) { const t = lab.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map(textOf).join(" "); if (t) return t; }
|
|
87
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") {
|
|
88
|
+
const ph = el.getAttribute("placeholder"); if (ph) return norm(ph);
|
|
89
|
+
if (el.id) { const l = document.querySelector('label[for="' + cssEsc(el.id) + '"]'); if (l) return textOf(l); }
|
|
90
|
+
const pl = el.closest("label"); if (pl) return textOf(pl);
|
|
91
|
+
const v = el.getAttribute("value"); if (v && (el.type === "submit" || el.type === "button")) return norm(v);
|
|
92
|
+
return norm(el.getAttribute("name") || el.getAttribute("title") || "");
|
|
93
|
+
}
|
|
94
|
+
const t = textOf(el); if (t) return t.slice(0, 80);
|
|
95
|
+
const img = el.querySelector("img[alt]"); if (img) return norm(img.getAttribute("alt"));
|
|
96
|
+
return norm(el.getAttribute("title") || "");
|
|
97
|
+
};
|
|
98
|
+
const roleOf = (el) => el.getAttribute("role") || IMPLIED[el.tagName.toLowerCase()] || (el.getAttribute("contenteditable") === "true" ? "textbox" : "generic");
|
|
99
|
+
const visible = (el) => {
|
|
100
|
+
const r = el.getBoundingClientRect();
|
|
101
|
+
if (r.width < 2 || r.height < 2) return false;
|
|
102
|
+
const cs = getComputedStyle(el);
|
|
103
|
+
return cs.visibility !== "hidden" && cs.display !== "none" && parseFloat(cs.opacity || "1") > 0.05;
|
|
104
|
+
};
|
|
105
|
+
const unique = (s) => { try { return document.querySelectorAll(s).length === 1; } catch (e) { return false; } };
|
|
106
|
+
const stableId = (id) => /^[A-Za-z][\w-]*$/.test(id) && !/\d{3,}/.test(id) && !/^(radix|headlessui|mui)-/i.test(id);
|
|
107
|
+
const stableClass = (c) => /^[A-Za-z][\w-]{1,40}$/.test(c) && !/[0-9a-f]{5,}/i.test(c) && !/^(css|sc|jsx|emotion)-/.test(c) && !/_[A-Za-z0-9]{4,}$/.test(c);
|
|
108
|
+
const items = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const el of all) {
|
|
111
|
+
if (!visible(el)) continue;
|
|
112
|
+
if (seen.has(el)) continue; seen.add(el);
|
|
113
|
+
const tag = el.tagName.toLowerCase();
|
|
114
|
+
const name = nameOf(el);
|
|
115
|
+
const role = roleOf(el);
|
|
116
|
+
const rect = el.getBoundingClientRect();
|
|
117
|
+
const cands = [];
|
|
118
|
+
for (const attr of ["data-testid", "data-test", "data-cy", "data-qa"]) {
|
|
119
|
+
const v = el.getAttribute(attr); if (v) cands.push("[" + attr + '="' + q(v) + '"]');
|
|
120
|
+
}
|
|
121
|
+
if (el.id && stableId(el.id)) cands.push("#" + cssEsc(el.id));
|
|
122
|
+
const aria = el.getAttribute("aria-label"); if (aria) cands.push(tag + '[aria-label="' + q(aria) + '"]');
|
|
123
|
+
const nm = el.getAttribute("name"); if (nm && (tag === "input" || tag === "select" || tag === "textarea" || tag === "button")) cands.push(tag + '[name="' + q(nm) + '"]');
|
|
124
|
+
const ph = el.getAttribute("placeholder"); if (ph) cands.push(tag + '[placeholder="' + q(ph) + '"]');
|
|
125
|
+
const href = el.getAttribute("href"); if (href && tag === "a" && href.length < 80 && !/^(#|javascript:)/.test(href)) cands.push('a[href="' + q(href) + '"]');
|
|
126
|
+
const classes = Array.from(el.classList).filter(stableClass).slice(0, 2);
|
|
127
|
+
if (classes.length) cands.push(tag + "." + classes.map(cssEsc).join("."));
|
|
128
|
+
const uniq = cands.filter(unique);
|
|
129
|
+
// role/text: Playwright-only syntax, uniqueness = same tag + same text count.
|
|
130
|
+
const t = textOf(el);
|
|
131
|
+
if (t && t.length <= 40 && (tag === "button" || tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem")) {
|
|
132
|
+
const same = all.filter((o) => o !== el && o.tagName === el.tagName && textOf(o) === t && visible(o)).length;
|
|
133
|
+
if (same === 0) uniq.push(tag + ':has-text("' + q(t) + '")');
|
|
134
|
+
}
|
|
135
|
+
if (!uniq.length) {
|
|
136
|
+
// Structural fallback: short nth-of-type path up to a landmark/id.
|
|
137
|
+
let node = el; const parts = [];
|
|
138
|
+
for (let d = 0; node && node !== document.body && d < 4; d++) {
|
|
139
|
+
const tg = node.tagName.toLowerCase();
|
|
140
|
+
if (node.id && stableId(node.id)) { parts.unshift("#" + cssEsc(node.id)); break; }
|
|
141
|
+
const sibs = node.parentElement ? Array.from(node.parentElement.children).filter((c) => c.tagName === node.tagName) : [];
|
|
142
|
+
parts.unshift(sibs.length > 1 ? tg + ":nth-of-type(" + (sibs.indexOf(node) + 1) + ")" : tg);
|
|
143
|
+
node = node.parentElement;
|
|
144
|
+
}
|
|
145
|
+
const path = parts.join(" > ");
|
|
146
|
+
if (unique(path)) uniq.push(path);
|
|
147
|
+
}
|
|
148
|
+
const item = {
|
|
149
|
+
role, name, tag,
|
|
150
|
+
selector: uniq[0] || "",
|
|
151
|
+
selectors: uniq,
|
|
152
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
153
|
+
inViewport: rect.bottom > 0 && rect.right > 0 && rect.top < vh && rect.left < vw,
|
|
154
|
+
};
|
|
155
|
+
const tid = el.getAttribute("data-testid") || el.getAttribute("data-test") || el.getAttribute("data-cy");
|
|
156
|
+
if (tid) item.testid = tid;
|
|
157
|
+
if (el.id) item.id = el.id;
|
|
158
|
+
if (href && tag === "a") item.href = href;
|
|
159
|
+
items.push(item);
|
|
160
|
+
if (items.length >= LIMIT) break;
|
|
161
|
+
}
|
|
162
|
+
const headings = Array.from(document.querySelectorAll("h1, h2, h3")).filter(visible).slice(0, 30)
|
|
163
|
+
.map((h) => ({ level: Number(h.tagName[1]), text: textOf(h).slice(0, 120) }));
|
|
164
|
+
const iframes = Array.from(document.querySelectorAll("iframe")).filter(visible).slice(0, 20).map((f, i) => ({
|
|
165
|
+
name: f.getAttribute("name") || f.getAttribute("title") || ("iframe-" + (i + 1)),
|
|
166
|
+
src: (f.getAttribute("src") || "").slice(0, 200),
|
|
167
|
+
selector: f.id && stableId(f.id) ? "#" + cssEsc(f.id) : (f.getAttribute("name") ? 'iframe[name="' + q(f.getAttribute("name")) + '"]' : "iframe:nth-of-type(" + (i + 1) + ")"),
|
|
168
|
+
}));
|
|
169
|
+
return { title: document.title, elements: items, headings, iframes, truncated: all.length > LIMIT && items.length >= LIMIT, total: all.length };
|
|
170
|
+
})()`;
|
|
171
|
+
|
|
172
|
+
interface ScanResult {
|
|
173
|
+
title: string;
|
|
174
|
+
elements: InspectElement[];
|
|
175
|
+
headings: Array<{ level: number; text: string }>;
|
|
176
|
+
iframes: Array<{ name: string; src: string; selector: string }>;
|
|
177
|
+
truncated: boolean;
|
|
178
|
+
total: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Scan one frame for visible interactive elements (see SCAN_JS). */
|
|
182
|
+
export async function scanInteractive(
|
|
183
|
+
target: Page | Frame,
|
|
184
|
+
limit = DEFAULT_LIMIT
|
|
185
|
+
): Promise<ScanResult> {
|
|
186
|
+
return (await target.evaluate(SCAN_JS.replace("__LIMIT__", String(limit)))) as ScanResult;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Open the URL in the recording profile and scan it. */
|
|
190
|
+
export async function inspectPage(opts: InspectOptions): Promise<InspectResult> {
|
|
191
|
+
const profileDir = opts.profileDir ?? chromeProfileDir();
|
|
192
|
+
await ensureProfileUnlocked(profileDir);
|
|
193
|
+
const viewport = opts.viewport ?? { width: 1280, height: 720 };
|
|
194
|
+
const context = await chromium.launchPersistentContext(profileDir, {
|
|
195
|
+
channel: "chrome",
|
|
196
|
+
headless: opts.headless !== false,
|
|
197
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
198
|
+
viewport,
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
202
|
+
await page.goto(opts.url, { waitUntil: "domcontentloaded" });
|
|
203
|
+
await page
|
|
204
|
+
.waitForLoadState("networkidle", { timeout: 4000 })
|
|
205
|
+
.catch(() => {});
|
|
206
|
+
await page.waitForTimeout(opts.settleMs ?? 1200);
|
|
207
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
208
|
+
const main = await scanInteractive(page, limit);
|
|
209
|
+
const elements = [...main.elements];
|
|
210
|
+
let truncated = main.truncated;
|
|
211
|
+
// Authored iframes (storyboard `frames`): scan each, tagging the frame name.
|
|
212
|
+
for (const [name, selector] of Object.entries(opts.frames ?? {})) {
|
|
213
|
+
const handle = await page.$(selector).catch(() => null);
|
|
214
|
+
const frame = handle ? await handle.contentFrame().catch(() => null) : null;
|
|
215
|
+
if (!frame) continue;
|
|
216
|
+
const sub = await scanInteractive(frame, Math.max(10, limit - elements.length)).catch(() => null);
|
|
217
|
+
if (!sub) continue;
|
|
218
|
+
for (const el of sub.elements) elements.push({ ...el, frame: name });
|
|
219
|
+
truncated = truncated || sub.truncated;
|
|
220
|
+
}
|
|
221
|
+
let screenshot: string | undefined;
|
|
222
|
+
if (opts.screenshotPath) {
|
|
223
|
+
await page.screenshot({ path: opts.screenshotPath, timeout: 8000 }).catch(() => {});
|
|
224
|
+
screenshot = opts.screenshotPath;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
url: opts.url,
|
|
228
|
+
finalUrl: page.url(),
|
|
229
|
+
title: main.title,
|
|
230
|
+
viewport,
|
|
231
|
+
headings: main.headings,
|
|
232
|
+
elements,
|
|
233
|
+
iframes: main.iframes,
|
|
234
|
+
truncated,
|
|
235
|
+
...(screenshot ? { screenshot } : {}),
|
|
236
|
+
};
|
|
237
|
+
} finally {
|
|
238
|
+
await context.close().catch(() => {});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Drift ranking: which elements on the page look like the one a failed
|
|
244
|
+
// selector was after? Pure string similarity over what the selector encodes
|
|
245
|
+
// (testid / id / text / classes / tag) vs each element's testid / id / name.
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
function bigrams(s: string): Set<string> {
|
|
249
|
+
const t = s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
250
|
+
const out = new Set<string>();
|
|
251
|
+
for (const w of t.split(" ")) {
|
|
252
|
+
if (!w) continue;
|
|
253
|
+
if (w.length === 1) out.add(w);
|
|
254
|
+
for (let i = 0; i + 1 < w.length; i++) out.add(w.slice(i, i + 2));
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Dice coefficient over character bigrams (0..1). */
|
|
260
|
+
export function similarity(a: string, b: string): number {
|
|
261
|
+
if (!a || !b) return 0;
|
|
262
|
+
const A = bigrams(a);
|
|
263
|
+
const B = bigrams(b);
|
|
264
|
+
if (!A.size || !B.size) return 0;
|
|
265
|
+
let hit = 0;
|
|
266
|
+
for (const g of A) if (B.has(g)) hit++;
|
|
267
|
+
return (2 * hit) / (A.size + B.size);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** What a selector is "about": the human-ish tokens it encodes. */
|
|
271
|
+
export function selectorHints(selector: string): { tag?: string; text: string[]; ids: string[] } {
|
|
272
|
+
const text: string[] = [];
|
|
273
|
+
const ids: string[] = [];
|
|
274
|
+
for (const m of selector.matchAll(/:(?:has-text|text-is|text)\(\s*["']?([^"')]+)["']?\s*\)/g)) text.push(m[1]);
|
|
275
|
+
for (const m of selector.matchAll(/\[(?:data-testid|data-test|data-cy|data-qa|name|aria-label|placeholder|id)\s*[*^$~|]?=\s*["']?([^"'\]]+)["']?\]/g)) ids.push(m[1]);
|
|
276
|
+
for (const m of selector.matchAll(/#([\w-]+)/g)) ids.push(m[1]);
|
|
277
|
+
for (const m of selector.matchAll(/\.([A-Za-z][\w-]+)/g)) ids.push(m[1]);
|
|
278
|
+
const tagM = selector.match(/(?:^|[\s>+~])([a-z][a-z0-9]*)(?=[.#\[:]|$)/);
|
|
279
|
+
return { ...(tagM ? { tag: tagM[1] } : {}), text, ids };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface DriftCandidate extends InspectElement {
|
|
283
|
+
score: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Rank scanned elements by how much they resemble what `selector` asked for. */
|
|
287
|
+
export function rankCandidates(
|
|
288
|
+
selector: string,
|
|
289
|
+
elements: InspectElement[],
|
|
290
|
+
limit = 8
|
|
291
|
+
): DriftCandidate[] {
|
|
292
|
+
const hints = selectorHints(selector);
|
|
293
|
+
const wanted = [...hints.text, ...hints.ids.map((s) => s.replace(/[-_]/g, " "))];
|
|
294
|
+
const scored = elements.map((el) => {
|
|
295
|
+
let s = 0;
|
|
296
|
+
const own = [el.name, el.testid ?? "", el.id ?? ""].filter(Boolean);
|
|
297
|
+
for (const w of wanted) for (const o of own) s = Math.max(s, similarity(w, o));
|
|
298
|
+
if (hints.tag && hints.tag === el.tag) s += 0.1;
|
|
299
|
+
if (!wanted.length && hints.tag && hints.tag === el.tag) s = 0.3;
|
|
300
|
+
if (!el.selector) s *= 0.5;
|
|
301
|
+
return { ...el, score: Number(s.toFixed(3)) };
|
|
302
|
+
});
|
|
303
|
+
return scored
|
|
304
|
+
.filter((c) => c.score >= 0.25)
|
|
305
|
+
.sort((a, b) => b.score - a.score)
|
|
306
|
+
.slice(0, limit);
|
|
307
|
+
}
|