desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention

- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state
  already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the
  context menu list sections and sub views; old tab ids, section and section/sub links all resolve
- Players > Game services (games only): both stores, one row per leaderboard / achievement with its
  languages and each store's state, Game Center locale map, Play API-disabled state with the console plan
- games status --json: items, localeMap, playApiDisabled; inspect summary: games
- sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio
  filter, workspace as a footer icon row
- boot check opens places like a person (section, then sub view)
This commit is contained in:
CRTkafa 2026-10-24 13:01:50 +08:00
parent 98cd1d3c92
commit 0ef27cf2e4
22 changed files with 918 additions and 115 deletions

View file

@ -244,20 +244,51 @@ async function newPage(ctx: { newPage(): Promise<import('playwright').Page> }):
return page;
}
/** Opens an app tab like a person: the tab itself when it is in the row, else through the row's "More" menu (tabs
* that do not fit the window go there). */
/** The app's places (docs IA): five sections, each with sub views in a segmented control. A place is named by its
* sub view's old tab id ("tab-prices"); the section of each, for opening it like a person. */
const SECTION_OF: Record<string, string> = {
releases: 'release', testflight: 'release', listings: 'store', art: 'store', appinfo: 'store', catalog: 'revenue', prices: 'revenue',
reviews: 'players', games: 'players', health: 'health', growth: 'health', checks: 'health', tools: 'health',
};
/** Opens a place in an app like a person: its section in the section row (or through the row's "More" menu when it
* does not fit), then the sub view in the section's segmented control when the section has more than one. */
async function clickTab(page: Page, id: string, o: { timeout?: number } = {}): Promise<void> {
const tab = page.locator(`[role="tab"]#${id}`);
const sub = id.replace(/^tab-/, '');
const sec = SECTION_OF[sub];
if (!sec) throw new Error(`unknown place ${id}`);
const tab = page.locator(`[role="tab"]#tab-${sec}`);
await tab.waitFor({ state: 'attached', timeout: o.timeout });
if (await tab.isVisible()) { await tab.click(o); return; }
const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim();
try {
await page.locator('.ds-tab-more').click(o);
await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o);
} catch (e) {
const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth }));
throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`);
if (await tab.isVisible()) await tab.click(o);
else {
const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim();
try {
await page.locator('.ds-tab-more').click(o);
await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o);
} catch (e) {
const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth }));
throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`);
}
}
const btn = page.locator(`.sec-subs #sub-${sub}`);
if (await btn.count()) { await btn.click(o); return; }
// a section with one sub view has no segmented control: the section itself is the place
if (await page.locator('.sec-subs button').count()) throw new Error(`${id}: not in the ${sec} section's views`);
}
/** Every place of the open app, in order: each section, then each of its sub views (as clickTab ids). */
async function places(page: Page, o: { timeout?: number } = {}): Promise<string[]> {
const secs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id.replace(/^tab-/, '')));
const out: string[] = [];
for (const sec of secs) {
await page.locator(`[role="tab"]#tab-${sec}`).click(o).catch(() => {});
await page.locator('[data-testid="section-head"]').waitFor({ state: 'visible', timeout: o.timeout }).catch(() => {});
const subs = await page.locator('.sec-subs button').evaluateAll(bs => bs.map(b => b.id.replace(/^sub-/, '')));
const first = Object.entries(SECTION_OF).find(([, s]) => s === sec)?.[0];
// a one-view section: its only view (Players without games is Reviews)
out.push(...(subs.length ? subs : first ? [first] : []).map(x => `tab-${x}`));
}
return out;
}
async function state(page: Page) {
@ -342,10 +373,12 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
if (await wizard.count()) problems.push('the welcome guide does not close with Escape');
}
// every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen
const count = await page.locator('nav.side button.nav').count();
// (apps are .nav rows; the workspace is the footer's icon row, named by aria-label)
const SIDE = 'nav.side button.nav, nav.side button.nav-ws';
const count = await page.locator(SIDE).count();
for (let i = 0; i < count; i++) {
const b = page.locator('nav.side button.nav').nth(i);
const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
const b = page.locator(SIDE).nth(i);
const label = ((await b.getAttribute('aria-label').catch(() => null)) || (await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`));
await settle(page);
await check(label);
@ -365,7 +398,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
// shared with the CLI: each account names its credential profile (or that its old keychain key has not moved)
const accts = fx.accounts_list as { label?: string; profile?: string | null }[];
if (accts.length) {
const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first();
const nav = page.locator('nav.side [data-screen="accounts"]').first();
if (await nav.count()) {
await nav.click({ timeout: WAIT_MS });
await settle(page);
@ -510,8 +543,8 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
await gone(page, '.cp-panel');
await crashCheck('Releases');
visited++;
// every tab of the app, then App info's console steps and the Dependencies screen
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
// every place of the app (each section and its sub views), then App info's console steps and Dependencies
const tabs = await places(page, { timeout: WAIT_MS });
for (const id of tabs) {
await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
await settle(page);
@ -524,7 +557,7 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty');
}
notes.push(`tabs: ${tabs.join(', ')}`);
const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first();
const deps = page.locator('nav.side [data-screen="dependencies"]').first();
await deps.click({ timeout: WAIT_MS }).catch(() => {});
await shows(page, '#content', 'Android SDK');
await settle(page);
@ -710,7 +743,7 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`));
await settle(page);
const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim();
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
const tabs = await places(page, { timeout: WAIT_MS });
for (const id of tabs) {
await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`));
await settle(page);