upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint
This commit is contained in:
parent
26f9559e1a
commit
fb1609cba7
24 changed files with 599 additions and 27 deletions
|
|
@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr
|
|||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { validateConfig } from '../cli/lib/config';
|
||||
import { capabilitySpecs, planCapabilities } from '../cli/lib/signing';
|
||||
import { gamesPrivacyFindings, gcFindings, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services';
|
||||
import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services';
|
||||
import { consolePlan } from '../cli/lib/console-plan';
|
||||
import { verifyText } from '../cli/lib/console-verify';
|
||||
import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status';
|
||||
|
|
@ -116,6 +116,42 @@ describe('apple game-center (fake App Store Connect)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Game Center locale map', () => {
|
||||
const live = { leaderboards: [{ id: 'lb-1', attributes: { vendorIdentifier: 'com.example.game.highscore', referenceName: 'com.example.game.highscore', defaultFormatter: 'INTEGER', submissionType: 'BEST_SCORE', scoreSortType: 'DESC' },
|
||||
localizations: [{ id: 'l1', attributes: { locale: 'en-GB', name: 'High score', formatterSuffix: ' points' } }, { id: 'l2', attributes: { locale: 'tr', name: 'En yüksek skor' } }] }], achievements: [] };
|
||||
const only = { ...GAME.games!, achievements: [] };
|
||||
const sig = (p: ReturnType<typeof planGameCenter>) => p[0]!.locales.map(l => l.locale + ':' + l.action);
|
||||
test('without a map the config en-US is created next to the live en-GB, and the suggestion names the map', () => {
|
||||
expect(sig(planGameCenter(only, GAME.languages, live))).toEqual(['en-US:create', 'tr:keep']);
|
||||
expect(suggestLocaleMap(GAME.languages, liveGcLocales(live))).toEqual([{ id: 'en', from: 'en-US', to: 'en-GB' }]);
|
||||
});
|
||||
test('--locale-map en=en-GB (or games.localeMap) leaves nothing to create or update, and no suggestion remains', () => {
|
||||
const map = gcLocaleMap(only, ['en=en-GB']);
|
||||
expect(map).toEqual({ en: 'en-GB' });
|
||||
const plan = planGameCenter(only, GAME.languages, live, map);
|
||||
expect(sig(plan)).toEqual(['en-GB:keep', 'tr:keep']);
|
||||
expect(plan[0]!.action).toBe('keep');
|
||||
expect(suggestLocaleMap(GAME.languages, liveGcLocales(live), map)).toEqual([]);
|
||||
expect(gcLocaleMap({ ...only, localeMap: { en: 'en-AU' } }, ['en=en-GB,tr=tr'])).toEqual({ en: 'en-GB', tr: 'tr' });
|
||||
expect(planGameCenter(only, GAME.languages, live, { 'en-US': 'en-GB' })[0]!.locales[0]!.locale).toBe('en-GB');
|
||||
});
|
||||
test('two English variants live: no guess; a bad map entry is refused', () => {
|
||||
expect(suggestLocaleMap(GAME.languages, ['en-GB', 'en-AU', 'tr'])).toEqual([]);
|
||||
expect(() => gcLocaleMap(only, ['en'])).toThrow('--locale-map');
|
||||
expect(validateGames({ ...only, localeMap: { en: 'english' } }, GAME.languages).join()).toContain('games.localeMap.en');
|
||||
});
|
||||
test('apple game-center against the fake store: the dry run warns with the exact flag; with the flag nothing is written', async () => {
|
||||
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
||||
const base = await run2(() => gameCenter(project(GAME).ctx(['--yes'])));
|
||||
expect(base.writes.length).toBeGreaterThan(0);
|
||||
for (const l of [...s.asc.all('gameCenterLeaderboardLocalizations'), ...s.asc.all('gameCenterAchievementLocalizations')]) if (l.attributes.locale === 'en-US') l.attributes.locale = 'en-GB';
|
||||
const dry = await run2(() => gameCenter(project(GAME).ctx(['--dry'])));
|
||||
expect(dry.out + dry.err).toContain('--locale-map en=en-GB');
|
||||
const mapped = await run2(() => gameCenter(project(GAME).ctx(['--yes', '--locale-map', 'en=en-GB'])));
|
||||
expect(mapped.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('play games (fake Games Configuration API)', () => {
|
||||
test('create, no-op re-run, update by name match; playId wins', async () => {
|
||||
const p = project(GAME);
|
||||
|
|
@ -141,6 +177,21 @@ describe('play games (fake Games Configuration API)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('play games with the Games Configuration API disabled', () => {
|
||||
test('a 403 SERVICE_DISABLED names the API, the enable URL for the project and the console plan', async () => {
|
||||
s.faults.push({ method: 'GET', match: /leaderboards/, when: 'before', status: 403, times: 5, code: 'SERVICE_DISABLED', detail: 'Google Play Game Services Publishing API has not been used in project 987654321 before or it is disabled. SERVICE_DISABLED accessNotConfigured' });
|
||||
const err = await run2(() => playGames(project(GAME).ctx(['--dry']))).then(() => null, (e: Error) => e);
|
||||
expect(err?.message).toContain('Google Play Game Services Publishing API');
|
||||
expect(err?.message).toContain('gamesconfiguration.googleapis.com');
|
||||
expect(err?.message).toContain('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=987654321');
|
||||
expect(err?.message).toContain('storebound console plan play-games-services');
|
||||
});
|
||||
test('the console plan has the enable step', () => {
|
||||
const plan = consolePlan('play-games-services', GAME);
|
||||
expect(JSON.stringify(plan)).toContain('gamesconfiguration.googleapis.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('project checks', () => {
|
||||
const ENT = '<?xml version="1.0"?><plist><dict><key>com.apple.developer.game-center</key><true/></dict></plist>';
|
||||
test('Game Center entitlement: missing, unreferenced, present, present without config', () => {
|
||||
|
|
|
|||
|
|
@ -287,6 +287,34 @@ describe('idempotent re-upload (fake stores)', () => {
|
|||
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'upload', store: 'apple', version: '1.2.0', build: '7', state: 'VALID' });
|
||||
expect(writes(s.calls).filter(c => /buildUploads/.test(c.path))).toEqual([]);
|
||||
});
|
||||
test('play upload --pre-check: a passing command writes the evidence and joins the result; a failing one refuses with PRE_CHECK_FAILED before any write', async () => {
|
||||
const d = tmp();
|
||||
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
|
||||
const ev = join(d, 'ev', 'evidence.json');
|
||||
const bad = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}; exit 3', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a }));
|
||||
expect(bad.code).toBe(1);
|
||||
const jb = JSON.parse(bad.out);
|
||||
expect(jb).toMatchObject({ ok: false, error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 3, size: expect.any(Number) } });
|
||||
expect(JSON.parse(readFileSync(ev, 'utf8')).exitCode).toBe(3);
|
||||
expect(writes(s.calls)).toEqual([]);
|
||||
const ok = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a }));
|
||||
expect(ok.code).toBe(0);
|
||||
const e = JSON.parse(readFileSync(ev, 'utf8'));
|
||||
expect(e).toMatchObject({ file: 'build/android/app.aab', exitCode: 0, storeboundVersion: expect.any(String) });
|
||||
expect(e.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(e.stdoutTail.join(' ')).toContain('scanning');
|
||||
expect(JSON.parse(ok.out).preCheck.sha256).toBe(e.sha256);
|
||||
});
|
||||
|
||||
test('apple upload --pre-check: a failing command stops before any App Store Connect call', async () => {
|
||||
const plist = '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>com.example.notes</string><key>CFBundleShortVersionString</key><string>1.2.0</string><key>CFBundleVersion</key><string>9</string></dict></plist>';
|
||||
const p = project(BASE, { 'build/ios/export/Example.ipa': makeIpa(plist) });
|
||||
const n = s.calls.length;
|
||||
const r = await ship('apple upload', ['--json', '--yes', '--pre-check', 'exit 1'], a => appleUpload({ loaded: p.loaded, args: a }));
|
||||
expect(r.code).toBe(1);
|
||||
expect(JSON.parse(r.out)).toMatchObject({ ok: false, step: 'upload', store: 'apple', error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 1 } });
|
||||
expect(s.calls.length).toBe(n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
|
|
|
|||
52
tests/shots-copy-translate.test.ts
Normal file
52
tests/shots-copy-translate.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// `storebound shots copy translate | check | reviewed`: fills copy.json per language with a marker file, caption limits.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { validateCopy } from '../cli/shots/lib';
|
||||
import { applyFields, marksNotes, overLimit, parseMarks, sceneFields } from '../cli/lib/shots-copy-translate';
|
||||
import { run } from '../cli/shots/copy';
|
||||
import { BASE, capture, project } from './helpers/store-mock';
|
||||
|
||||
const CFG: ShipConfig = {
|
||||
...BASE,
|
||||
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }],
|
||||
shots: { copy: 'store/shots/copy.json', scenes: [{ id: 'home', palette: 'gold', frames: [] }] } as never,
|
||||
};
|
||||
const COPY = JSON.stringify({ en: { home: { eyebrow: 'Hello', title: ['Everything', 'in order'], sub: 'Lists and notes.' } } });
|
||||
const go = async (p: ReturnType<typeof project>, argv: string[]) => { const r = await capture(() => run(p.ctx(argv))); return r; };
|
||||
|
||||
describe('shots copy translate', () => {
|
||||
test('without a hook the source is copied in as status=translate; the missing-locale problem names the command', async () => {
|
||||
const p = project(CFG, { 'store/shots/copy.json': COPY });
|
||||
expect(validateCopy(JSON.parse(COPY), CFG.shots!, ['en', 'de'])[0]).toContain('storebound shots copy translate --to de');
|
||||
await go(p, ['translate', '--to', 'de']);
|
||||
const copy = JSON.parse(readFileSync(join(p.root, 'store/shots/copy.json'), 'utf8'));
|
||||
expect(copy.de.home.title).toEqual(['Everything', 'in order']);
|
||||
const marks = parseMarks(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8'));
|
||||
expect(marks.de).toEqual({ from: 'en', status: 'translate' });
|
||||
expect(marksNotes(marks, 'copy')[0]).toContain('not translated yet');
|
||||
expect(validateCopy(copy, CFG.shots!, ['en', 'de'])).toEqual([]);
|
||||
});
|
||||
test('with a hook: draft; an over-limit field keeps the source and the language stays translate; reviewed refuses translate', async () => {
|
||||
const p = project(CFG, { 'store/shots/copy.json': COPY });
|
||||
await go(p, ['translate', '--to', 'de', '--translate', 'tr a-z A-Z']);
|
||||
let copy = JSON.parse(readFileSync(join(p.root, 'store/shots/copy.json'), 'utf8'));
|
||||
expect(copy.de.home.eyebrow).toBe('HELLO');
|
||||
expect(parseMarks(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8')).de!.status).toBe('draft');
|
||||
const r = await go(p, ['reviewed', '--lang', 'de']);
|
||||
expect(r.exit).toBeFalsy();
|
||||
expect(readFileSync(join(p.root, 'store/shots/copy.translations.json'), 'utf8').trim()).toBe('{}');
|
||||
const q = project(CFG, { 'store/shots/copy.json': COPY });
|
||||
await go(q, ['translate', '--to', 'de', '--translate', 'echo xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx']);
|
||||
copy = JSON.parse(readFileSync(join(q.root, 'store/shots/copy.json'), 'utf8'));
|
||||
expect(copy.de.home.title[0]).toBe('Everything');
|
||||
expect(parseMarks(readFileSync(join(q.root, 'store/shots/copy.translations.json'), 'utf8')).de!.status).toBe('translate');
|
||||
});
|
||||
test('helpers: fields, limits', () => {
|
||||
const sc = JSON.parse(COPY).en.home;
|
||||
expect(sceneFields(sc).map(f => f.key)).toEqual(['eyebrow', 'title.0', 'title.1', 'sub']);
|
||||
expect(applyFields(sc, { sub: 'x' }).sub).toBe('x');
|
||||
expect(overLimit({ home: { title: ['a'.repeat(40), 'b'] } })[0]).toMatchObject({ field: 'title.0', limit: 28 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue