console fill apple-app-privacy: set up each data type from the real page ('Set Up <type>' is a plain text, clicked by its exact text in the Data Types section), fill its dialog step by step, dump and pause on an unknown step; '?' and Publish never clicked
This commit is contained in:
parent
3aedf9e137
commit
e0bef82b06
7 changed files with 500 additions and 28 deletions
|
|
@ -30,7 +30,8 @@ import { join } from 'node:path';
|
|||
import type { Ctx } from '../lib/ctx';
|
||||
import { type Answer, type ConsoleResult, consoleResult, DISCLAIMER, FORM_IDS, type FormId, fillValue, formMeta, isForm, valueText } from '../lib/console-answers';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms';
|
||||
import { APPLE_PRIVACY_TYPES } from '../lib/app-privacy';
|
||||
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
|
||||
import { log, printJson, warn } from '../lib/log';
|
||||
import { openAcross, withAccountIds } from './accounts';
|
||||
import { appleAppId, withAppleAppId } from './app-id';
|
||||
|
|
@ -196,7 +197,7 @@ export interface FillStep {
|
|||
* ambiguous field -> fallback (nothing of that section is touched). Certain answers are set, the rest is highlighted
|
||||
* and left empty. Never clicks Next / Save. `answers` carry the values the page gets (fillValue).
|
||||
*/
|
||||
export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set<number> = new Set(), partial = false): Promise<FillStep> {
|
||||
export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set<number> = new Set(), partial = false, current?: string): Promise<FillStep> {
|
||||
const byId = new Map(answers.map(a => [a.id, a]));
|
||||
for (const [i, sec] of def.sections.entries()) {
|
||||
if (done.has(i)) continue;
|
||||
|
|
@ -209,6 +210,11 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
|
|||
const c = await l.count();
|
||||
if (c) { d = c === 1 ? { state: 'ok', loc: l } : { state: 'ambiguous' }; break; }
|
||||
}
|
||||
// the data type storebound just opened (App Privacy): its dialog may be titled otherwise; the one visible dialog
|
||||
if (d.state === 'none' && sec.type && sec.type === current) {
|
||||
const l = vis(page.getByRole('dialog'));
|
||||
if (await l.count() === 1) d = { state: 'ok', loc: l };
|
||||
}
|
||||
if (d.state === 'none') continue;
|
||||
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
|
||||
root = d.loc;
|
||||
|
|
@ -343,14 +349,25 @@ export async function flowWhere(loc: Locator, dataTypes: string[]): Promise<Flow
|
|||
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
|
||||
const r: { inDialog: boolean; inDataTypes: boolean } = await loc.evaluate((el: any, names: string[]) => {
|
||||
const inDialog = !!el.closest('[role="dialog"], dialog');
|
||||
const norm = (h: any) => (h.textContent ?? '').trim().replace(/\s+/g, ' ');
|
||||
let inDataTypes = false;
|
||||
// the nearest container around the control that has a heading decides: its first heading is "Data Types"
|
||||
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) {
|
||||
const h = a.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
|
||||
if (!h) continue;
|
||||
inDataTypes = names.includes((h.textContent ?? '').trim().replace(/\s+/g, ' '));
|
||||
inDataTypes = names.includes(norm(h));
|
||||
break;
|
||||
}
|
||||
// or the last visible heading before the control (outside dialogs) is "Data Types": the real page puts the heading
|
||||
// and its Edit in one box and the data types ("Set Up <type>") in the next one
|
||||
if (!inDataTypes) {
|
||||
let last: any = null;
|
||||
for (const h of el.ownerDocument.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
|
||||
if (h.closest('[role="dialog"], dialog') || !h.getClientRects().length || h.contains(el)) continue;
|
||||
if (h.compareDocumentPosition(el) & 4) last = h; else break;
|
||||
}
|
||||
inDataTypes = !!last && names.includes(norm(last));
|
||||
}
|
||||
return { inDialog, inDataTypes };
|
||||
}, dt);
|
||||
return { privacyPage, dataTypes, ...r };
|
||||
|
|
@ -371,7 +388,7 @@ export async function flowClick(loc: Locator, form: FormId, dataTypes: string[],
|
|||
}
|
||||
|
||||
/** Visible controls named like `names` (buttons and links) that sit where `ok` says; the one match, or null. */
|
||||
async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[]): Promise<Locator | null> {
|
||||
async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[], pick: 'one' | 'first' = 'one'): Promise<Locator | null> {
|
||||
const hits: Locator[] = [];
|
||||
for (const role of ['button', 'link']) {
|
||||
for (const n of names) {
|
||||
|
|
@ -380,9 +397,119 @@ async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) =>
|
|||
for (let i = 0; i < c; i++) { const l = all.nth(i); if (ok(await flowWhere(l, dataTypes))) hits.push(l); }
|
||||
}
|
||||
}
|
||||
if (pick === 'first' && hits.length > 1) {
|
||||
// the first in document order (Edit next to the Data Types heading comes before the Edit of any set-up type)
|
||||
return firstInOrder(hits);
|
||||
}
|
||||
return hits.length === 1 ? hits[0]! : null;
|
||||
}
|
||||
|
||||
async function firstInOrder(hits: Locator[]): Promise<Locator | null> {
|
||||
let best: Locator | null = null;
|
||||
for (const h of hits) {
|
||||
if (!best) { best = h; continue; }
|
||||
const earlier = await h.evaluate((a: any, b: any) => !!(a.compareDocumentPosition(b) & 4), await best.elementHandle({ timeout: ACTION_MS })).catch(() => false);
|
||||
if (earlier) best = h;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ---- App Privacy's Data Types section: "Set Up <type>" is a plain text (a <p> in a div with a click handler) ----
|
||||
|
||||
const textOf = async (loc: Locator): Promise<string> => ((await loc.evaluate((el: any) => el.textContent ?? '')) as string).trim().replace(/\s+/g, ' ');
|
||||
|
||||
/** "Set Up <type>" (or a console label of it) by exact text in the Data Types section, outside dialogs: the one match or null. */
|
||||
async function setUpControl(page: Page, t: string, dataTypes: string[]): Promise<Locator | null> {
|
||||
const hits: Locator[] = [];
|
||||
for (const text of setUpTexts(t)) {
|
||||
const all = vis(page.getByText(text, { exact: true }));
|
||||
const c = await all.count();
|
||||
for (let i = 0; i < c; i++) {
|
||||
const l = all.nth(i);
|
||||
if (setUpAllowed(await textOf(l), await flowWhere(l, dataTypes))) hits.push(l);
|
||||
}
|
||||
}
|
||||
return hits.length === 1 ? hits[0]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Set Up click: the element's whole text must be "Set Up <a data type storebound answers>" in the Data Types
|
||||
* section of the App Privacy page, outside any dialog (setUpAllowed). Logged like every flow click.
|
||||
*/
|
||||
export async function setUpClick(loc: Locator, dataTypes: string[], say: (m: string) => void, onClick?: (name: string) => void): Promise<boolean> {
|
||||
const text = await textOf(loc);
|
||||
if (!setUpAllowed(text, await flowWhere(loc, dataTypes))) { warn(TAG, `not clicking "${text}": only "Set Up <data type>" in the Data Types section is clicked by its text`); return false; }
|
||||
await loc.click({ timeout: ACTION_MS });
|
||||
say(`clicked "${text}" (navigation)`);
|
||||
onClick?.(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Every App Privacy type label the console shows (Apple's names and the console's own) -> Apple's name. */
|
||||
const TYPE_OF_LABEL = new Map<string, string>(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [l, n] as [string, string])));
|
||||
|
||||
/** The leaf texts of the Data Types section (after its heading, up to the next heading, outside dialogs). Read-only. */
|
||||
async function dataTypesTexts(page: Page): Promise<string[]> {
|
||||
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
|
||||
return page.evaluate((names: string[]) => {
|
||||
const shown = (e: any) => !!e.getClientRects().length;
|
||||
const norm = (e: any) => (e.textContent ?? '').trim().replace(/\s+/g, ' ');
|
||||
const hs = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')].filter(h => !h.closest('[role="dialog"], dialog') && shown(h));
|
||||
const at = hs.findIndex(h => names.includes(norm(h)));
|
||||
if (at < 0) return [];
|
||||
const start = hs[at]!, end = hs[at + 1] ?? null;
|
||||
const out: string[] = [];
|
||||
for (const e of document.querySelectorAll('body *')) {
|
||||
if (!(start.compareDocumentPosition(e) & 4) || start.contains(e)) continue;
|
||||
if (end && !(e.compareDocumentPosition(end) & 4)) break;
|
||||
if (e.children.length || e.closest('[role="dialog"], dialog') || !shown(e)) continue;
|
||||
const t = norm(e);
|
||||
if (t) out.push(t);
|
||||
}
|
||||
return out;
|
||||
}, dt);
|
||||
}
|
||||
|
||||
/** The data types the Data Types section lists (by their label or their "Set Up <type>"), as Apple's names. Read-only. */
|
||||
export async function listedTypes(page: Page): Promise<string[]> {
|
||||
const out = new Set<string>();
|
||||
for (const t of await dataTypesTexts(page)) {
|
||||
const name = TYPE_OF_LABEL.get(t.replace(/^Set Up /, '').replace(/ ayarla$/, ''));
|
||||
if (name) out.add(name);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* A data type set up before (its label in the Data Types section, no "Set Up"): the text of its row and its Edit
|
||||
* (marked data-storebound-row-edit for the click; only when the row has exactly one Edit). Reads texts only.
|
||||
*/
|
||||
async function typeRow(page: Page, t: string): Promise<{ text: string; edit: Locator | null } | null> {
|
||||
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
|
||||
const r: { text: string; edit: boolean } | null = await page.evaluate(([names, mine, all]: [string[], string[], string[]]) => {
|
||||
const shown = (e: any) => !!e.getClientRects().length;
|
||||
const norm = (e: any) => (e.textContent ?? '').trim().replace(/\s+/g, ' ');
|
||||
document.querySelectorAll('[data-storebound-row-edit]').forEach(e => e.removeAttribute('data-storebound-row-edit'));
|
||||
const hs = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')].filter(h => !h.closest('[role="dialog"], dialog') && shown(h));
|
||||
const head = hs.find(h => names.includes(norm(h)));
|
||||
if (!head) return null;
|
||||
const leaves = [...document.querySelectorAll('body *')].filter(e => !e.children.length && !!(head.compareDocumentPosition(e) & 4) && !e.closest('[role="dialog"], dialog') && shown(e));
|
||||
const leaf = mine.map(m => leaves.find(e => norm(e) === m)).find(Boolean);
|
||||
if (!leaf) return null;
|
||||
const other = (e: any) => { const x = norm(e).replace(/^Set Up /, ''); return all.includes(x) && !mine.includes(x); };
|
||||
let row: any = leaf;
|
||||
for (let a = leaf.parentElement; a && a !== document.body; a = a.parentElement) {
|
||||
if (a.contains(head) || leaves.some(e => a.contains(e) && other(e))) break;
|
||||
row = a;
|
||||
}
|
||||
const edits = [...row.querySelectorAll('button, a, [role="button"]')].filter((e: any) => /^(Edit|Düzenle)$/.test(norm(e)) && shown(e));
|
||||
if (edits.length === 1) edits[0]!.setAttribute('data-storebound-row-edit', '1');
|
||||
return { text: (row.innerText ?? norm(row)).replace(/\s+/g, ' '), edit: edits.length === 1 };
|
||||
}, [dt, typeLabels(t), [...TYPE_OF_LABEL.keys()]]);
|
||||
if (!r) return null;
|
||||
return { text: r.text, edit: r.edit ? vis(page.locator('[data-storebound-row-edit="1"]')) : null };
|
||||
}
|
||||
|
||||
/** Every outlined control of each uncertain answer has been set by the owner (a click, a choice, typing). Read-only. */
|
||||
async function answered(pending: Locator[][]): Promise<boolean> {
|
||||
for (const field of pending) {
|
||||
|
|
@ -700,9 +827,11 @@ const mins = (ms: number): string => (ms < 60_000 ? `${Math.round(ms / 1000)} s`
|
|||
* 3. a step not found -> the printed steps, and storebound keeps watching: a sign-in page that goes away -> from step
|
||||
* 1; the owner moving on by hand (the + button, the New App menu item, the app in the list appear) -> from that step
|
||||
* (resumeAt); the owner opening the form -> it is filled.
|
||||
* 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, each
|
||||
* data type, and the draft Save of its dialogs). An uncertain answer stops it on that page until the owner set it.
|
||||
* The final button (Publish, Submit, Save, Create) and agreements stay the owner's.
|
||||
* 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, then
|
||||
* "Set Up <type>" for each collected type, a plain text clicked by its exact text (setUpClick), and the draft Save
|
||||
* of its dialog). An uncertain answer stops it on that page until the owner set it; a type dialog step it does not
|
||||
* know gets a debug dump and a pause naming the type (the watchdog). The final button (Publish, Submit, Save,
|
||||
* Create), the "?" help buttons and agreements stay the owner's.
|
||||
*/
|
||||
export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promise<{ filled: number; highlighted: number; fallback: boolean; timedOut?: boolean; debugDir?: string }> {
|
||||
const { form, config, say, startUrl } = o;
|
||||
|
|
@ -717,7 +846,15 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
};
|
||||
/** Something storebound knows happened; `next`: what it waits for now. */
|
||||
const progress = (next: string) => { progressAt = Date.now(); lastBeat = progressAt; expecting = next; stalled = false; };
|
||||
const clicked = (name: string) => { progress(`the page after "${name}"`); if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, `after ${name}`)).catch(() => { /* gone */ }); } };
|
||||
/** App Privacy: the data type whose dialog storebound opened last (named in the log, the watchdog and the dumps). */
|
||||
let currentType: string | undefined;
|
||||
const clicked = (name: string) => {
|
||||
const t = currentType;
|
||||
// the type's last step was saved: what comes next is the App Privacy page again
|
||||
const closing = !!t && def.sections.every((x, i) => x.type !== t || done.has(i));
|
||||
progress(t && !closing ? `the "${t}" dialog step after "${name}"` : `the page after "${t ? `${t}: ` : ''}${name}"`);
|
||||
if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
|
||||
};
|
||||
const meta = formMeta(form, config), res = consoleResult(form, config);
|
||||
const active = (): Page | undefined => b.pages().at(-1);
|
||||
const reached = (p: Page) => consoleReached(meta.store, p.url(), startUrl);
|
||||
|
|
@ -841,7 +978,12 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
const last = def.sections.length - 1;
|
||||
const privacy = form === 'apple-app-privacy';
|
||||
/** App Privacy: the data types storebound answers (their entries in the Data Types section open their questions). */
|
||||
const dataTypes = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
|
||||
const types = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
|
||||
/** The same with the console's own labels ("Purchases" for Purchase History): what the click gates accept. */
|
||||
const dataTypes = [...new Set(types.flatMap(typeLabels))];
|
||||
/** Rounds a set-up type has been seen without its "Set Up" (a re-render must not count as "set up before"). */
|
||||
const setUpSeen = new Map<string, number>();
|
||||
const typeDone = (t: string) => def.sections.forEach((x, i) => { if (x.type === t) done.add(i); });
|
||||
/** The page was filled: its Next (App Privacy: Next, else the dialog's draft Save) is clicked when nothing waits. */
|
||||
let pendingAdvance = false;
|
||||
/** A control of the last filled section (its dialog / page holds the Next or Save to press). */
|
||||
|
|
@ -896,7 +1038,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
opened.set(key, n + 1);
|
||||
await flowClick(loc, form, dataTypes, say, clicked);
|
||||
};
|
||||
const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes);
|
||||
// the first Edit of the section (next to its heading); a type set up before has its own Edit further down
|
||||
const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes, 'first');
|
||||
if (!done.has(0)) {
|
||||
const start = await controlWhere(p, FLOW_NAMES.getStarted, w => w.privacyPage && !w.inDialog, dataTypes);
|
||||
if (start) return open('get-started', 'Get Started', start);
|
||||
|
|
@ -908,13 +1051,73 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
return;
|
||||
}
|
||||
done.add(0);
|
||||
const typesAt = def.sections.findIndex(x => x.title === 'Data types');
|
||||
if (typesAt >= 0 && await typesSaved(p)) {
|
||||
done.add(typesAt);
|
||||
progress('the first data type');
|
||||
say('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
|
||||
return;
|
||||
}
|
||||
say('Data collection was answered before (the page lists data types): storebound opens the data types with Edit');
|
||||
return open('edit', 'Edit next to Data Types', edit);
|
||||
}
|
||||
const typesAt = def.sections.findIndex(x => x.title === 'Data types');
|
||||
if (typesAt >= 0 && !done.has(typesAt)) return open('edit-types', 'Edit next to Data Types', await editTypes());
|
||||
const t = def.sections.find((x, i) => x.type && !done.has(i))?.type;
|
||||
if (t) return open(`type:${t}`, t, await controlWhere(p, [t, `Set Up ${t}`, `${t} ayarla`], w => w.inDataTypes && !w.inDialog, dataTypes));
|
||||
if (t) return setUpType(p, t);
|
||||
}
|
||||
|
||||
/** The Data Types section lists exactly the collected types of the config (all certain): their list needs no Edit. */
|
||||
async function typesSaved(p: Page): Promise<boolean> {
|
||||
const ans = pageAnswers.filter(a => a.id.startsWith('type:'));
|
||||
if (!ans.length || !ans.every(a => a.certain)) return false;
|
||||
const want = ans.filter(a => a.value === true).map(a => a.id.slice(5)).sort();
|
||||
const listed = (await listedTypes(p)).sort();
|
||||
return want.length > 0 && listed.join('|') === want.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* One collected data type, the App Privacy page with no dialog open: "Set Up <type>" (a plain text: clicked by its
|
||||
* exact text, setUpClick), else a button / link named like the type, else the type was set up before: its Edit only
|
||||
* when the purposes its row shows differ from the config; otherwise it is left as it is (logged).
|
||||
*/
|
||||
async function setUpType(p: Page, t: string): Promise<void> {
|
||||
const key = `type:${t}`, n = opened.get(key) ?? 0;
|
||||
const go = async (loc: Locator, how: (l: Locator) => Promise<boolean>): Promise<void> => {
|
||||
if (n === 2) { opened.set(key, 3); warn(TAG, `${t}: its questions did not open after two clicks: the console UI may have changed. Open them in the browser; storebound fills them when they appear`); }
|
||||
if (n >= 2) return;
|
||||
opened.set(key, n + 1);
|
||||
currentType = t;
|
||||
if (!await how(loc)) currentType = undefined;
|
||||
};
|
||||
const setUp = await setUpControl(p, t, dataTypes);
|
||||
if (setUp) return go(setUp, l => setUpClick(l, dataTypes, say, clicked));
|
||||
const named = await controlWhere(p, [...typeLabels(t), ...setUpTexts(t)], w => w.inDataTypes && !w.inDialog, dataTypes);
|
||||
if (named) return go(named, l => flowClick(l, form, dataTypes, say, clicked));
|
||||
const row = await typeRow(p, t);
|
||||
if (!row) { setUpSeen.delete(t); return; }
|
||||
// twice in a row: a page that is re-rendering can show the label a moment before its "Set Up"
|
||||
const seen = (setUpSeen.get(t) ?? 0) + 1;
|
||||
setUpSeen.set(t, seen);
|
||||
if (seen < 2) return;
|
||||
if (n > 0) { // storebound opened it, its dialog closed before every step was filled: the owner finished it
|
||||
typeDone(t);
|
||||
progress(`the next data type after "${t}"`);
|
||||
say(`${t} was finished in the browser: storebound goes on with the next data type`);
|
||||
return;
|
||||
}
|
||||
const purposes = pageAnswers.filter(a => a.id.startsWith(`purpose:${t}:`));
|
||||
const shows = (a: Answer) => row.text.includes(a.id.slice(`purpose:${t}:`.length));
|
||||
const readable = purposes.some(shows);
|
||||
const same = readable && purposes.every(a => a.certain && (a.value === true) === shows(a));
|
||||
if (readable && !same && purposes.every(a => a.certain) && row.edit) {
|
||||
say(`${t} was set up before with other purposes than the config: storebound opens it with its Edit`);
|
||||
return go(row.edit, l => flowClick(l, form, dataTypes, say, clicked));
|
||||
}
|
||||
typeDone(t);
|
||||
progress(`the next data type after "${t}"`);
|
||||
say(same ? `${t} was set up before with the purposes of the config: left as it is`
|
||||
: `${t} was set up before; storebound cannot compare its answers on this page and leaves it as it is: check it in the summary before Publish`);
|
||||
}
|
||||
|
||||
/** A page storebound does not know: what is there (top buttons), the dump, and the manual steps. Once per wait. */
|
||||
|
|
@ -922,8 +1125,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
stalled = true;
|
||||
const dbg = await import('./debug');
|
||||
const d = await dump(p, `unknown ${expecting}`).catch(() => null);
|
||||
const top = dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null));
|
||||
say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons there: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
|
||||
// an open dialog: its own buttons (the page behind it has many more)
|
||||
const dl = vis(p.getByRole('dialog'));
|
||||
const inDialog = await dl.count().catch(() => 0) === 1;
|
||||
const own: string[] = inDialog ? await dl.getByRole('button').filter({ visible: true }).evaluateAll((els: any[]) => els.map(e => (e.getAttribute('aria-label') || e.textContent || '').trim().replace(/\s+/g, ' ')).filter(Boolean).slice(0, 10)).catch(() => []) : [];
|
||||
const top = own.length ? own : dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null));
|
||||
say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons ${own.length ? 'in the dialog' : 'there'}: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
|
||||
if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. Answer it in the browser and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`);
|
||||
say(`do this part yourself; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`);
|
||||
for (const g of res.guidance) say(` ${g}`);
|
||||
};
|
||||
|
|
@ -972,13 +1180,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
|
|||
}
|
||||
}
|
||||
let s: FillStep;
|
||||
try { s = await fillPage(p, def, pageAnswers, done); } catch { await Bun.sleep(tick); continue; } // navigating
|
||||
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await Bun.sleep(tick); continue; } // navigating
|
||||
if (s.section !== null) {
|
||||
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least 1.5 s) is a UI change
|
||||
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < 1500)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
|
||||
misses = 0;
|
||||
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
|
||||
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true); } catch { await Bun.sleep(tick); continue; } }
|
||||
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
|
||||
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): fill ${s.missing.length === 1 ? 'it' : 'them'} yourself`);
|
||||
if (s.fallback) {
|
||||
fallback = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue