storebound/cli/console/debug.ts
CRT 81313b6fa4 console fill: no silent waits; an unknown page gets a debug dump and a bounded stop
A real `console fill apple-app-privacy` clicked Next onto a console page
the engine did not recognise and logged nothing for 8 hours.

- Step watchdog: after a click, or while the form is expected, no known
  page within --step-timeout seconds (default 30) -> "the page after
  "Next" is not a page storebound knows ... Buttons there: ...", a debug
  dump, and the manual steps. It keeps watching, but 20 min without
  progress closes the window and exits 1.
- A heartbeat line every minute while waiting (also during sign-in); the
  final review wait is bounded (2 h) with a reminder every 10 min.
- Debug dump (cli/console/debug.ts) in .storebound/console-debug/
  <form>-<time>/: page.json (URL without query, headings, dialogs, every
  visible control with role and name), page.html (rebuilt from a clone:
  no scripts, values, hidden inputs or non-whitelisted attributes) and a
  small screenshot. Emails, phone numbers and the header / account area
  are replaced; cookies, storage and tokens are never read.
  --debug-dump writes one at every step for bug reports.
- Guardrail tests: an unknown-page fixture (dump sanitized, bounded exit)
  and a scan of cli/console for while loops without a deadline and
  Playwright waits without a timeout (one waitForLoadState fixed).
- Pitfall 14 (docs/pitfalls.md, store-pitfalls skill); the
  console-autofill skill says how to send a dump.
2026-10-12 07:08:10 +08:00

123 lines
8.2 KiB
TypeScript

// Diagnostics of a console page storebound does not recognise (`console fill`): what is on the page, written to
// .storebound/console-debug/<form>-<time>/ (gitignored) so a bug report can attach the real console structure.
// page.json URL (query and fragment dropped), title, headings, dialog titles, every visible button, link, radio,
// checkbox, switch, combobox and textbox as { role, name } (checked state for choices, never a value)
// page.html the DOM rebuilt from a clone: scripts, styles, frames, hidden inputs and every attribute outside a short
// list (id, class, role, aria-*, type, for, name, hidden, disabled, checked, href without its query) are
// dropped; input / textarea values and selected options are never written
// screen.jpg the visible part of the window, small (JPEG, quality 50)
// Personal data: emails, phone-like numbers and the texts of the page header / account menu (where the account name
// and the signed-in user show) are replaced; cookies, storage, tokens and request data are never read. The screenshot
// shows what the window shows: the owner looks at it before sending it.
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/* eslint-disable @typescript-eslint/no-explicit-any */
type Page = any;
export interface PageSummary {
url: string;
title: string;
headings: string[];
dialogs: string[];
controls: { role: string; name: string; checked?: boolean }[];
}
const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
const PHONE = /\+?\d[\d\s().-]{7,}\d/g;
/** Emails and phone-like numbers out of a text (ids of 6+ digits without separators stay: they are app / account ids). */
export const scrub = (t: string): string => t.replace(EMAIL, '[email]').replace(PHONE, m => (/^\d+$/.test(m) ? m : '[number]'));
/** The URL without its query and fragment (they can carry tokens). */
export const bareUrl = (u: string): string => { try { const x = new URL(u); return `${x.origin}${x.pathname}`; } catch { return ''; } };
/** What the page shows, read in the page (labels only, never values). */
export async function summarize(page: Page): Promise<PageSummary> {
const r: Omit<PageSummary, 'url'> = await page.evaluate(() => {
const PRIVATE = 'header, [role="banner"], [aria-label*="account" i], [aria-label*="hesap" i]';
const shown = (el: Element) => !!(el as HTMLElement).getClientRects().length && getComputedStyle(el).visibility !== 'hidden';
const text = (el: Element | null) => (el?.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 120);
const name = (el: Element): string => {
if (el.closest(PRIVATE)) return '[account area]';
const by = el.getAttribute('aria-labelledby');
const ref = by ? by.split(/\s+/).map(id => text(document.getElementById(id))).join(' ') : '';
const labels = (el as HTMLInputElement).labels ? [...(el as HTMLInputElement).labels!].map(l => text(l)).join(' ') : '';
return (el.getAttribute('aria-label') || ref || labels || el.getAttribute('title') || el.getAttribute('placeholder') || text(el)).trim().slice(0, 120);
};
const roleOf = (el: Element): string => {
const r = el.getAttribute('role');
if (r) return r;
const t = el.tagName.toLowerCase(), type = (el.getAttribute('type') ?? '').toLowerCase();
if (t === 'a') return 'link';
if (t === 'button' || (t === 'input' && ['button', 'submit', 'reset'].includes(type))) return 'button';
if (t === 'select') return 'combobox';
if (t === 'textarea') return 'textbox';
if (t === 'input') return type === 'radio' ? 'radio' : type === 'checkbox' ? 'checkbox' : type === 'hidden' ? '' : 'textbox';
return t;
};
const controls: { role: string; name: string; checked?: boolean }[] = [];
const sel = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="radio"], [role="checkbox"], [role="switch"], [role="combobox"], [role="textbox"], [role="tab"], [role="menuitem"], [role="option"]';
for (const el of document.querySelectorAll(sel)) {
if (!shown(el)) continue;
const role = roleOf(el);
if (!role) continue;
const c: { role: string; name: string; checked?: boolean } = { role, name: name(el) };
if (['radio', 'checkbox', 'switch'].includes(role)) c.checked = (el as HTMLInputElement).checked ?? el.getAttribute('aria-checked') === 'true';
controls.push(c);
if (controls.length >= 400) break;
}
const headings = [...document.querySelectorAll('h1, h2, h3, h4, [role="heading"]')].filter(shown).map(h => text(h)).filter(Boolean).slice(0, 60);
const dialogs = [...document.querySelectorAll('[role="dialog"], dialog, [role="alertdialog"]')].filter(shown)
.map(d => d.getAttribute('aria-label') || text(d.querySelector('h1, h2, h3, [role="heading"]')) || '(untitled dialog)');
return { title: document.title.slice(0, 200), headings, dialogs, controls };
});
return {
url: bareUrl(page.url()), title: scrub(r.title), headings: r.headings.map(scrub), dialogs: r.dialogs.map(scrub),
controls: r.controls.map(c => ({ ...c, name: scrub(c.name) })),
};
}
/** The page's DOM without scripts, values, tokens or personal texts (see the header). */
export async function sanitizedHtml(page: Page): Promise<string> {
const html: string = await page.evaluate(() => {
const KEEP = new Set(['id', 'class', 'role', 'type', 'for', 'name', 'hidden', 'disabled', 'checked', 'href', 'tabindex', 'title', 'alt', 'placeholder', 'data-step']);
const root = document.documentElement.cloneNode(true) as HTMLElement;
root.querySelectorAll('script, style, noscript, iframe, object, embed, template, link, meta, input[type="hidden"], svg, canvas, video, audio').forEach(e => e.remove());
root.querySelectorAll('header, [role="banner"], [aria-label*="account" i], [aria-label*="hesap" i]').forEach(e => { e.textContent = '[account area]'; });
for (const el of root.querySelectorAll('*')) {
for (const a of [...el.attributes]) {
if (!(KEEP.has(a.name) || a.name.startsWith('aria-'))) { el.removeAttribute(a.name); continue; }
if (a.name === 'href') { try { const u = new URL(a.value, location.href); el.setAttribute('href', `${u.origin}${u.pathname}`); } catch { el.removeAttribute('href'); } }
}
if (el.tagName === 'INPUT') el.removeAttribute('value');
if (el.tagName === 'TEXTAREA') el.textContent = '';
if (el.tagName === 'OPTION') el.removeAttribute('selected');
if (el.tagName === 'IMG') el.removeAttribute('src');
}
return `<!doctype html>\n${root.outerHTML}`;
});
return scrub(html);
}
/** A short name for a dump folder ("after-next", "page-3"). */
const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'page';
/**
* Writes the dump of the page into `dir/<nn>-<label>/` (page.json, page.html, screen.jpg) and returns that folder and
* the summary. Never throws: a part that fails is left out (a note in page.json says which).
*/
export async function dumpPage(page: Page, dir: string, n: number, label: string): Promise<{ dir: string; summary: PageSummary | null }> {
const out = join(dir, `${String(n).padStart(2, '0')}-${slug(label)}`);
mkdirSync(out, { recursive: true });
const failed: string[] = [];
let summary: PageSummary | null = null;
try { summary = await summarize(page); } catch (e) { failed.push(`summary: ${(e as Error).message.split('\n')[0]}`); }
try { writeFileSync(join(out, 'page.html'), await sanitizedHtml(page)); } catch (e) { failed.push(`html: ${(e as Error).message.split('\n')[0]}`); }
try { await page.screenshot({ path: join(out, 'screen.jpg'), type: 'jpeg', quality: 50, scale: 'css', timeout: 10_000 }); } catch (e) { failed.push(`screenshot: ${(e as Error).message.split('\n')[0]}`); }
writeFileSync(join(out, 'page.json'), `${JSON.stringify({ label, at: new Date().toISOString(), ...(summary ?? {}), ...(failed.length ? { failed } : {}) }, null, 1)}\n`);
return { dir: out, summary };
}
/** The first `n` distinct button / link names of a summary, for the log. */
export const topButtons = (s: PageSummary | null, n = 10): string[] =>
[...new Set((s?.controls ?? []).filter(c => c.role === 'button' || c.role === 'link').map(c => c.name).filter(x => x && x !== '[account area]'))].slice(0, n);