From 3aedf9e13765d0304b6a0f621dd292415f43a033 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 07:28:42 +0800 Subject: [PATCH] console fill: sign-in only by the top-level URL, the main frame's form or an Apple sign-in frame Google's account widget and cookie rotation frames (accounts.google.com / ogs.google.com) on a signed-in Play Console no longer count as a sign-in page. Each detection logs its rule and URL; heartbeats name the page; a copied Chrome session that lands on a sign-in URL is said plainly; the sign-in wait gets a watchdog dump, and --debug-dump up to 3 dumps. Fixtures and tests for the chooser and home with the widget frames and a top-level accounts.google.com page; pitfall 16. --- cli/console/fill.ts | 151 +++++++++++++----- cli/lib/console-forms.ts | 5 + docs/pitfalls.md | 1 + skills/store-pitfalls/SKILL.md | 15 +- tests/console-fill.test.ts | 87 +++++++++- .../console/google-account-widget.html | 16 ++ tests/fixtures/console/google-signin.html | 12 ++ tests/fixtures/console/play-chooser-ogb.html | 24 +++ tests/fixtures/console/play-home-ogb.html | 20 +++ tests/fixtures/console/play-signed-out.html | 7 + 10 files changed, 294 insertions(+), 44 deletions(-) create mode 100644 tests/fixtures/console/google-account-widget.html create mode 100644 tests/fixtures/console/google-signin.html create mode 100644 tests/fixtures/console/play-chooser-ogb.html create mode 100644 tests/fixtures/console/play-home-ogb.html create mode 100644 tests/fixtures/console/play-signed-out.html diff --git a/cli/console/fill.ts b/cli/console/fill.ts index d30d6f8..37becc8 100644 --- a/cli/console/fill.ts +++ b/cli/console/fill.ts @@ -11,7 +11,8 @@ // its gate. After sign-in it opens the form itself (navPlan in cli/lib/console-forms.ts: Apps > + > New App, the app's // App Privacy page, Play app list > the app > App content > Start / Manage); those clicks go through navClick, which // applies the same gate (only a link named exactly Start / Manage / Başla / Yönet is let through). A sign-in page at -// any point (signInPage: URL, frames, texts) -> storebound waits for the owner, then starts again at step 1. A step not +// any point (signInReason: the top-level URL, the main frame's form, an Apple sign-in frame; never a Google widget +// frame) -> storebound waits for the owner, then starts again at step 1. A step not // found in time -> the printed steps, and storebound keeps watching (fillSession). With several Play developer // accounts or App Store Connect teams behind one login, the account that has the app is found and remembered // (cli/console/accounts.ts). The owner reviews each page, clicks Next, and presses the @@ -29,10 +30,11 @@ 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, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms'; +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 { log, printJson, warn } from '../lib/log'; import { openAcross, withAccountIds } from './accounts'; import { appleAppId, withAppleAppId } from './app-id'; +import { bareUrl } from './debug'; import { type ConsoleBrowser, openConsoleBrowser } from './session'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -240,38 +242,64 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done return { section: null, filled: [], highlighted: [], missing: [], fallback: false }; } -/** - * A sign-in page, read-only: the page or any frame on a sign-in URL (App Store Connect /login, idmsa.apple.com / - * appleid.apple.com, accounts.google.com, Google /signin), a visible password field in any frame, or a visible text, - * label or placeholder only a sign-in form shows (isSignInText: "Sign in with Passkey", "Email or Phone Number" ...). - * Apple's form sits in an idmsa.apple.com frame and starts with no password field, so the URL and texts matter most. - * Only element texts are read (never values, cookies or storage). - */ -export async function signInPage(page: Page): Promise { - if (isSignInUrl(page.url())) return true; - const frames: any[] = page.frames(); - if (frames.some(f => isSignInUrl(f.url()))) return true; - for (const f of frames) { - try { - const r: { pw: boolean; texts: string[] } = await f.evaluate(() => { - const shown = (el: any) => !!(el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden'); - const pw = [...document.querySelectorAll('input[type="password"]')].some(shown); - const texts: string[] = []; - for (const el of document.querySelectorAll('label, button, a, h1, h2, h3, legend, span, p, div, [role="button"], input, [aria-label]')) { - if (texts.length > 3000) break; - const e = el as any; - for (const t of [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.children.length ? null : e.textContent, e.tagName === 'LABEL' ? e.textContent : null]) { - if (t && t.length <= 80 && shown(e)) texts.push(t); - } - } - return { pw, texts }; - }); - if (r.pw || r.texts.some(t => isSignInText(t, f !== page.mainFrame()))) return true; - } catch { /* a frame that navigates or went away */ } - } - return false; +/** Why a page counts as a sign-in page (signInReason): the rule that fired and the top-level URL (query dropped). */ +export interface SignInWhy { rule: string; url: string } + +/** Reads, in one frame, whether a password field and which sign-in texts are visible (element texts only, never values). */ +async function signInForm(f: any, inFrame: boolean): Promise<{ pw: boolean; text: string | null }> { + const r: { pw: boolean; texts: string[] } = await f.evaluate(() => { + const shown = (el: any) => !!(el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden'); + const pw = [...document.querySelectorAll('input[type="password"]')].some(shown); + const texts: string[] = []; + for (const el of document.querySelectorAll('label, button, a, h1, h2, h3, legend, span, p, div, [role="button"], input, [aria-label]')) { + if (texts.length > 3000) break; + const e = el as any; + for (const t of [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.children.length ? null : e.textContent, e.tagName === 'LABEL' ? e.textContent : null]) { + if (t && t.length <= 80 && shown(e)) texts.push(t); + } + } + return { pw, texts }; + }); + return { pw: r.pw, text: r.texts.find(t => isSignInText(t, inFrame))?.trim().replace(/\s+/g, ' ') ?? null }; } +/** + * Why the page is a sign-in page, or null when it is not. Read-only, and only these rules count: + * 1. the TOP-LEVEL URL is a sign-in page (accounts.google.com, Google /signin or ServiceLogin, idmsa.apple.com / + * appleid.apple.com, App Store Connect /login: isSignInUrl); + * 2. the main frame shows a visible password field, or a visible text / label / placeholder only a sign-in form shows + * (isSignInText: "Email or phone", "Sign in with Passkey" ...); + * 3. a visible Apple sign-in frame (idmsa.apple.com / appleid.apple.com: Apple's form lives in one and starts with no + * password field) that shows a password field or a sign-in text. + * Any other frame never counts: Google pages (Play Console's home, its account chooser) embed accounts.google.com / + * ogs.google.com frames for the account widget and cookie rotation while the owner IS signed in. The header's avatar, + * name and email are not sign-in texts either. Only element texts are read (never values, cookies or storage). + */ +export async function signInReason(page: Page): Promise { + const top: string = page.url(); + const url = bareUrl(top) || top; + if (isSignInUrl(top)) return { rule: 'the top-level URL is a sign-in page', url }; + try { + const m = await signInForm(page.mainFrame(), false); + if (m.pw) return { rule: 'a visible password field in the page', url }; + if (m.text) return { rule: `a sign-in form in the page ("${m.text}")`, url }; + } catch { /* the page navigates */ } + for (const f of page.frames() as any[]) { + if (f === page.mainFrame() || !isAppleSignInFrame(f.url())) continue; + try { + const el = await f.frameElement(); + if (!await el.isVisible()) continue; + const r = await signInForm(f, true); + if (r.pw || r.text) return { rule: `an Apple sign-in frame (${bareUrl(f.url())})${r.text ? ` with "${r.text}"` : ' with a password field'}`, url }; + } catch { /* a frame that navigates or went away */ } + } + return null; +} + +/** A sign-in page (signInReason found a rule). */ +export async function signInPage(page: Page): Promise { + return !!await signInReason(page); +} // ---- navigation to the form (after sign-in) ---- @@ -692,7 +720,6 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis 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 */ }); } }; const meta = formMeta(form, config), res = consoleResult(form, config); const active = (): Page | undefined => b.pages().at(-1); - const isSignIn = (p: Page) => signInPage(p).catch(() => false); const reached = (p: Page) => consoleReached(meta.store, p.url(), startUrl); let hinted = false; const signInHint = () => { @@ -704,16 +731,56 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis : 'leave "Stay signed in" on, so the next run starts signed in'); }; - /** Waits until the console is open and no sign-in page shows (two rounds in a row). false: window closed or time up. */ + const signInWhy = (p: Page) => signInReason(p).catch(() => null); + let lastWhy = '', firstWait = true, signInDumps = 0; + /** Says which rule made the page a sign-in page (again only when the rule or the URL changes). */ + const sayWhy = (w: SignInWhy) => { + const k = `${w.rule}@${w.url}`; + if (k !== lastWhy) { lastWhy = k; say(`sign-in detected because: ${w.rule} at ${w.url}`); } + }; + const consoleName = meta.store === 'apple' ? 'App Store Connect' : 'Play Console'; + + /** + * Waits until the console is open and no sign-in page shows (two rounds in a row). false: window closed or time up. + * Never silent: the rule that made the page a sign-in page is said; every heartbeat says the page and what it is; + * the watchdog dumps the page once when the sign-in has not finished within stepMs; --debug-dump also dumps on + * entering the wait and at each heartbeat (at most 3 sign-in dumps per run). A copied Chrome session that lands on a + * sign-in URL right after the launch is said plainly (it did not carry over). + */ async function signedIn(): Promise { - const end = Date.now() + signInMs; - let told = false, ok = 0, beat = Date.now(); - const start = Date.now(); + const start = Date.now(), end = start + signInMs, first = firstWait; + firstWait = false; + let told = false, ok = 0, beat = start, watched = false, entered = false; + const signInDump = async (p: Page | undefined, label: string) => { + if (!o.debugEveryStep || !p || signInDumps >= 3) return; + signInDumps++; + await dump(p, label).catch(() => null); + }; while (!b.isClosed() && Date.now() < end) { - if (Date.now() - beat >= heartbeatMs) { beat = Date.now(); say(`still waiting for the sign-in, ${mins(Date.now() - start)}`); } const p = active(); - if (p && await isSignIn(p)) { ok = 0; if (!told) { signInHint(); told = true; } } - else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); return true; } } + const w = p ? await signInWhy(p) : null; + const state = (): string => !p ? 'no tab open' : `${bareUrl(p.url()) || p.url()}: ${w ? `a sign-in page (${w.rule})` : reached(p) ? 'the console' : 'neither a sign-in page nor the console yet'}`; + // the wait really starts: a sign-in page, or a page that is still not the console after a few rounds + if (p && !entered && (w || (!reached(p) && Date.now() - start >= 3 * tick))) { entered = true; await signInDump(p, 'sign-in wait'); } + if (Date.now() - beat >= heartbeatMs) { + beat = Date.now(); + say(`still waiting for the sign-in, ${mins(Date.now() - start)} (${state()})`); + await signInDump(p, 'sign-in wait'); + } + if (p && !watched && Date.now() - start >= stepMs) { + watched = true; + const d = await dump(p, 'sign-in not finished').catch(() => null); + say(`the sign-in has not finished after ${mins(Date.now() - start)}: ${state()}${d ? `. Debug saved to ${d.dir}` : ''}`); + } + if (p && w) { + ok = 0; + sayWhy(w); + if (!told) { + told = true; + if (first && b.mode === 'copy' && isSignInUrl(p.url())) say(`your Chrome session for ${consoleName} did not carry over (expired or tied to this device); sign in once in this window, or run with --fresh-profile`); + signInHint(); + } + } else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } } else ok = 0; await Bun.sleep(tick); } @@ -875,7 +942,9 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis const p = active(); if (!await watch(p)) break; if (!p) { await Bun.sleep(tick); continue; } - if (await signInPage(p).catch(() => true)) { + const w = await signInReason(p).catch(() => ({ rule: 'the page could not be read', url: bareUrl(p.url()) })); + if (w) { + sayWhy(w); if (!signedOut && plan.length) signInHint(); signedOut = true; await Bun.sleep(tick); continue; diff --git a/cli/lib/console-forms.ts b/cli/lib/console-forms.ts index e34796d..b4f1c99 100644 --- a/cli/lib/console-forms.ts +++ b/cli/lib/console-forms.ts @@ -167,6 +167,11 @@ export function isSignInUrl(url: string): boolean { } catch { return false; } } +/** An Apple sign-in frame (idmsa.apple.com / appleid.apple.com): the only child frame whose form counts as a sign-in. */ +export function isAppleSignInFrame(url: string): boolean { + try { const h = new URL(url).hostname; return h === 'idmsa.apple.com' || h === 'appleid.apple.com'; } catch { return false; } +} + /** * Whole visible texts (or labels / placeholders) that only a sign-in form shows, English and Turkish. Apple's form * lives in an idmsa.apple.com frame and starts with no password field (Email or Phone Number > Continue > passkey or diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 308c083..f1a1744 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: at "; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). | | 15 | 2026-10 | The real App Privacy "Data Collection" dialog names each data type checkbox by its label run together with its help text ("NameIncluding first or last name"), and labels some types differently from Apple's help page ("Purchases", "Other Data"): exact-name matching found none. | `labelNames` (exact name, then the label as a prefix that ends where the help text starts) and `TYPE_ALIASES` in `cli/lib/console-forms.ts`; the flow fixture mirrors the real dialog's structure (`tests/console-flow.test.ts`). | | 14 | 2026-10 | `console fill apple-app-privacy` clicked Next onto a real console page it did not recognise and then waited 8 hours without a single log line. | Every wait of the console engine is bounded: a step watchdog (`--step-timeout`, 30 s) writes a sanitized debug dump to `.storebound/console-debug/` and prints the page's buttons and the manual steps; a line a minute while waiting; no progress for 20 min stops the run with exit 1; `--debug-dump` dumps every step (`cli/console/debug.ts`, `tests/console-flow.test.ts`, which also scans the engine for waits without a deadline). | | 13 | 2026-10 | App Privacy purposes were over-mapped: Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). | `applePurpose` maps one Play purpose to one Apple purpose (advertising: third-party only); Developer's Advertising only from `appPrivacy.types`; `check` compares `appPrivacy.types` with Info.plist / PrivacyInfo.xcprivacy (`tests/app-privacy.test.ts`). | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 2edad74..1655c61 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1,6 +1,6 @@ --- name: store-pitfalls -description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump). Read it before any store task, and when a store command does something you did not expect. +description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames). Read it before any store task, and when a store command does something you did not expect. --- # Store pitfalls @@ -143,6 +143,19 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful (pitfall 14) writes a debug dump: re-run with `--debug-dump` and attach it. - **Guard:** `labelNames` and `TYPE_ALIASES` (`cli/lib/console-forms.ts`), `tests/console-flow.test.ts`. +## 16. "Still waiting for the sign-in" on a console that is signed in +- **Symptom:** `console fill` (often with the copied Chrome cookies) prints only "still waiting for the sign-in" + while the window shows Play Console (the account chooser, the app list). +- **Cause:** Google pages embed `accounts.google.com` / `ogs.google.com` frames (the account widget, cookie rotation) + while the owner is signed in; an older detector counted any frame on a sign-in host as a sign-in page. +- **Do:** read the line "sign-in detected because: at " and the heartbeats (they name the page). A + top-level `accounts.google.com` / `idmsa.apple.com` / App Store Connect `/login` URL right after the launch means the + copied Chrome session did not carry over (expired or tied to that device): sign in once in the window, or run with + `--fresh-profile`. Anything else: re-run with `--debug-dump` (up to 3 dumps of the sign-in wait) and attach the + folder after the owner looked at `screen.jpg`. +- **Guard:** `signInReason` in `cli/console/fill.ts` (top-level URL, the main frame's password / email-entry form, a + visible Apple sign-in frame; no other frame counts), the sign-in watchdog dump, `tests/console-fill.test.ts`. + ## Adding a lesson Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test that fails without the fix (see CONTRIBUTING.md). diff --git a/tests/console-fill.test.ts b/tests/console-fill.test.ts index bae654b..933d54d 100644 --- a/tests/console-fill.test.ts +++ b/tests/console-fill.test.ts @@ -7,11 +7,11 @@ // of the engine (navTimeoutMs / navMs, scaled down through the options). The relaunch of storebound's own profile // (session cookies kept) needs a real Chrome: tests/console-persist.test.ts. import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { matchAccount, openAcross, readAccounts, rememberApple, rememberPlay, withAccountIds } from '../cli/console/accounts'; -import { createAppHere, fillPage, fillSession, navClick, navigate, resumeAt, run, safeClick, signInPage } from '../cli/console/fill'; +import { createAppHere, fillPage, fillSession, navClick, navigate, resumeAt, run, safeClick, signInPage, signInReason } from '../cli/console/fill'; import type { ConsoleBrowser } from '../cli/console/session'; import { parseArgs } from '../cli/lib/args'; import { type ShipConfig, validateExtras } from '../cli/lib/config'; @@ -976,3 +976,86 @@ test('ticks are gated on agreements only: help text with "saved" is fine, Develo expect(forbiddenControl('Save')).toBe(true); expect(forbiddenControl('Publish')).toBe(true); }); + +// ---- sign-in detection is precise: Google's account widget / cookie rotation frames never make a page a sign-in page ---- +// Real run (Play Console, copied Chrome cookies): the chooser carried an accounts.google.com frame and the old +// detector (any frame on a sign-in host) waited "for the sign-in" on a page that was signed in. + +/** The Play Console routes of a signed-in owner whose pages carry the Google account widget frames. */ +const playOgb = (u: URL): string | null => { + if (u.hostname === 'accounts.google.com' || u.hostname === 'ogs.google.com') return u.pathname.startsWith('/v3/signin') ? 'google-signin.html' : 'google-account-widget.html'; + if (u.hostname !== 'play.google.com') return null; + if (u.pathname.endsWith('/app-list')) return 'play-home-ogb.html'; + if (u.pathname === '/play-create-app.html') return 'play-create-app.html'; + return u.pathname === '/console' || u.pathname === '/console/u/0/developers' ? 'play-chooser-ogb.html' : null; +}; +const frameOn = (page: any, host: string): boolean => page.frames().some((f: any) => { try { return new URL(f.url()).hostname === host; } catch { return false; } }); +/** Test helper: waits until `pred` holds (at most 10 s). */ +async function until(pred: () => boolean): Promise { + const end = Date.now() + 10_000; + while (!pred()) { if (Date.now() > end) throw new Error('until: timed out'); await Bun.sleep(50); } +} + +describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main frame\'s form or an Apple sign-in frame', () => { + test('the Play account chooser with the account widget (accounts.google.com frames, avatar and email header) is NOT a sign-in page', async () => { + const b = await consoleBrowser(playOgb); + await b.page.goto('https://play.google.com/console/u/0/developers'); + await until(() => frameOn(b.page, 'accounts.google.com') && frameOn(b.page, 'ogs.google.com')); + expect(await signInReason(b.page)).toBeNull(); + expect(await signInPage(b.page)).toBe(false); + await b.context.close(); + }, BROWSER_TEST_MS); + + test('Play home with the widget frame is NOT a sign-in page', async () => { + const b = await consoleBrowser(playOgb); + await b.page.goto('https://play.google.com/console/u/0/developers/7000000001/app-list'); + await until(() => frameOn(b.page, 'accounts.google.com') && frameOn(b.page, 'ogs.google.com')); + expect(await signInReason(b.page)).toBeNull(); + await b.context.close(); + }, BROWSER_TEST_MS); + + test('a top-level accounts.google.com page IS a sign-in page, and the rule says why (URL without its query)', async () => { + const b = await consoleBrowser(playOgb); + await b.page.goto('https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fplay.google.com%2Fconsole'); + expect(await signInReason(b.page)).toEqual({ rule: 'the top-level URL is a sign-in page', url: 'https://accounts.google.com/v3/signin/identifier' }); + await b.context.close(); + // a sign-in form in the main frame on a host storebound does not know: its texts tell + const page = await open('signin-passkey.html'); + expect((await signInReason(page))?.rule).toMatch(/^a sign-in form in the page \("(Email or Phone Number|Sign in with Passkey)"\)$/); + await page.close(); + }, BROWSER_TEST_MS); + + test('fillSession play-app-record: the chooser with the widget frames is passed, the form filled, no sign-in said', async () => { + const b = await consoleBrowser(playOgb); + const log = logLines(), said = log.lines; + const done = scope.run(fillSession(b, { form: 'play-app-record', config, ...fast(log) })); + await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled \d+/.test(m))); + expect(said).toContain('creating the app in developer account "Example Studio" (7000000001, the only developer account of this login)'); + expect(said.filter(m => /sign-in detected|sign in in the browser window|still waiting for the sign-in/.test(m))).toEqual([]); + expect(await clicks(b.page)).toEqual([]); + await b.context.close(); + await done; + }, SLOW_TEST_MS); + + test('a copied Chrome session that lands on accounts.google.com: said plainly with the rule; --debug-dump dumps the sign-in wait (at most 3), the watchdog once', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-signin-debug-')); + tmpDirs.push(dir); + const b: ConsoleBrowser = { ...await consoleBrowser(u => (u.hostname === 'play.google.com' ? 'play-signed-out.html' : playOgb(u))), mode: 'copy' }; + const log = logLines(), said = log.lines; + const done = scope.run(fillSession(b, { form: 'play-app-record', config, ...fast(log), heartbeatMs: 300, stepTimeoutMs: 1000, debugDir: dir, debugEveryStep: true })); + await log.wait('the watchdog of the sign-in wait', l => l.some(m => m.startsWith('the sign-in has not finished after'))); + await log.wait('four heartbeats', l => l.filter(m => m.startsWith('still waiting for the sign-in')).length >= 4); + expect(said).toContain('sign-in detected because: the top-level URL is a sign-in page at https://accounts.google.com/v3/signin/identifier'); + expect(said.filter(m => m.startsWith('sign-in detected because'))).toHaveLength(1); + expect(said).toContain('your Chrome session for Play Console did not carry over (expired or tied to this device); sign in once in this window, or run with --fresh-profile'); + expect(said.find(m => m.startsWith('still waiting for the sign-in'))).toMatch(/\(https:\/\/accounts\.google\.com\/v3\/signin\/identifier: a sign-in page \(the top-level URL is a sign-in page\)\)$/); + expect(said.find(m => m.startsWith('the sign-in has not finished after'))).toContain(`Debug saved to ${dir}`); + const names = readdirSync(dir).sort(); + expect(names.filter(n => /-sign-in-wait$/.test(n))).toHaveLength(3); + expect(names.filter(n => /-sign-in-not-finished$/.test(n))).toHaveLength(1); + expect(await b.page.locator('#identifier').inputValue()).toBe(''); // nothing typed + expect(await clicks(b.page)).toEqual([]); // nothing clicked + await b.context.close(); + await done; + }, SLOW_TEST_MS); +}); diff --git a/tests/fixtures/console/google-account-widget.html b/tests/fixtures/console/google-account-widget.html new file mode 100644 index 0000000..31c9478 --- /dev/null +++ b/tests/fixtures/console/google-account-widget.html @@ -0,0 +1,16 @@ + + +Google Account + +
+ Example Owner +

Example Owner

+

owner@example.com

+ Manage your Google Account + Add another account + Sign out +
+ diff --git a/tests/fixtures/console/google-signin.html b/tests/fixtures/console/google-signin.html new file mode 100644 index 0000000..234419e --- /dev/null +++ b/tests/fixtures/console/google-signin.html @@ -0,0 +1,12 @@ + + +Sign in - Google Accounts + +

Sign in

+

to continue to Google Play Console

+

+Forgot email? + + + diff --git a/tests/fixtures/console/play-chooser-ogb.html b/tests/fixtures/console/play-chooser-ogb.html new file mode 100644 index 0000000..7e46b3d --- /dev/null +++ b/tests/fixtures/console/play-chooser-ogb.html @@ -0,0 +1,24 @@ + + +Choose developer account - Play Console + + +
+ Google Play Console + Example Owner + owner@example.com + +
+ + +
+

Choose developer account

+ +
+ diff --git a/tests/fixtures/console/play-home-ogb.html b/tests/fixtures/console/play-home-ogb.html new file mode 100644 index 0000000..4f5f42c --- /dev/null +++ b/tests/fixtures/console/play-home-ogb.html @@ -0,0 +1,20 @@ + + +All apps - Play Console + +
+ Google Play Console + Example Owner + +
+ +

All apps

+ + + + + +
AppPackage
Other Appcom.example.other
+ diff --git a/tests/fixtures/console/play-signed-out.html b/tests/fixtures/console/play-signed-out.html new file mode 100644 index 0000000..61628fa --- /dev/null +++ b/tests/fixtures/console/play-signed-out.html @@ -0,0 +1,7 @@ + + +Play Console + + +