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.
This commit is contained in:
parent
b48407115e
commit
3aedf9e137
10 changed files with 294 additions and 44 deletions
|
|
@ -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<boolean> {
|
||||
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<SignInWhy | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: <rule> at <url>"; 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`). |
|
||||
|
|
|
|||
|
|
@ -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: <rule> at <url>" 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).
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
16
tests/fixtures/console/google-account-widget.html
vendored
Normal file
16
tests/fixtures/console/google-account-widget.html
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<!-- MODELED, not recorded: what Google pages (Play Console included) embed from accounts.google.com / ogs.google.com
|
||||
while the owner IS signed in: the account widget (avatar, name, email, Manage your Google Account, Sign out) and
|
||||
the hidden cookie rotation frame. Its URL is a sign-in host, but it is a third-party frame, never a sign-in page.
|
||||
Placeholder data only. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Google Account</title></head>
|
||||
<body>
|
||||
<div aria-label="Google Account: Example Owner (owner@example.com)">
|
||||
<img alt="Example Owner" width="32" height="32" src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">
|
||||
<p>Example Owner</p>
|
||||
<p>owner@example.com</p>
|
||||
<a href="https://myaccount.google.com/">Manage your Google Account</a>
|
||||
<a href="https://accounts.google.com/AddSession">Add another account</a>
|
||||
<a href="https://accounts.google.com/Logout">Sign out</a>
|
||||
</div>
|
||||
</body></html>
|
||||
12
tests/fixtures/console/google-signin.html
vendored
Normal file
12
tests/fixtures/console/google-signin.html
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<!-- MODELED, not recorded: Google's sign-in page as a top-level page on accounts.google.com (where Play Console sends
|
||||
a signed-out browser). storebound must detect it by its top-level URL, never type or click there. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Sign in - Google Accounts</title><script src="/_common.js"></script></head>
|
||||
<body>
|
||||
<h1>Sign in</h1>
|
||||
<p>to continue to Google Play Console</p>
|
||||
<p><input id="identifier" type="email" aria-label="Email or phone"></p>
|
||||
<a href="https://accounts.google.com/signin/v2/usernamerecovery">Forgot email?</a>
|
||||
<button type="button">Create account</button>
|
||||
<button type="button">Next</button>
|
||||
</body></html>
|
||||
24
tests/fixtures/console/play-chooser-ogb.html
vendored
Normal file
24
tests/fixtures/console/play-chooser-ogb.html
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!doctype html>
|
||||
<!-- MODELED, not recorded: Play Console's "Choose developer account" page as a signed-in owner sees it, with what every
|
||||
Google page carries: a header with the avatar, name and email (the account widget, OGB), the widget's frame from
|
||||
ogs.google.com and a hidden accounts.google.com cookie rotation frame. Signed in: NOT a sign-in page, although a
|
||||
frame sits on accounts.google.com. One developer account. Ids and names are placeholders. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Choose developer account - Play Console</title><script src="/_common.js"></script>
|
||||
<style>.sub { display: block; color: #666; font-size: 12px; } .row { display: flex; gap: 8px; align-items: center; padding: 6px; }</style></head>
|
||||
<body>
|
||||
<header role="banner">
|
||||
<a href="https://play.google.com/console">Google Play Console</a>
|
||||
<a href="https://accounts.google.com/SignOutOptions" aria-label="Google Account: Example Owner (owner@example.com)"><img alt="Example Owner" width="32" height="32" src="data:image/gif;base64,R0lGODlhAQABAAAAACw="></a>
|
||||
<span>owner@example.com</span>
|
||||
<iframe title="Google Account" src="https://ogs.google.com/widget/account?origin=https%3A%2F%2Fplay.google.com" width="320" height="120"></iframe>
|
||||
</header>
|
||||
<iframe title="" src="https://accounts.google.com/RotateCookiesPage?og_pid=1&rot=3&origin=https%3A%2F%2Fplay.google.com" width="0" height="0" style="display:none"></iframe>
|
||||
<iframe title="Google Account" src="https://accounts.google.com/o/oauth2/iframe" width="1" height="1"></iframe>
|
||||
<main>
|
||||
<h1>Choose developer account</h1>
|
||||
<div role="list">
|
||||
<div role="listitem"><a class="row" href="./developers/7000000001/app-list"><span><span>Example Studio</span><span class="sub">Developer account</span></span></a></div>
|
||||
<div role="listitem"><a class="row" href="/signup"><span>Create a new developer account</span></a></div>
|
||||
</div>
|
||||
</main>
|
||||
</body></html>
|
||||
20
tests/fixtures/console/play-home-ogb.html
vendored
Normal file
20
tests/fixtures/console/play-home-ogb.html
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<!-- MODELED, not recorded: Play Console > Home (All apps) of a signed-in owner with the Google account widget (avatar,
|
||||
name, email in the header, its ogs.google.com frame) and the accounts.google.com cookie rotation frame. NOT a
|
||||
sign-in page. "Create app" opens the Create app form. Placeholder data only. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>All apps - Play Console</title><script src="/_common.js"></script></head>
|
||||
<body>
|
||||
<header role="banner">
|
||||
<a href="https://play.google.com/console">Google Play Console</a>
|
||||
<a href="https://accounts.google.com/SignOutOptions" aria-label="Google Account: Example Owner (owner@example.com)"><img alt="Example Owner" width="32" height="32" src="data:image/gif;base64,R0lGODlhAQABAAAAACw="></a>
|
||||
<iframe title="Google Account" src="https://ogs.google.com/widget/account?origin=https%3A%2F%2Fplay.google.com" width="320" height="120"></iframe>
|
||||
</header>
|
||||
<iframe title="" src="https://accounts.google.com/RotateCookiesPage?og_pid=1&rot=3&origin=https%3A%2F%2Fplay.google.com" width="0" height="0" style="display:none"></iframe>
|
||||
<h1>All apps</h1>
|
||||
<label>Search apps <input type="search"></label>
|
||||
<button type="button" onclick="location.href='/play-create-app.html'">Create app</button>
|
||||
<table>
|
||||
<tr><th>App</th><th>Package</th></tr>
|
||||
<tr><td><a href="/play-dashboard.html">Other App</a></td><td>com.example.other</td></tr>
|
||||
</table>
|
||||
</body></html>
|
||||
7
tests/fixtures/console/play-signed-out.html
vendored
Normal file
7
tests/fixtures/console/play-signed-out.html
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!doctype html>
|
||||
<!-- MODELED, not recorded: Play Console for a browser whose copied session did not carry over: it sends the window on
|
||||
to Google's sign-in page (a top-level accounts.google.com URL). -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Play Console</title></head>
|
||||
<body>
|
||||
<script>location.replace('https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fplay.google.com%2Fconsole&service=androiddeveloper');</script>
|
||||
</body></html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue