apple.macos listing / review notes per platform + iOS-only claim warnings; apple upload of a .pkg: bounded pkgutil, macOS processing wait, heartbeats; release 409 pattern
This commit is contained in:
parent
53d57544bf
commit
1e47c2ee60
18 changed files with 417 additions and 63 deletions
112
tests/macos-overrides.test.ts
Normal file
112
tests/macos-overrides.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// apple.macos: per-platform listing file and review notes for an app with an iOS and a macOS App Store version, the
|
||||
// config check, and the validate warning for iOS-only claims in a macOS version's texts.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isMacosPlatform, listingPathFor, reviewNotesFor, validateExtras, type ShipConfig } from '../cli/lib/config';
|
||||
import { reviewDetailPlan, reviewNotes } from '../cli/lib/review-details';
|
||||
import { appleRules, macosClaimRules, type AppleSnapshot } from '../cli/lib/validate-rules';
|
||||
|
||||
const cfg = (apple: Record<string, unknown>) => ({ apple: { bundleId: 'com.example.app', ...apple } }) as unknown as Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>;
|
||||
const contact = { contactFirstName: 'A', contactLastName: 'B', contactPhone: '+10000000000', contactEmail: 'a@example.com' };
|
||||
|
||||
describe('platform helpers', () => {
|
||||
test('spellings of macOS', () => {
|
||||
for (const p of ['macos', 'MAC_OS', 'macOS', 'mac']) expect(isMacosPlatform(p)).toBe(true);
|
||||
for (const p of ['ios', 'IOS', 'tvos', '', undefined]) expect(isMacosPlatform(p)).toBe(false);
|
||||
});
|
||||
test('listingPathFor: macOS override, else apple.listing, else the default', () => {
|
||||
const c = cfg({ listing: 'store/ios.md', macos: { listing: 'store/mac.md' } });
|
||||
expect(listingPathFor(c, 'macos')).toBe('store/mac.md');
|
||||
expect(listingPathFor(c, 'MAC_OS')).toBe('store/mac.md');
|
||||
expect(listingPathFor(c, 'ios')).toBe('store/ios.md');
|
||||
expect(listingPathFor(c)).toBe('store/ios.md');
|
||||
expect(listingPathFor(cfg({ macos: {} }), 'macos')).toBe('store/appstore.md');
|
||||
expect(listingPathFor(cfg({ listing: 'store/ios.md', macos: { reviewNotes: 'x' } }), 'macos')).toBe('store/ios.md');
|
||||
});
|
||||
test('reviewNotesFor: macOS override, else apple.info.reviewNotes', () => {
|
||||
const c = cfg({ info: { reviewNotes: 'iOS notes' }, macos: { reviewNotes: 'Mac notes' } });
|
||||
expect(reviewNotesFor(c, 'macos')).toBe('Mac notes');
|
||||
expect(reviewNotesFor(c, 'ios')).toBe('iOS notes');
|
||||
expect(reviewNotesFor(cfg({ info: { reviewNotes: 'iOS notes' }, macos: {} }), 'macos')).toBe('iOS notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review details plan', () => {
|
||||
const c = { ...cfg({ info: { reviewNotes: 'iOS notes: Game Center, IAP' }, macos: { reviewNotes: 'Mac notes: no purchases' } }), reviewAccess: { signInRequired: false, instructions: 'No sign-in.' } } as Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>;
|
||||
test('macos picks the Mac notes, ios and default keep the iOS notes; the shared sign-in line stays', () => {
|
||||
expect(reviewDetailPlan(c, contact, null, 'macos').attributes.notes).toBe('Mac notes: no purchases\n\nSign-in: No sign-in.');
|
||||
expect(reviewDetailPlan(c, contact, null, 'ios').attributes.notes).toBe('iOS notes: Game Center, IAP\n\nSign-in: No sign-in.');
|
||||
expect(reviewDetailPlan(c, contact, null).attributes.notes).toBe(reviewNotes(c));
|
||||
});
|
||||
});
|
||||
|
||||
describe('config check', () => {
|
||||
test('apple.macos shape', () => {
|
||||
expect(validateExtras({ apple: { bundleId: 'com.example.app', macos: { listing: 'a.md', reviewNotes: 'x', gameCenter: true, noIap: true } } } as never)).toEqual([]);
|
||||
const bad = validateExtras({ apple: { bundleId: 'com.example.app', macos: { listing: '', gameCenter: 'yes', noIap: 1 } } } as never);
|
||||
expect(bad.some(m => m.startsWith('apple.macos.listing'))).toBe(true);
|
||||
expect(bad.some(m => m.startsWith('apple.macos.gameCenter'))).toBe(true);
|
||||
expect(bad.some(m => m.startsWith('apple.macos.noIap'))).toBe(true);
|
||||
expect(validateExtras({ apple: { bundleId: 'com.example.app', macos: 'x' } } as never).some(m => m.startsWith('apple.macos:'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate: iOS-only claims on a macOS version', () => {
|
||||
const loc = (o: Record<string, string>) => [{ locale: 'en-US', screenshots: {}, ...o }];
|
||||
const snap = (over: Partial<AppleSnapshot>): Pick<AppleSnapshot, 'version' | 'locales' | 'reviewDetail' | 'macos'> =>
|
||||
({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, locales: [], reviewDetail: null, ...over });
|
||||
const codes = (f: { msg: string }[]) => f.map(x => x.msg);
|
||||
|
||||
test('Game Center, touch words and iPhone mentions warn; nothing blocks', () => {
|
||||
const f = macosClaimRules(snap({ locales: loc({ description: 'Compete on Game Center. Tap and swipe to play on your iPhone.' }) }));
|
||||
expect(f.length).toBe(3);
|
||||
expect(f.every(x => x.level === 'warn' && x.code === 'macos-claims')).toBe(true);
|
||||
expect(codes(f).join('\n')).toContain('en-US description');
|
||||
});
|
||||
test('promotional text, what\'s new, keywords and live review notes are read', () => {
|
||||
const f = macosClaimRules(snap({ locales: loc({ promotionalText: 'Use the touch screen', whatsNew: 'Pinch to zoom', keywords: 'ipad' }), reviewDetail: { notes: 'Sign in with Game Center' } }));
|
||||
const all = codes(f).join('\n');
|
||||
expect(all).toContain('en-US promotionalText');
|
||||
expect(all).toContain('en-US whatsNew');
|
||||
expect(all).toContain('en-US keywords');
|
||||
expect(all).toContain('review notes (on the store)');
|
||||
});
|
||||
test('the config review notes the Mac would use are checked too', () => {
|
||||
expect(codes(macosClaimRules(snap({ macos: { notes: 'Game Center leaderboards are in the menu' } }))).join()).toContain('review notes (config)');
|
||||
});
|
||||
test('apple.macos.gameCenter silences Game Center; IAP lines only with noIap', () => {
|
||||
const text = loc({ description: 'Game Center scores. Remove ads with an in-app purchase.' });
|
||||
expect(macosClaimRules(snap({ locales: text, macos: { gameCenter: true } }))).toEqual([]);
|
||||
const f = macosClaimRules(snap({ locales: text, macos: { gameCenter: true, noIap: true } }));
|
||||
expect(f.length).toBe(1);
|
||||
expect(f[0]!.msg).toContain('in-app purchase');
|
||||
});
|
||||
test('words that only look similar do not match; iOS versions are never checked', () => {
|
||||
expect(macosClaimRules(snap({ locales: loc({ description: 'Contact the support team; a stapler, a tapestry, an iPhoneless desk.' }) }))).toEqual([]);
|
||||
expect(macosClaimRules(snap({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, locales: loc({ description: 'Game Center, tap' }) }))).toEqual([]);
|
||||
});
|
||||
test('appleRules includes the warning', () => {
|
||||
const base = { expectedLocales: [], requiredDisplayTypes: [], iaps: [], expectedProducts: [], ageRating: null, priceSchedule: false, availability: false, build: null, privacyUrl: { url: 'https://example.com', status: 200 } };
|
||||
const f = appleRules({ ...base, ...snap({ locales: loc({ description: 'Tap here', screenshots: {} as never }) }) } as AppleSnapshot);
|
||||
expect(f.some(x => x.code === 'macos-claims' && x.level === 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple listing reads the platform file', () => {
|
||||
test('readAppleCopy(ctx, "macos") reads apple.macos.listing, ios reads apple.listing', async () => {
|
||||
const { mkdtempSync, writeFileSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join, resolve } = await import('node:path');
|
||||
const { readAppleCopy } = await import('../cli/apple/listing');
|
||||
const { parseArgs } = await import('../cli/lib/args');
|
||||
const fence = '`'.repeat(3);
|
||||
const sec = (d: string) => ['## en-US', '', '### subtitle', fence, 'Sub', fence, '', '### promotionalText', fence, 'Promo', fence, '', '### keywords', fence, 'run,time', fence, '', '### description', fence, d, fence, ''].join('\n');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-macos-'));
|
||||
writeFileSync(join(dir, 'ios.md'), sec('Play on iPhone with Game Center.'));
|
||||
writeFileSync(join(dir, 'mac.md'), sec('Play on your Mac.'));
|
||||
const loaded = { config: cfg({ listing: 'ios.md', macos: { listing: 'mac.md' } }), resolve: (p: string) => resolve(dir, p), root: dir };
|
||||
const ctx = { loaded, args: parseArgs([]) } as never;
|
||||
expect(readAppleCopy(ctx, 'macos')['en-US']?.description).toContain('Mac');
|
||||
expect(readAppleCopy(ctx, 'ios')['en-US']?.description).toContain('iPhone');
|
||||
expect(readAppleCopy(ctx)['en-US']?.description).toContain('iPhone');
|
||||
});
|
||||
});
|
||||
114
tests/upload-pkg-hang.test.ts
Normal file
114
tests/upload-pkg-hang.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// `apple upload <app.pkg> --platform macos` once printed nothing for 10 minutes over SSH although the build number was
|
||||
// VALID on App Store Connect. Causes: pkgutil --check-signature / --expand ran unbounded and silent, and the processing
|
||||
// wait polled the IOS platform only, so a Mac build was "not listed yet" for the whole --timeout. These tests keep
|
||||
// every slow step logged and bounded, and an already-VALID build number ends at once (no upload, no wait).
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { hooks, run as appleUpload } from '../cli/apple/upload';
|
||||
import { PKG_CHECK_MS, PKG_EXPAND_MS, pkgInfo } from '../cli/lib/mas';
|
||||
import { HEARTBEAT_MS } from '../cli/lib/progress';
|
||||
import type { SyncResult } from '../cli/lib/rt';
|
||||
import { ascEnv, BASE, project, restore } from './helpers/store-mock';
|
||||
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
||||
|
||||
const SIDE = JSON.stringify({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' });
|
||||
const files = (): Record<string, string> => ({ 'build/macos/Example.pkg': 'pkg', 'build/macos/Example.pkg.json': SIDE });
|
||||
const result = (o: Partial<SyncResult>): SyncResult => ({ exitCode: 0, signalCode: null, success: true, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), ...o });
|
||||
const timedOut = result({ exitCode: null, signalCode: 'SIGTERM', success: false });
|
||||
const SIGNED = 'Package "Bare.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Example (ABCDE12345)\n Expires: 2030-01-01\n 2. Apple Worldwide Developer Relations Certification Authority\n 3. Apple Root CA\n';
|
||||
|
||||
type Seen = { argv: string[]; timeout?: number };
|
||||
const fake = (seen: Seen[], answer: (argv: string[]) => SyncResult) => ((argv: string[], o: { timeout?: number }) => { seen.push({ argv, timeout: o.timeout }); return answer(argv); }) as never;
|
||||
|
||||
/** Runs fn with log output captured (log.ts writes through console). */
|
||||
async function logged(fn: () => Promise<void>): Promise<{ text: string; error: Error | null }> {
|
||||
const out: string[] = [];
|
||||
const log = console.log, err = console.error, warn = console.warn;
|
||||
console.log = console.error = console.warn = (...a: unknown[]) => { out.push(a.map(String).join(' ')); };
|
||||
let error: Error | null = null;
|
||||
try { await fn(); } catch (e) { error = e as Error; } finally { console.log = log; console.error = err; console.warn = warn; }
|
||||
return { text: out.join('\n'), error };
|
||||
}
|
||||
|
||||
describe('pkgInfo: bounded and never silent', () => {
|
||||
test('the signature check has a timeout and a log line; when it times out the .json apple build wrote is used', () => {
|
||||
const p = project(BASE, files());
|
||||
const said: string[] = [], seen: Seen[] = [];
|
||||
const info = pkgInfo(join(p.root, 'build/macos/Example.pkg'), 'com.example.notes', { say: m => said.push(m), spawnSync: fake(seen, () => timedOut) });
|
||||
expect(info).toEqual({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' });
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]!.timeout).toBe(PKG_CHECK_MS);
|
||||
expect(said[0]).toContain('pkgutil --check-signature');
|
||||
expect(said.at(-1)).toContain('gave no answer');
|
||||
expect(said.at(-1)).toContain('Example.pkg.json');
|
||||
});
|
||||
|
||||
test('the expand is bounded too; a timeout without the .json file is an error that names the cause', () => {
|
||||
const p = project(BASE, { 'build/macos/Bare.pkg': 'pkg' });
|
||||
const said: string[] = [], seen: Seen[] = [];
|
||||
const spawnSync = fake(seen, argv => (argv[1] === '--check-signature' ? result({ stdout: Buffer.from(SIGNED) }) : timedOut));
|
||||
let error: Error | null = null;
|
||||
try { pkgInfo(join(p.root, 'build/macos/Bare.pkg'), undefined, { say: m => said.push(m), spawnSync, expandMs: 5000 }); } catch (e) { error = e as Error; }
|
||||
expect(error?.message).toContain('pkgutil --expand gave no answer');
|
||||
expect(error?.message).toContain('Bare.pkg.json');
|
||||
expect(seen.map(x => x.argv[1])).toEqual(['--check-signature', '--expand']);
|
||||
expect(seen[1]!.timeout).toBe(5000);
|
||||
expect(said.some(m => m.includes('pkgutil --expand'))).toBe(true);
|
||||
expect(PKG_EXPAND_MS).toBeGreaterThan(PKG_CHECK_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple upload of a .pkg (fake stores)', () => {
|
||||
setDefaultTimeout(30_000);
|
||||
let s: Stores;
|
||||
const path = process.env.PATH;
|
||||
beforeAll(() => { ascEnv(); });
|
||||
afterAll(restore);
|
||||
beforeEach(() => { s = installStores(); process.env.PATH = mkdtempSync(join(tmpdir(), 'sb-nopkgutil-')); }); // no pkgutil: the .json is read
|
||||
afterEach(() => { uninstallStores(); process.env.PATH = path; hooks.heartbeat = undefined; });
|
||||
|
||||
test('an already VALID build number ends at once: clear message, no upload, no wait, every slow step logged first', async () => {
|
||||
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } });
|
||||
const p = project(BASE, files());
|
||||
const started = Date.now();
|
||||
const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes'])));
|
||||
expect(Date.now() - started).toBeLessThan(10_000);
|
||||
expect(r.error?.message).toContain('already on App Store Connect (VALID)');
|
||||
expect(writes(s.calls)).toEqual([]);
|
||||
expect(s.calls.some(c => decodeURIComponent(c.path).includes('preReleaseVersion.platform'))).toBe(false); // no processing poll
|
||||
expect(s.asc.parts).toEqual([]);
|
||||
expect(r.text).toContain('reading the package');
|
||||
expect(r.text).toContain('asking App Store Connect');
|
||||
expect(r.text.indexOf('reading the package')).toBeLessThan(r.text.indexOf('asking App Store Connect'));
|
||||
});
|
||||
|
||||
test('--allow-existing on a Mac build: the wait polls MAC_OS and prints a heartbeat while a poll is in flight', async () => {
|
||||
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } });
|
||||
const p = project(BASE, files());
|
||||
let beat: (() => void) | null = null;
|
||||
hooks.heartbeat = { everyMs: 1000, ticker: f => { beat = f; return () => { beat = null; }; } };
|
||||
const inner = globalThis.fetch, polls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
if (decodeURIComponent(String(input)).includes('preReleaseVersion.platform')) {
|
||||
(beat as (() => void) | null)?.(); // the poll is in flight: time passes
|
||||
polls.push(decodeURIComponent(String(input)));
|
||||
// the fake does not implement include=: answer the way App Store Connect does
|
||||
return new Response(JSON.stringify({ data: [{ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'pv-1' } } } }], included: [{ type: 'preReleaseVersions', id: 'pv-1', attributes: { version: '1.2.0' } }] }), { status: 200 });
|
||||
}
|
||||
return inner(input as never, init);
|
||||
}) as typeof fetch;
|
||||
const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes', '--allow-existing'])));
|
||||
expect(r.error).toBeNull();
|
||||
expect(r.text).toMatch(/still waiting for build 7: first check/);
|
||||
expect(r.text).toContain('build 7 is VALID');
|
||||
expect(polls).toHaveLength(1);
|
||||
expect(polls[0]).toContain('filter[preReleaseVersion.platform]=MAC_OS');
|
||||
expect(writes(s.calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the default heartbeat is at least once a minute', () => {
|
||||
expect(HEARTBEAT_MS).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue