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
|
|
@ -3,6 +3,10 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- `apple.macos: { listing, reviewNotes, gameCenter, noIap }`: an app with iOS and macOS versions gets its own Mac listing file and review notes (`apple listing --platform macos`, `apple review-details --platform macos`, new there); `validate` warns about iOS-only claims in a macOS version's texts (Game Center, tap / swipe, iPhone / iPad, in-app purchases with `noIap`).
|
||||
- `apple upload <pkg>`: reading the package is bounded (pkgutil signature check 90 s, expand 5 min, then the `apple build` .json) and logged; the processing wait finds macOS builds (it looked only at iOS and sat silently until --timeout); every slow read prints a heartbeat; an SSH / non-GUI session is warned about up front.
|
||||
- `apple release`: a 409 on a release type change is recognized again (a stray control character had broken the pattern).
|
||||
|
||||
## 0.8.65 · 2026-11-03
|
||||
|
||||
- `validate`: in-app purchases removed from sale (DEVELOPER_REMOVED_FROM_SALE, REMOVED_FROM_SALE) are no blockers; a warning only when the config still sells one.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { compactDiff } from '../lib/diff';
|
|||
import { mergeAppleListing, overLimits } from '../lib/listing-pull';
|
||||
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
|
||||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||
import { appleName } from '../lib/config';
|
||||
import { appleName, listingPathFor } from '../lib/config';
|
||||
import { wantedRelease } from '../lib/release-type';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
|
|
@ -29,10 +29,10 @@ import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
|
|||
import { stdinIsTTY } from '../lib/tty';
|
||||
import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||
|
||||
export function readAppleCopy(ctx: Ctx) {
|
||||
export function readAppleCopy(ctx: Ctx, platform?: string) {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const only = ctx.args.list('only');
|
||||
const all = parseAppStoreListing(readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8'));
|
||||
const all = parseAppStoreListing(readFileSync(resolve(listingPathFor(config, platform)), 'utf8'));
|
||||
return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l)));
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ async function pull(ctx: Ctx): Promise<void> {
|
|||
const at = l.attributes;
|
||||
remote[at.locale] = { ...remote[at.locale], description: at.description ?? undefined, keywords: at.keywords ?? undefined, promotionalText: at.promotionalText ?? undefined, whatsNew: at.whatsNew ?? undefined };
|
||||
}
|
||||
const file = resolve(config.apple?.listing ?? 'store/appstore.md');
|
||||
const file = resolve(listingPathFor(config, platform));
|
||||
const before = existsSync(file) ? readFileSync(file, 'utf8') : '# App Store listing copy\n\nPulled from App Store Connect by `storebound apple listing pull`.\n';
|
||||
const r = mergeAppleListing(before, remote, appleName(config));
|
||||
const over = overLimits(r.changes, 'apple');
|
||||
|
|
@ -104,8 +104,8 @@ type Asc = Awaited<ReturnType<typeof ascClient>>;
|
|||
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise<AppleListingPlan> {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const app = config.app;
|
||||
const listing = readAppleCopy(ctx);
|
||||
const md = readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8');
|
||||
const listing = readAppleCopy(ctx, platform);
|
||||
const md = readFileSync(resolve(listingPathFor(config, platform)), 'utf8');
|
||||
const drafts = draftSections(md, 'apple');
|
||||
const include = ctx.args.has('include-drafts');
|
||||
const skipped: Skipped[] = [];
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ async function setReleaseType(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>
|
|||
try {
|
||||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to])) } });
|
||||
} catch (e) {
|
||||
if (/-> 409/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
|
||||
if (/-> 409\b/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
|
||||
It is editable until App Review approves the version; after that use apple release --manual-release.`);
|
||||
throw e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// App Review information on the editable version: contact, demo account, notes. Idempotent.
|
||||
// storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]
|
||||
// storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]
|
||||
// The contact (name, phone, email) is personal data, so it is never in the config: it comes from
|
||||
// .storebound/credentials.json "reviewContact", or is copied from another app of the same team with --contact-from.
|
||||
// Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or
|
||||
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus
|
||||
// reviewAccess.instructions and accounts.inApp (lib/review-details.ts).
|
||||
// reviewAccess.instructions and accounts.inApp (lib/review-details.ts). --platform macos: apple.macos.reviewNotes
|
||||
// replaces apple.info.reviewNotes when set (a Mac app may have no Game Center, IAP or ads).
|
||||
import { refuseTemplate } from '../lib/template-answers';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
|
|
@ -12,7 +13,8 @@ import { findReviewContact, maskSecret, maskUser, noContactMessage, reviewDemoAc
|
|||
import { reviewDetailPlan } from '../lib/review-details';
|
||||
import { done, log } from '../lib/log';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { ascClient } from './asc';
|
||||
import { ascClient, EDITABLE, type J } from './asc';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel } from './platform';
|
||||
|
||||
type Attrs = Record<string, unknown>;
|
||||
const shown = (k: string, v: unknown): string => {
|
||||
|
|
@ -36,8 +38,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const { config, root } = ctx.loaded;
|
||||
refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const version = await c.editableVersion();
|
||||
if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)');
|
||||
const appId = await c.app();
|
||||
const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, appId), (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, 'an editable version');
|
||||
const version = await c.editableVersion(platform);
|
||||
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (apple listing --version x.y.z creates it)`);
|
||||
|
||||
let contact: Record<string, string> | null = null;
|
||||
const from = ctx.args.get('contact-from');
|
||||
|
|
@ -57,7 +61,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (!contact) throw new Error(noContactMessage(root, checked));
|
||||
|
||||
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root));
|
||||
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root), platform);
|
||||
const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null;
|
||||
const cur = await read();
|
||||
if (ctx.args.has('dry')) {
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ const TAG = 'testflight';
|
|||
const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
|
||||
|
||||
/** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */
|
||||
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<BuildSeen & { version?: string }> {
|
||||
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
||||
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string, platform = 'IOS'): Promise<BuildSeen & { version?: string }> {
|
||||
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': platform, include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
||||
if (marketing) q.set('filter[preReleaseVersion.version]', marketing);
|
||||
const resp = await c.asc('GET', `/v1/builds?${q}`);
|
||||
const hits = buildsWithVersion(resp).filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing));
|
||||
|
|
@ -54,13 +54,13 @@ export interface BuildWait { outcome: WaitOutcome; timedOut: boolean; seen: Buil
|
|||
* Polls build `number` until processing ends (VALID, or FAILED / INVALID) or `timeoutMs` passes; logs each state change.
|
||||
* `once` reads the state a single time. Shared by `testflight wait` and `apple upload` (which waits after the upload).
|
||||
*/
|
||||
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise<BuildWait> {
|
||||
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; platform?: 'IOS' | 'MAC_OS'; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise<BuildWait> {
|
||||
const started = Date.now();
|
||||
let delay: number | null = null, last = '';
|
||||
// polls are up to 2 minutes apart: a heartbeat in between says the wait is alive
|
||||
const beat = o.once ? null : heartbeat(ms => log(o.tag ?? TAG, `still waiting for build ${number}: ${last || 'first check'}, ${elapsed(ms)} of ${elapsed(o.timeoutMs)}`), o);
|
||||
try { for (;;) {
|
||||
const seen = await seeBuild(c, appId, number, o.marketing);
|
||||
const seen = await seeBuild(c, appId, number, o.marketing, o.platform);
|
||||
const outcome = waitOutcome(seen);
|
||||
const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${seen.processingState}${seen.internal ? `, internal ${seen.internal}` : ''}`;
|
||||
if (line !== last) { log(o.tag ?? TAG, `build ${number}${seen.version ? ` (${seen.version})` : ''}: ${line} [${Math.round((Date.now() - started) / 60_000)} min]`); last = line; }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, remoteBuildSession, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
|
|
@ -51,6 +51,9 @@ function stop(legacy: number, message: string): never {
|
|||
|
||||
const TAG = 'upload';
|
||||
|
||||
/** Test hook: heartbeat options (short interval, manual ticker) for the reads, the upload and the processing wait. */
|
||||
export const hooks: { heartbeat?: HeartbeatOptions } = {};
|
||||
|
||||
function md5File(path: string): string {
|
||||
const h = rt.hasher('md5');
|
||||
const fd = openSync(path, 'r');
|
||||
|
|
@ -257,9 +260,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
} else ipa = resolve(ipa);
|
||||
shipPhase('verify', `checking ${relative(root, ipa)}`);
|
||||
const isPkg = ipa.endsWith('.pkg');
|
||||
log(TAG, `${relative(root, ipa)}: reading ${isPkg ? 'the package (pkgutil)' : 'the IPA'} (${mb(statSync(ipa).size)})`);
|
||||
// over SSH (no GUI session) anything that touches the login keychain can wait for a prompt nobody sees: say so up front
|
||||
const remote = process.platform === 'darwin' ? remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()) : null;
|
||||
if (remote) warn(TAG, `${remote}: the upload itself uses the App Store Connect API key (no keychain, no prompt), but pkgutil / altool can stall on a keychain or GUI prompt nobody sees here; every step has a timeout. If it stalls, run it in the Mac's logged-in GUI session (see storebound apple build --help, --allow-ssh)`);
|
||||
const platform: 'IOS' | 'MAC_OS' = isPkg ? 'MAC_OS' : 'IOS';
|
||||
if (macos && !isPkg) throw new Error('--platform macos uploads a signed .pkg (storebound apple build --platform macos --tauri <src-tauri>), not an .ipa');
|
||||
const info: ReturnType<typeof ipaInfo> = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa);
|
||||
const info: ReturnType<typeof ipaInfo> = isPkg ? pkgInfo(ipa, bundleId, { say: m => log(TAG, m) }) : ipaInfo(ipa);
|
||||
log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build})`);
|
||||
shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') });
|
||||
if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`);
|
||||
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
|
||||
|
|
@ -277,18 +285,21 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const wait = !a.has('no-wait');
|
||||
const timeout = parseDuration(a.str('timeout', '60m'));
|
||||
|
||||
log(TAG, `asking App Store Connect about ${bundleId}: the app, the editable version, the builds, earlier uploads of build ${info.build}`);
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
// the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0
|
||||
// from a project that ignored the intended version otherwise lands in the wrong train
|
||||
const editable = await c.editableVersion(platform).catch(() => null);
|
||||
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
|
||||
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
// every read has its own timeout and retries; the heartbeat says the run is alive while one of them waits
|
||||
const { appId, editable, builds, already } = await withHeartbeat(ms => log(TAG, `still reading App Store Connect, ${elapsed(ms)}`), async () => {
|
||||
const appId = await c.app();
|
||||
// the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0
|
||||
// from a project that ignored the intended version otherwise lands in the wrong train
|
||||
const editable = await c.editableVersion(platform).catch(() => null);
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
// the same IPA uploaded by an earlier run: nothing to send again, only the wait
|
||||
const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined;
|
||||
return { appId, editable, builds, already };
|
||||
}, hooks.heartbeat);
|
||||
const numbers = builds.map(b => String(b.attributes.version));
|
||||
const next = nextBuildNumber(numbers);
|
||||
// the same IPA uploaded by an earlier run: nothing to send again, only the wait
|
||||
const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined;
|
||||
const onAsc = builds.find(b => String(b.attributes.version) === info.build);
|
||||
const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next });
|
||||
// the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload
|
||||
|
|
@ -300,14 +311,18 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
return;
|
||||
}
|
||||
if (decision instanceof Error) throw decision;
|
||||
log(TAG, `build ${info.build} is not on App Store Connect (next free: ${next})`);
|
||||
if (decision === 'recheck') {
|
||||
log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`);
|
||||
if (!wait) { if (json) printJson({ ...{ ipa: relative(root, ipa), version: info.version, build: info.build }, uploaded: true, alreadyUploaded: true }); done(TAG, `build ${info.build} already uploaded`); return; }
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG });
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: timeout, tag: TAG, ...hooks.heartbeat });
|
||||
if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null });
|
||||
if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; }
|
||||
stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r));
|
||||
}
|
||||
// (a build number App Store Connect has already ends above, before this: the clearer message)
|
||||
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
|
||||
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
|
||||
if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`);
|
||||
const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform);
|
||||
const plan = via === 'api'
|
||||
|
|
@ -327,14 +342,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0);
|
||||
log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`);
|
||||
if (via === 'api') {
|
||||
try { await apiUpload(c, appId, ipa, info, deadline, {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
try { await apiUpload(c, appId, ipa, info, deadline, hooks.heartbeat ?? {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
} else {
|
||||
try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
try { await altoolUpload(root, ipa, deadline, hooks.heartbeat); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; }
|
||||
}
|
||||
if (!wait) { if (json) printJson({ ...summary, uploaded: true }); done(TAG, `build ${info.build} uploaded; storebound apple status shows when it is VALID`); return; }
|
||||
// the same poll as `storebound apple testflight wait`, within what is left of --timeout
|
||||
shipPhase('processing', `waiting for App Store Connect to process build ${info.build}`);
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG });
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG, ...hooks.heartbeat });
|
||||
const state = r.timedOut || (r.outcome !== 'valid' && r.outcome !== 'failed') ? 'TIMEOUT' : r.seen.processingState ?? 'UNKNOWN';
|
||||
if (json) printJson({ ...summary, uploaded: true, processingState: state });
|
||||
if (r.outcome === 'valid') { done(TAG, `${waitMessage(info.build, r)}. Then: apple testflight distribute --build ${info.build} --group <name> --dry`); return; }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkMani
|
|||
import { trackingIssue } from './lib/privacy-manifest';
|
||||
import { type AsoFinding, auditAso } from './lib/aso';
|
||||
import { draftLocales } from './lib/aso-intel';
|
||||
import { appleName, playCodes } from './lib/config';
|
||||
import { appleName, listingPathFor, playCodes } from './lib/config';
|
||||
import { fieldsMentions } from './lib/cross-store';
|
||||
import { appBuildFile, pickGradle, versionSourceIssues, versionSourceMessage } from './lib/gradle';
|
||||
import { playConsoleReminders } from './lib/console-plan';
|
||||
|
|
@ -107,6 +107,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(traceMessage(t)); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); }
|
||||
// other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77)
|
||||
try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); }
|
||||
if (config.apple?.macos?.listing) tryDo('appstore listing (macOS)', () => {
|
||||
const copy = parseAppStoreListing(readFileSync(resolve(listingPathFor(config, 'macos')), 'utf8'));
|
||||
const missing = config.languages.filter(l => l.apple && !copy[l.apple]).map(l => l.apple);
|
||||
if (missing.length) note(`macOS appstore listing has no section for ${missing.join(', ')}`);
|
||||
return `${Object.keys(copy).length} locales`;
|
||||
});
|
||||
// listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed
|
||||
for (const f of [config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : '', config.play ? resolve(config.play.listing ?? 'store/listing.md') : ''].filter(Boolean)) {
|
||||
if (!existsSync(f)) continue;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>; --repair-stuck [--dry] (replace only screenshots not COMPLETE, at their positions); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
|
||||
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
|
||||
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes; --platform ios|macos|tvos|visionos (macos: apple.macos.reviewNotes when set)'],
|
||||
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'],
|
||||
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
|
||||
|
|
|
|||
|
|
@ -264,7 +264,8 @@
|
|||
"with-version"
|
||||
],
|
||||
"apple review-details": [
|
||||
"contact-from"
|
||||
"contact-from",
|
||||
"platform"
|
||||
],
|
||||
"apple screenshots": [
|
||||
"include-drafts",
|
||||
|
|
@ -1580,7 +1581,7 @@
|
|||
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple review-details": [
|
||||
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
||||
"storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]"
|
||||
],
|
||||
"apple screenshots": [
|
||||
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]",
|
||||
|
|
|
|||
|
|
@ -707,6 +707,15 @@ export interface ShipConfig {
|
|||
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/landscape/ (shots.landscape). Uploaded
|
||||
* as APP_DESKTOP to the macOS version: `apple screenshots --platform macos`. */
|
||||
mac?: boolean;
|
||||
/**
|
||||
* macOS App Store overrides (not `mac`, which only switches Mac screenshots on). An app with an iOS and a macOS
|
||||
* version often needs different version texts and review notes (the Mac app may have no Game Center, in-app
|
||||
* purchases or ads). `listing`: an App Store listing file used instead of apple.listing for `apple listing
|
||||
* --platform macos`; `reviewNotes`: used instead of apple.info.reviewNotes by `apple review-details --platform macos`.
|
||||
* `gameCenter: true`: the Mac app has Game Center (silences the Game Center check of `validate --platform macos`).
|
||||
* `noIap: true`: the Mac app sells nothing (validate then flags in-app purchase lines in the Mac texts).
|
||||
*/
|
||||
macos?: { listing?: string; reviewNotes?: string; gameCenter?: boolean; noIap?: boolean };
|
||||
/** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under
|
||||
* <renders>/<lang>/creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to
|
||||
* 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a
|
||||
|
|
@ -1036,6 +1045,14 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
|
|||
e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)');
|
||||
if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)');
|
||||
if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)');
|
||||
const mo = cfg.apple?.macos;
|
||||
if (mo !== undefined) {
|
||||
if (typeof mo !== 'object' || mo === null || Array.isArray(mo)) e.push('apple.macos: { listing?: "store/appstore-macos.md", reviewNotes?: "...", gameCenter?: true, noIap?: true }');
|
||||
else {
|
||||
for (const k of ['listing', 'reviewNotes'] as const) if (mo[k] !== undefined && (typeof mo[k] !== 'string' || !mo[k]!.trim())) e.push(`apple.macos.${k}: a non-empty string${k === 'listing' ? ' (path of the macOS App Store listing file)' : ''}`);
|
||||
for (const k of ['gameCenter', 'noIap'] as const) if (mo[k] !== undefined && typeof mo[k] !== 'boolean') e.push(`apple.macos.${k}: true | false`);
|
||||
}
|
||||
}
|
||||
const ad = cfg.apple?.adhoc;
|
||||
if (ad?.method !== undefined && ad.method !== 'release-testing' && ad.method !== 'ad-hoc') e.push('apple.adhoc.method: release-testing | ad-hoc');
|
||||
const ppp = cfg.prices?.ppp;
|
||||
|
|
@ -1114,6 +1131,23 @@ function validateIarcDetails(d: IarcDetails | undefined): string[] {
|
|||
}
|
||||
|
||||
/** Platforms of the app (app identity): app.platforms, else from the store sections. */
|
||||
/** True for the macOS platform in any spelling: "macos", "mac_os" (API name MAC_OS), "mac". Pure. */
|
||||
export const isMacosPlatform = (p: string | undefined | null): boolean => !!p && /^mac(_?os)?$/i.test(p.replace(/[\s-]/g, ''));
|
||||
|
||||
/** The App Store listing file (as written in the config, relative) for a platform: apple.macos.listing for macOS when
|
||||
* set, else apple.listing, else store/appstore.md. Pure. */
|
||||
export function listingPathFor(config: Pick<ShipConfig, 'apple'>, platform?: string | null): string {
|
||||
const mac = isMacosPlatform(platform) ? config.apple?.macos?.listing?.trim() : '';
|
||||
return mac || config.apple?.listing || 'store/appstore.md';
|
||||
}
|
||||
|
||||
/** The App Review notes text of the config for a platform: apple.macos.reviewNotes for macOS when set, else
|
||||
* apple.info.reviewNotes. Pure; reviewAccess and account deletion lines are added by reviewNotes() in lib/review-details. */
|
||||
export function reviewNotesFor(config: Pick<ShipConfig, 'apple'>, platform?: string | null): string | undefined {
|
||||
const mac = isMacosPlatform(platform) ? config.apple?.macos?.reviewNotes : undefined;
|
||||
return mac?.trim() ? mac : config.apple?.info?.reviewNotes;
|
||||
}
|
||||
|
||||
/** App Store name limit (characters). */
|
||||
export const APPLE_NAME_MAX = 30;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// report. Reads store/appstore.md and store/listing.md (or the config's listing paths).
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { relative } from 'node:path';
|
||||
import type { Loaded } from './config';
|
||||
import { listingPathFor, type Loaded } from './config';
|
||||
import { fileMentions, type PlatformHit } from './cross-store';
|
||||
|
||||
export function listingFileMentions(loaded: Pick<Loaded, 'config' | 'resolve' | 'root'>, stores: { apple: boolean; play: boolean } = { apple: true, play: true }): PlatformHit[] {
|
||||
|
|
@ -11,6 +11,7 @@ export function listingFileMentions(loaded: Pick<Loaded, 'config' | 'resolve' |
|
|||
const out: PlatformHit[] = [];
|
||||
const files: ['apple' | 'play', string | null][] = [
|
||||
['apple', stores.apple && config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null],
|
||||
['apple', stores.apple && config.apple?.macos?.listing ? resolve(listingPathFor(config, 'macos')) : null],
|
||||
['play', stores.play && config.play ? resolve(config.play.listing ?? 'store/listing.md') : null],
|
||||
];
|
||||
for (const [store, f] of files) if (f && existsSync(f)) out.push(...fileMentions(store, relative(root, f) || f, readFileSync(f, 'utf8'), allow));
|
||||
|
|
|
|||
|
|
@ -271,29 +271,51 @@ export function readAppInfo(appDir: string): Record<string, PlistValue> {
|
|||
return parsePlist(readFileSync(p)) as Record<string, PlistValue>;
|
||||
}
|
||||
|
||||
/** What bounds and reports the .pkg reading (tests inject a fake spawnSync and short limits). */
|
||||
export interface PkgInfoOptions { say?: (msg: string) => void; spawnSync?: typeof rt.spawnSync; checkMs?: number; expandMs?: number }
|
||||
/** pkgutil --check-signature evaluates the certificate chain (revocation lookups over the network): never unbounded. */
|
||||
export const PKG_CHECK_MS = 90_000;
|
||||
/** pkgutil --expand copies the whole archive to a temp folder: minutes for a big app, never unbounded. */
|
||||
export const PKG_EXPAND_MS = 5 * 60_000;
|
||||
|
||||
/** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the <pkg>.json next to it that
|
||||
* `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. */
|
||||
export function pkgInfo(pkg: string, expectBundleId?: string): { bundleId: string; version: string; build: string } {
|
||||
if (rt.which('pkgutil')) {
|
||||
const sig = parsePkgSignature(rt.spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe' }).stdout.toString());
|
||||
const bad = pkgProblems(sig);
|
||||
if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`);
|
||||
const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`);
|
||||
try {
|
||||
if (rt.spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe' }).exitCode === 0) {
|
||||
for (const d of readdirSync(tmp)) {
|
||||
const p = join(tmp, d, 'PackageInfo');
|
||||
const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null;
|
||||
if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info;
|
||||
}
|
||||
}
|
||||
} finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } }
|
||||
}
|
||||
* `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. Both pkgutil
|
||||
* calls have a timeout (no stdin, so no prompt) and say what they do first; one that times out falls back to the
|
||||
* .json file, else it fails with the reason (a silent hang over SSH, 10 min, was this read). */
|
||||
export function pkgInfo(pkg: string, expectBundleId?: string, o: PkgInfoOptions = {}): { bundleId: string; version: string; build: string } {
|
||||
const say = o.say ?? (() => {});
|
||||
const spawnSync = o.spawnSync ?? rt.spawnSync;
|
||||
const side = `${pkg}.json`;
|
||||
let timedOut = '';
|
||||
if (rt.which('pkgutil') || o.spawnSync) {
|
||||
const checkMs = o.checkMs ?? PKG_CHECK_MS, expandMs = o.expandMs ?? PKG_EXPAND_MS;
|
||||
say(`checking the signature of ${basename(pkg)} (pkgutil --check-signature, up to ${Math.round(checkMs / 1000)} s)`);
|
||||
const chk = spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe', timeout: checkMs });
|
||||
if (chk.exitCode === null && chk.signalCode) timedOut = `pkgutil --check-signature gave no answer in ${Math.round(checkMs / 1000)} s (it checks the certificate chain over the network)`;
|
||||
else {
|
||||
const bad = pkgProblems(parsePkgSignature(chk.stdout.toString()));
|
||||
if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`);
|
||||
const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`);
|
||||
say(`reading bundle id, version and build (pkgutil --expand, up to ${Math.round(expandMs / 1000)} s; a big package takes a while)`);
|
||||
try {
|
||||
const ex = spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe', timeout: expandMs });
|
||||
if (ex.exitCode === null && ex.signalCode) timedOut = `pkgutil --expand gave no answer in ${Math.round(expandMs / 1000)} s`;
|
||||
else if (ex.exitCode === 0) {
|
||||
for (const d of readdirSync(tmp)) {
|
||||
const p = join(tmp, d, 'PackageInfo');
|
||||
const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null;
|
||||
if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info;
|
||||
}
|
||||
}
|
||||
} finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } }
|
||||
}
|
||||
}
|
||||
if (timedOut) say(`${timedOut}: using ${basename(side)} (written by apple build) instead`);
|
||||
if (existsSync(side)) {
|
||||
const j = JSON.parse(readFileSync(side, 'utf8')) as { bundleId?: string; version?: string; build?: string; unsigned?: boolean };
|
||||
if (j.unsigned) throw new Error(`${basename(pkg)} was built with --unsigned: the Mac App Store refuses it`);
|
||||
if (j.bundleId && j.version && j.build) return { bundleId: j.bundleId, version: j.version, build: j.build };
|
||||
}
|
||||
if (timedOut) throw new Error(`${basename(pkg)}: ${timedOut}, and there is no ${basename(side)} file from apple build to read instead: run it in the Mac's logged-in GUI session, or check the Mac's network`);
|
||||
throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@
|
|||
// field takes up to 4000 bytes; a demo account must keep working during the whole review (Guideline 2.1).
|
||||
// https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/appstorereviewdetail/attributes-data.dictionary
|
||||
import type { ShipConfig } from './config';
|
||||
import { reviewNotesFor, type ShipConfig } from './config';
|
||||
import { type DemoAccount, maskSecret, maskUser } from './credentials';
|
||||
|
||||
export const NOTES_MAX_BYTES = 4000;
|
||||
|
||||
/** Notes for the reviewer: apple.info.reviewNotes, then reviewAccess.instructions, then where account deletion is. */
|
||||
export function reviewNotes(config: Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>): string {
|
||||
const parts = [config.apple?.info?.reviewNotes?.trim() ?? ''];
|
||||
/** Notes for the reviewer: apple.info.reviewNotes (macOS: apple.macos.reviewNotes when set), then reviewAccess.instructions,
|
||||
* then where account deletion is. */
|
||||
export function reviewNotes(config: Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>, platform?: string): string {
|
||||
const parts = [reviewNotesFor(config, platform)?.trim() ?? ''];
|
||||
const ins = config.reviewAccess?.instructions?.trim();
|
||||
if (ins && !parts[0]!.includes(ins)) parts.push(`Sign-in: ${ins}`);
|
||||
const del = config.accounts?.enabled ? config.accounts.inApp?.trim() : '';
|
||||
|
|
@ -29,8 +30,8 @@ export interface ReviewDetailPlan {
|
|||
* (throws when it is missing, so a submission never goes out with "sign-in required" and no account); without it the
|
||||
* demo fields are cleared.
|
||||
*/
|
||||
export function reviewDetailPlan(config: Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>, contact: Record<string, string>, demo: DemoAccount | null): ReviewDetailPlan {
|
||||
const notes = reviewNotes(config);
|
||||
export function reviewDetailPlan(config: Pick<ShipConfig, 'apple' | 'reviewAccess' | 'accounts'>, contact: Record<string, string>, demo: DemoAccount | null, platform?: string): ReviewDetailPlan {
|
||||
const notes = reviewNotes(config, platform);
|
||||
const bytes = new TextEncoder().encode(notes).length;
|
||||
if (bytes > NOTES_MAX_BYTES) throw new Error(`review notes are ${bytes} bytes; App Store Connect takes ${NOTES_MAX_BYTES}`);
|
||||
const required = !!config.reviewAccess?.signInRequired;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ export interface AppleSnapshot {
|
|||
build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null;
|
||||
/** Locales the config expects (apple: entries of `languages`). */
|
||||
expectedLocales: string[];
|
||||
locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record<string, number> }[];
|
||||
locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; promotionalText?: string; whatsNew?: string; screenshots: Record<string, number> }[];
|
||||
/** apple.macos facts for the macOS claim check (validate fills it): the config's macOS review notes (what `apple review-details
|
||||
* --platform macos` would send) and the gameCenter / noIap switches. */
|
||||
macos?: { notes?: string; gameCenter?: boolean; noIap?: boolean };
|
||||
requiredDisplayTypes: string[];
|
||||
/** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */
|
||||
iphoneDuo?: boolean;
|
||||
|
|
@ -208,6 +211,35 @@ export function copyrightRules(s: Pick<AppleSnapshot, 'version' | 'configCopyrig
|
|||
|
||||
const PLATFORM_NAMES: Record<string, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
|
||||
|
||||
const MACOS_CLAIMS: { kind: string; re: RegExp; needs?: 'noGameCenter' | 'noIap'; what: string }[] = [
|
||||
{ kind: 'game-center', re: /game\s*center/i, needs: 'noGameCenter', what: 'Game Center (set apple.macos.gameCenter: true when the Mac app has it)' },
|
||||
{ kind: 'touch', re: /\b(tap|taps|tapping|swipe|swipes|swiping|pinch|pinches)\b|touch[\s-]?screen/i, what: 'touch words (tap, swipe, pinch, touch screen)' },
|
||||
{ kind: 'ios-device', re: /\b(iphone|ipad)s?\b/i, what: 'an iPhone / iPad mention' },
|
||||
{ kind: 'iap', re: /in[\s-]app[\s-]purchases?|\biaps?\b|restore purchases/i, needs: 'noIap', what: 'an in-app purchase line (apple.macos.noIap is set)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* A macOS version's texts (description, promotional text, what's new, keywords, subtitle) and review notes (live, and
|
||||
* the config's apple.macos.reviewNotes / apple.info.reviewNotes) that carry iOS-only claims. Warnings only. Game Center
|
||||
* is flagged unless apple.macos.gameCenter is true; in-app purchase lines only when apple.macos.noIap is set. Pure.
|
||||
*/
|
||||
export function macosClaimRules(s: Pick<AppleSnapshot, 'version' | 'locales' | 'reviewDetail' | 'macos'>): Finding[] {
|
||||
if (s.version?.platform !== 'MAC_OS') return [];
|
||||
const texts: { where: string; text: string }[] = [];
|
||||
for (const l of s.locales) for (const k of ['subtitle', 'description', 'promotionalText', 'whatsNew', 'keywords'] as const) if (l[k]) texts.push({ where: `${l.locale} ${k}`, text: l[k]! });
|
||||
if (s.reviewDetail?.notes) texts.push({ where: 'review notes (on the store)', text: s.reviewDetail.notes });
|
||||
if (s.macos?.notes) texts.push({ where: 'review notes (config)', text: s.macos.notes });
|
||||
const out: Finding[] = [];
|
||||
for (const c of MACOS_CLAIMS) {
|
||||
if (c.needs === 'noGameCenter' && s.macos?.gameCenter) continue;
|
||||
if (c.needs === 'noIap' && !s.macos?.noIap) continue;
|
||||
const hits = [...new Set(texts.filter(t => c.re.test(t.text)).map(t => t.where))];
|
||||
if (hits.length) out.push({ store: 'apple', level: 'warn', code: 'macos-claims', msg: `macOS version ${s.version.versionString}: ${c.what} in ${hits.slice(0, 6).join(', ')}${hits.length > 6 ? ` (+${hits.length - 6} more)` : ''}`,
|
||||
fix: 'a separate macOS listing file (apple.macos.listing, then storebound apple listing --platform macos) and apple.macos.reviewNotes (storebound apple review-details --platform macos)' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function appleRules(s: AppleSnapshot): Finding[] {
|
||||
const f: Finding[] = [];
|
||||
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction, url?: string) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}), ...(url ? { url } : {}) });
|
||||
|
|
@ -232,6 +264,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`);
|
||||
}
|
||||
f.push(...copyrightRules(s));
|
||||
f.push(...macosClaimRules(s));
|
||||
|
||||
if (!s.build) add('blocker', 'build', 'no build attached to the version', 'storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)');
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { firstProductReport } from './apple/first-products';
|
|||
import { requiredDisplayTypes } from './apple/screenshots';
|
||||
import { pendingScreenshots, setRows, type ShotRow, updateLedger } from './apple/screenshot-health';
|
||||
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
|
||||
import { reviewNotes } from './lib/review-details';
|
||||
import { reviewDemoAccount } from './lib/credentials';
|
||||
import { allSubscriptions, basePlanId } from './lib/subscriptions';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
|
|
@ -143,7 +144,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
shotRows.push(...setRows(shots, l.attributes.locale, s.attributes.screenshotDisplayType));
|
||||
}
|
||||
const ia = infoLocs.get(l.attributes.locale) ?? {};
|
||||
locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, screenshots });
|
||||
locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, promotionalText: l.attributes.promotionalText ?? undefined, whatsNew: l.attributes.whatsNew ?? undefined, screenshots });
|
||||
}
|
||||
reviewDetail = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null;
|
||||
}
|
||||
|
|
@ -188,6 +189,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple, platform),
|
||||
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
|
||||
macos: platform === 'MAC_OS' ? { notes: reviewNotes(config, 'macos'), gameCenter: config.apple?.macos?.gameCenter, noIap: config.apple?.macos?.noIap } : undefined,
|
||||
macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac,
|
||||
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
||||
availability: !!avail?.data,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,12 @@ answers the compliance facts no file proves, all in one batch at the start.
|
|||
10. **API · Review details.** `storebound apple review-details` (contact from `.storebound/credentials.json`
|
||||
`reviewContact`, or `--contact-from <Apple ID of another app of the team>`; notes from the config). Apps behind a
|
||||
sign-in: the demo account from `.storebound/` (`review-access` skill). Apps with accounts: in-app deletion
|
||||
(`account-deletion` skill).
|
||||
(`account-deletion` skill). An app with an iOS **and** a macOS version: `--platform macos` (`apple listing`, `apple
|
||||
review-details`) uses `apple.macos.listing` (its own appstore file) and `apple.macos.reviewNotes` when set, else
|
||||
the iOS ones; the Mac app often has no Game Center, IAP or ads, so say so there. `storebound validate --platform
|
||||
macos` warns about iOS-only claims in the Mac texts and notes (Game Center unless `apple.macos.gameCenter: true`,
|
||||
tap / swipe / pinch / touch screen, iPhone / iPad, and IAP lines when `apple.macos.noIap: true`). Not `apple.mac`
|
||||
(that only turns Mac screenshots on).
|
||||
11. **API · In-app purchases and subscriptions.** `store-iap-setup` and `store-subscriptions` skills. First
|
||||
IAPs and subscriptions go to review **with** this version: `storebound apple iap-submit --with-version --dry`,
|
||||
then (after the owner's yes, since it submits) `storebound apple iap-submit --with-version --yes`. On a real first
|
||||
|
|
|
|||
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