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.
123 lines
8.2 KiB
TypeScript
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);
|