storebound/tests/play-app-id.test.ts

129 lines
8.9 KiB
TypeScript

// The Play Console app id (play.appId): taken from the URL Create app redirects to (`console verify play-app-record
// --url`), or found by package name in captured app list pages (`play app-id`, a modeled list of 10 apps per page with
// the new app on page 3), then written into the config as an upsert after a dry diff. Placeholder ids only.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { consolePlan } from '../cli/lib/console-plan';
import { PLAY_CREATE_CAPTURE, verifyText } from '../cli/lib/console-verify';
import { APP_LIST_CAPTURE, appIdChange, appIdDiff, appIdSteps, appListPage, type AppListPage, findPlayAppId, parseAppListPages, playAppIdInText, writePlayAppId } from '../cli/lib/play-app-id';
import { upsertNestedString } from '../cli/lib/scan/config-edit';
const PKG = 'com.example.game';
const base: ShipConfig = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: PKG, developerId: '4444' },
} as ShipConfig;
const dash = (app: string) => `https://play.google.com/console/u/0/developers/4444/app/${app}/app-dashboard`;
/** A modeled app list: 25 apps, 10 per page, the new app (com.example.game) on page 3, a look-alike on page 1. */
function list(): AppListPage[] {
const apps = Array.from({ length: 24 }, (_, i) => ({ name: `Other App ${i + 1}`, pkg: `com.example.other${i + 1}`, id: String(4975000000000000000 + i) }));
apps.splice(1, 0, { name: 'Example Game Pro', pkg: 'com.example.game2', id: '4975999999999999998' }); // not a whole-token match
apps.push({ name: 'Example Game', pkg: PKG, id: '4975123456789012345' });
const pages: AppListPage[] = [];
for (let i = 0; i < apps.length; i += 10) {
const slice = apps.slice(i, i + 10);
pages.push({ url: appListPage('4444', pages.length + 1), hasNext: i + 10 < apps.length,
rows: slice.map(a => ({ text: `${a.name} ${a.pkg} Production Published`, href: `/console/u/0/developers/4444/app/${a.id}/app-dashboard` })) });
}
return pages;
}
describe('the app id from a URL or a capture', () => {
test('playAppIdInText: the /developers/<id>/app/<id> digits, anywhere', () => {
expect(playAppIdInText(dash('4975123456789012345'))).toBe('4975123456789012345');
expect(playAppIdInText(JSON.stringify({ url: dash('9876'), fields: {} }))).toBe('9876');
expect(playAppIdInText('https://play.google.com/console/u/0/developers/4444/app-list')).toBeNull();
expect(playAppIdInText('https://play.google.com/console/u/0/developers/4444/create-new-app')).toBeNull();
});
test('console verify play-app-record --url: the created app\'s id matches and is handed to the upsert', () => {
const r = verifyText('play-app-record', base, dash('9876'));
expect(r).toMatchObject({ ok: true, playAppId: '9876', matched: 1 });
expect(r.rows[0]!.expected).toContain('not in the config yet');
// the capture snippet carries the URL too
expect(PLAY_CREATE_CAPTURE).toContain('url: location.href');
expect(verifyText('play-app-record', base, JSON.stringify({ url: dash('9876'), fields: {}, checked: [], text: 'Dashboard' })).playAppId).toBe('9876');
// a different id in the config: a mismatch, and --write would replace it
const other = verifyText('play-app-record', { ...base, play: { ...base.play!, appId: '1111' } } as ShipConfig, dash('9876'));
expect(other).toMatchObject({ ok: false, mismatches: 1, playAppId: '9876' });
// the form still open (before Create app): no app id, the usual field rows
const form = verifyText('play-app-record', base, JSON.stringify({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app', fields: { 'App name': 'Example Game' }, checked: [], text: '' }));
expect(form.playAppId).toBeUndefined();
expect(form.rows.length).toBeGreaterThan(1);
});
test('the plan says how to capture it after Create app', () => {
const p = consolePlan('play-app-record', base);
expect(p.steps.map(s => s.text).join('\n')).toContain('console verify play-app-record --url');
expect(p.uiFacts.join('\n')).toContain('10 apps per page');
});
});
describe('play app-id: the package name across the app list pages', () => {
test('found on page 3 of a modeled 3-page list; the look-alike com.example.game2 never matches', () => {
const pages = list();
expect(pages).toHaveLength(3);
expect(pages[0]!.rows).toHaveLength(10);
expect(findPlayAppId(pages.slice(0, 1), PKG)).toEqual({ found: false, pages: 1, hasNext: true });
expect(findPlayAppId(pages.slice(0, 2), PKG)).toEqual({ found: false, pages: 2, hasNext: true });
expect(findPlayAppId(pages, PKG)).toMatchObject({ found: true, appId: '4975123456789012345', page: 3 });
expect(findPlayAppId(pages, 'com.example.missing')).toEqual({ found: false, pages: 3, hasNext: false });
});
test('captures: one object, a JSON array or one per line', () => {
const pages = list();
expect(parseAppListPages(JSON.stringify(pages[2]))).toHaveLength(1);
expect(parseAppListPages(JSON.stringify(pages))).toHaveLength(3);
expect(parseAppListPages(pages.map(p => JSON.stringify(p)).join('\n'))).toHaveLength(3);
expect(() => parseAppListPages('{"nope":1}')).toThrow('rows');
expect(findPlayAppId(parseAppListPages(pages.map(p => JSON.stringify(p)).join('\n')), PKG)).toMatchObject({ found: true, page: 3 });
});
test('the agent steps: the search box first, then the pages; the capture snippet is valid JavaScript', () => {
const steps = appIdSteps(base, '4444').join('\n');
for (const s of ['https://play.google.com/console/u/0/developers/4444/app-list', 'search box', PKG, 'Next page', '/app-list?page=', '--write']) expect(steps).toContain(s);
expect(() => new Function(`return ${APP_LIST_CAPTURE}`)).not.toThrow();
expect(appListPage('4444', 3)).toBe('https://play.google.com/console/u/0/developers/4444/app-list?page=3');
});
});
describe('play.appId written into the config: a dry diff, then an upsert', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-app-id-'));
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const TS = `import { defineConfig } from 'storebound';\n\nexport default defineConfig({\n app: { name: 'Example Game' },\n languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],\n play: {\n packageName: '${PKG}', // the package\n developerId: '4444',\n listing: { title: 'x' },\n },\n});\n`;
test('adds play.appId inside play (only there), then replaces it; the rest of the file stays', () => {
const added = upsertNestedString(TS, 'storebound.config.ts', 'play', 'appId', '9876');
expect(added).toContain(` play: {\n appId: '9876',\n packageName: '${PKG}', // the package`);
expect(added.match(/appId/g)).toHaveLength(1);
expect(added.replace(" appId: '9876',\n", '')).toBe(TS);
const again = upsertNestedString(added, 'storebound.config.ts', 'play', 'appId', '1111');
expect(again).toBe(added.replace("'9876'", "'1111'"));
expect(upsertNestedString("export default { app: { name: 'X' }, play: { packageName: 'a.b' } };\n", 'c.ts', 'play', 'appId', '5')).toBe("export default { app: { name: 'X' }, play: { packageName: 'a.b', appId: '5' } };\n");
expect(JSON.parse(upsertNestedString(JSON.stringify({ play: { packageName: 'a.b' } }), 'c.json', 'play', 'appId', '5')).play).toEqual({ packageName: 'a.b', appId: '5' });
expect(() => upsertNestedString("export default { app: {} };\n", 'c.ts', 'play', 'appId', '5')).toThrow('no play block');
// a nested appId (another block inside play) is not the one replaced
const nested = "export default {\n play: {\n extra: { appId: 'keep' },\n packageName: 'a.b',\n },\n};\n";
expect(upsertNestedString(nested, 'c.ts', 'play', 'appId', '5')).toContain(" appId: '5',\n extra: { appId: 'keep' },");
});
test('writePlayAppId: a dry diff first, a write only when it changes, re-runs converge', () => {
const file = join(dir, 'storebound.config.ts');
writeFileSync(file, TS);
const cfg = { ...base } as ShipConfig;
const change = appIdChange(cfg, '9876');
expect(change).toEqual({ from: null, to: '9876', changed: true });
expect(appIdDiff(change, 'storebound.config.ts')).toBe("storebound.config.ts: play.appId (not set) -> '9876'");
expect(writePlayAppId(file, cfg, '9876')).toBe(true);
expect(readFileSync(file, 'utf8')).toContain("appId: '9876'");
const now = { ...cfg, play: { ...cfg.play!, appId: '9876' } } as ShipConfig;
expect(writePlayAppId(file, now, '9876')).toBe(false); // already there: nothing written
expect(appIdDiff(appIdChange(now, '9876'), 'storebound.config.ts')).toContain('already');
expect(() => writePlayAppId(file, cfg, 'abc')).toThrow('not a Play Console app id');
});
});