Compare commits

..
29 changed files with 198 additions and 2473 deletions

No files matched your search

+1 -6
View File
@@ -8,16 +8,11 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [worker] Read `WORKER_CAPABILITIES_BASE64` and set `EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED` from declared worker capabilities. ([dca786a9](https://github.com/expo/eas-cli/commit/dca786a9948dce43ddbb753a2c4371bd2b8386f0) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
+1 -1
View File
@@ -43,7 +43,7 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.19",
"@expo/apple-utils": "2.1.16",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
-293
View File
@@ -515,57 +515,6 @@
"lootBox": {
"type": "boolean",
"description": "Does your app contain purchasable loot boxes? These virtual containers provide players with in-game items, including functional cards or cosmetic modifications, based on random chance."
},
"advertising": {
"type": "boolean",
"description": "Does your app display advertising?"
},
"ageAssurance": {
"type": "boolean",
"description": "Does your app implement age assurance mechanisms?"
},
"ageRatingOverrideV2": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/definitions/apple/AppleAgeRatingOverrideV2"
}
],
"description": "Override the age rating using the V2 rating system. Allows setting a more granular age rating than the auto-calculated value."
},
"developerAgeRatingInfoUrl": {
"oneOf": [
{
"type": "null"
},
{
"type": "string",
"format": "uri"
}
],
"description": "URL to additional developer-provided information about the app's age rating."
},
"gunsOrOtherWeapons": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain depictions of guns or other weapons?"
},
"healthOrWellnessTopics": {
"type": "boolean",
"description": "Does your app contain health or wellness topics?"
},
"messagingAndChat": {
"type": "boolean",
"description": "Does your app contain messaging or chat functionality?"
},
"parentalControls": {
"type": "boolean",
"description": "Does your app implement parental controls?"
},
"userGeneratedContent": {
"type": "boolean",
"description": "Does your app contain or display user-generated content?"
}
}
},
@@ -688,230 +637,6 @@
}
}
},
"AppleAppClip": {
"type": "object",
"additionalProperties": false,
"description": "App Clip metadata. Only applies to apps that ship an App Clip target.",
"properties": {
"defaultExperience": {
"$ref": "#/definitions/apple/AppleAppClipDefaultExperience",
"description": "The default experience for this App Clip. There is exactly one per app."
}
}
},
"AppleAppClipDefaultExperience": {
"type": "object",
"additionalProperties": false,
"description": "Default experience for an App Clip - the configuration shown when invoked without a custom experience.",
"properties": {
"action": {
"enum": ["OPEN", "VIEW", "PLAY"],
"description": "Action button shown in the App Clip card."
},
"releaseWithAppStoreVersion": {
"type": "boolean",
"description": "Whether to release this default experience alongside the next App Store version."
},
"reviewDetail": {
"$ref": "#/definitions/apple/AppleAppClipReviewDetail",
"description": "App Store review information for the App Clip default experience."
},
"info": {
"$ref": "#/definitions/apple/AppleAppClipInfo",
"description": "Localized subtitle and header image for the App Clip default experience."
}
}
},
"AppleAppClipReviewDetail": {
"type": "object",
"additionalProperties": false,
"description": "App Store review detail for an App Clip default experience.",
"required": ["invocationUrls"],
"properties": {
"invocationUrls": {
"type": "array",
"description": "Invocation URLs that App Review will use to launch the App Clip during review.",
"items": {
"type": "string",
"format": "uri"
}
}
}
},
"AppleAppClipLocalizedInfo": {
"type": "object",
"additionalProperties": false,
"description": "Localized subtitle and header image for an App Clip default experience.",
"properties": {
"subtitle": {
"type": "string",
"description": "Subtitle shown in the App Clip card. Apple limits this to 43 characters.",
"maxLength": 43
},
"headerImage": {
"type": "string",
"description": "Relative path (from project root) to the App Clip header image PNG."
}
}
},
"AppleAppClipInfo": {
"type": "object",
"description": "Localized App Clip information, keyed by locale.",
"additionalProperties": false,
"properties": {
"ar-SA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Arabic" },
"ca": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Catalan" },
"zh-Hans": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Simplified)" },
"zh-Hant": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Chinese (Traditional)" },
"hr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Croatian" },
"cs": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Czech" },
"da": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Danish" },
"nl-NL": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Dutch" },
"en-AU": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Australia)" },
"en-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (Canada)" },
"en-GB": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.K.)" },
"en-US": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "English (U.S.)" },
"fi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Finnish" },
"fr-FR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French" },
"fr-CA": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "French (Canada)" },
"de-DE": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "German" },
"el": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Greek" },
"he": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hebrew" },
"hi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hindi" },
"hu": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Hungarian" },
"id": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Indonesian" },
"it": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Italian" },
"ja": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Japanese" },
"ko": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Korean" },
"ms": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Malay" },
"no": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Norwegian" },
"pl": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Polish" },
"pt-BR": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Brazil)" },
"pt-PT": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Portuguese (Portugal)" },
"ro": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Romanian" },
"ru": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Russian" },
"sk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Slovak" },
"es-MX": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Mexico)" },
"es-ES": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Spanish (Spain)" },
"sv": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Swedish" },
"th": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Thai" },
"tr": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Turkish" },
"uk": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Ukrainian" },
"vi": { "$ref": "#/definitions/apple/AppleAppClipLocalizedInfo", "description": "Vietnamese" }
}
},
"ApplePrivacy": {
"type": "object",
"additionalProperties": false,
"description": "Privacy metadata. Currently only contains App Privacy Nutrition Labels (data usage).",
"properties": {
"dataUsage": {
"$ref": "#/definitions/apple/AppleDataUsage",
"description": "App Privacy Nutrition Labels. Declares what data the app collects, how it is used, and how it is linked to the user. Required for new app submissions since 2021."
}
}
},
"AppleDataUsage": {
"type": "object",
"additionalProperties": false,
"description": "Privacy Nutrition Labels — declarative declaration of data the app collects. When `dataNotCollected` is true, no data is collected and `categories` must be empty.",
"properties": {
"dataNotCollected": {
"type": "boolean",
"description": "Whether the app collects no data at all. Mirrors Apple's \"Data Not Collected\" toggle. When true, `categories` must be empty."
},
"categories": {
"type": "array",
"description": "Per-category data usage declarations.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryEntry"
}
}
}
},
"AppleDataUsageCategoryEntry": {
"type": "object",
"additionalProperties": false,
"required": ["category"],
"description": "A single Privacy Nutrition Labels declaration: pairs a data category with the purposes it is used for and the data protection / linkage classification.",
"properties": {
"category": {
"$ref": "#/definitions/apple/AppleDataUsageCategoryId",
"description": "Apple data category."
},
"purposes": {
"type": "array",
"description": "Purposes the category is used for. At least one entry is required when the category is collected.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsagePurposeId"
}
},
"protections": {
"type": "array",
"description": "Data protection / linkage classification(s). A single category can be both linked to the user and used for tracking.",
"items": {
"$ref": "#/definitions/apple/AppleDataUsageDataProtectionId"
}
}
}
},
"AppleDataUsageCategoryId": {
"enum": [
"ADVERTISING_DATA",
"AUDIO",
"BROWSING_HISTORY",
"COARSE_LOCATION",
"CONTACTS",
"CRASH_DATA",
"CREDIT_AND_FRAUD",
"CUSTOMER_SUPPORT",
"DEVICE_ID",
"EMAIL_ADDRESS",
"EMAILS_OR_TEXT_MESSAGES",
"ENVIRONMENTAL_SCANNING",
"FITNESS",
"GAMEPLAY_CONTENT",
"HANDS",
"HEAD_MOVEMENT",
"HEALTH",
"NAME",
"OTHER_CONTACT_INFO",
"OTHER_DATA",
"OTHER_DIAGNOSTIC_DATA",
"OTHER_FINANCIAL_INFO",
"OTHER_USAGE_DATA",
"OTHER_USER_CONTENT",
"PAYMENT_INFORMATION",
"PERFORMANCE_DATA",
"PHONE_NUMBER",
"PHOTOS_OR_VIDEOS",
"PHYSICAL_ADDRESS",
"PRECISE_LOCATION",
"PRODUCT_INTERACTION",
"PURCHASE_HISTORY",
"SEARCH_HISTORY",
"SENSITIVE_INFO",
"USER_ID"
]
},
"AppleDataUsagePurposeId": {
"enum": [
"THIRD_PARTY_ADVERTISING",
"DEVELOPERS_ADVERTISING",
"ANALYTICS",
"PRODUCT_PERSONALIZATION",
"APP_FUNCTIONALITY",
"OTHER_PURPOSES"
]
},
"AppleDataUsageDataProtectionId": {
"enum": [
"DATA_USED_TO_TRACK_YOU",
"DATA_LINKED_TO_YOU",
"DATA_NOT_LINKED_TO_YOU",
"DATA_NOT_COLLECTED"
]
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
@@ -925,16 +650,6 @@
"FIFTEEN_PLUS",
"NINETEEN_PLUS"
]
},
"AppleAgeRatingOverrideV2": {
"enum": [
"NONE",
"NINE_PLUS",
"THIRTEEN_PLUS",
"SIXTEEN_PLUS",
"EIGHTEEN_PLUS",
"UNRATED"
]
}
},
"AppleConfig": {
@@ -1142,14 +857,6 @@
"review": {
"$ref": "#/definitions/apple/AppleReview",
"description": "Info for the App Store reviewer"
},
"appClip": {
"$ref": "#/definitions/apple/AppleAppClip",
"description": "App Clip metadata. Only applies to apps that ship an App Clip target."
},
"privacy": {
"$ref": "#/definitions/apple/ApplePrivacy",
"description": "Privacy metadata, including App Privacy Nutrition Labels (data usage). Required for new app submissions since 2021."
}
}
}
@@ -1,11 +0,0 @@
# CLAUDE.md - metadata commands
`metadata:push` validates `store.config.json` against `packages/eas-cli/schema/metadata-0.json`. The schema can drift from the apple config reader/writer, and when it does `metadata:pull` happily writes fields that `metadata:push` then rejects — and the failure looks like the user's fault.
**Whenever you touch `src/metadata/apple/config/{reader,writer}.ts`, update `metadata-0.json` in the same change.** For new advisory fields:
- Add the property under `definitions.apple.AppleAdvisory.properties`.
- For enums from `@expo/apple-utils`, add a matching `Apple<Name>` enum definition with the exact string values and reference it via `$ref`. Wrap in `oneOf` with `{ "type": "null" }` if the writer ever persists `null`.
- Don't add to `required` — that breaks existing user configs. Optional with a sensible default in the writer is the convention.
The drift is caught by `src/metadata/apple/config/__tests__/writer.test.ts` ("writer output for ... passes JSON schema validation"). If you change apple-utils types or fixtures, that test will tell you exactly what the schema is missing.
@@ -6,7 +6,6 @@ import path from 'path';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { downloadMetadataAsync } from '../../metadata/download';
@@ -22,7 +21,10 @@ export default class MetadataPull extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
...EASNonInteractiveFlag,
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
};
static override contextDefinition = {
@@ -4,7 +4,6 @@ import { Flags } from '@oclif/core';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { handleMetadataError } from '../../metadata/errors';
@@ -20,7 +19,10 @@ export default class MetadataPush extends EasCommand {
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
...EASNonInteractiveFlag,
'non-interactive': Flags.boolean({
default: false,
description: 'Run the command in non-interactive mode.',
}),
};
static override contextDefinition = {
@@ -34,15 +34,6 @@ export const emptyAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: null,
koreaAgeRatingOverride: null,
lootBox: null,
advertising: null,
ageAssurance: null,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: null,
healthOrWellnessTopics: null,
messagingAndChat: null,
parentalControls: null,
userGeneratedContent: null,
};
export const leastRestrictiveAdvisory: AgeRatingDeclarationProps = {
@@ -64,15 +55,6 @@ export const leastRestrictiveAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: RatingOverride.NONE,
koreaAgeRatingOverride: KoreaRatingOverride.NONE,
lootBox: false,
advertising: false,
ageAssurance: false,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: Rating.NONE,
healthOrWellnessTopics: false,
messagingAndChat: false,
parentalControls: false,
userGeneratedContent: false,
};
export const mostRestrictiveAdvisory: AgeRatingDeclarationProps = {
@@ -94,15 +76,6 @@ export const mostRestrictiveAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: RatingOverride.SEVENTEEN_PLUS,
koreaAgeRatingOverride: KoreaRatingOverride.NINETEEN_PLUS,
lootBox: true,
advertising: true,
ageAssurance: true,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: Rating.FREQUENT_OR_INTENSE,
healthOrWellnessTopics: true,
messagingAndChat: true,
parentalControls: true,
userGeneratedContent: true,
};
export const kidsSixToEightAdvisory: AgeRatingDeclarationProps = {
@@ -124,13 +97,4 @@ export const kidsSixToEightAdvisory: AgeRatingDeclarationProps = {
ageRatingOverride: RatingOverride.NONE,
koreaAgeRatingOverride: KoreaRatingOverride.NONE,
lootBox: false,
advertising: false,
ageAssurance: false,
ageRatingOverrideV2: null,
developerAgeRatingInfoUrl: null,
gunsOrOtherWeapons: Rating.NONE,
healthOrWellnessTopics: false,
messagingAndChat: false,
parentalControls: false,
userGeneratedContent: false,
};
@@ -176,43 +176,3 @@ describe('getVersion', () => {
});
});
});
describe('getAppClip', () => {
it('returns null when no app clip is configured', () => {
const reader = new AppleConfigReader({});
expect(reader.getAppClip()).toBeNull();
expect(reader.getAppClipDefaultExperience()).toBeNull();
expect(reader.getAppClipLocales()).toEqual([]);
});
it('returns the configured app clip block', () => {
const reader = new AppleConfigReader({
appClip: {
defaultExperience: {
action: 'OPEN',
releaseWithAppStoreVersion: true,
reviewDetail: { invocationUrls: ['https://example.com/clip'] },
info: {
'en-US': { subtitle: 'Quick experience', headerImage: 'header.png' },
'fr-FR': { subtitle: 'Expérience rapide' },
},
},
},
});
expect(reader.getAppClipDefaultExperience()).toMatchObject({
action: 'OPEN',
releaseWithAppStoreVersion: true,
reviewDetail: { invocationUrls: ['https://example.com/clip'] },
});
expect(reader.getAppClipLocales().sort()).toEqual(['en-US', 'fr-FR']);
expect(reader.getAppClipLocalizedInfo('en-US')).toEqual({
subtitle: 'Quick experience',
headerImage: 'header.png',
});
expect(reader.getAppClipLocalizedInfo('fr-FR')).toEqual({
subtitle: 'Expérience rapide',
});
expect(reader.getAppClipLocalizedInfo('de-DE')).toBeNull();
});
});
@@ -12,7 +12,6 @@ import { nameAndDemoReviewDetails, nameOnlyReviewDetails } from './fixtures/appS
import { automaticRelease, manualRelease, scheduledRelease } from './fixtures/appStoreVersion';
import { dutchVersion, englishVersion } from './fixtures/appStoreVersionLocalization';
import { phasedRelease } from './fixtures/appStoreVersionPhasedRelease';
import { validateConfig } from '../../../config/validate';
import { AppleConfigWriter } from '../writer';
describe('toSchema', () => {
@@ -43,22 +42,6 @@ describe('setAgeRating', () => {
writer.setAgeRating(mostRestrictiveAdvisory);
expect(writer.schema.advisory).toMatchObject(mostRestrictiveAdvisory);
});
// Drift guard: every advisory field that the writer emits must be allowed by
// metadata-0.json. If you add a field to writer.ts (or apple-utils surfaces a
// new one upstream), update the schema in the same change. Without this
// test, the drift only shows up at `metadata:push` time as a confusing
// validation error against the user's freshly pulled config.
it.each([
['emptyAdvisory', emptyAdvisory],
['leastRestrictiveAdvisory', leastRestrictiveAdvisory],
['mostRestrictiveAdvisory', mostRestrictiveAdvisory],
['kidsSixToEightAdvisory', kidsSixToEightAdvisory],
])('writer output for %s passes JSON schema validation', (_name, advisory) => {
const writer = new AppleConfigWriter();
writer.setAgeRating(advisory);
expect(validateConfig(writer.toSchema())).toEqual([]);
});
});
describe('setInfoLocale', () => {
@@ -346,72 +329,3 @@ describe('setReviewDetails', () => {
});
});
});
describe('setAppClipDefaultExperience', () => {
it('sets default experience attributes', () => {
const writer = new AppleConfigWriter();
writer.setAppClipDefaultExperience({
action: 'OPEN',
releaseWithAppStoreVersion: true,
});
expect(writer.schema.appClip?.defaultExperience).toMatchObject({
action: 'OPEN',
releaseWithAppStoreVersion: true,
});
});
});
describe('setAppClipReviewDetail', () => {
it('sets invocation urls', () => {
const writer = new AppleConfigWriter();
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toEqual({
invocationUrls: ['https://example.com/clip'],
});
});
it('removes review detail when null', () => {
const writer = new AppleConfigWriter();
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
writer.setAppClipReviewDetail(null);
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toBeUndefined();
});
it('removes review detail when invocationUrls is empty', () => {
const writer = new AppleConfigWriter();
writer.setAppClipReviewDetail({ invocationUrls: ['https://example.com/clip'] });
writer.setAppClipReviewDetail({ invocationUrls: [] });
expect(writer.schema.appClip?.defaultExperience?.reviewDetail).toBeUndefined();
});
});
describe('setAppClipLocalizedInfo', () => {
it('writes localized subtitle and header image', () => {
const writer = new AppleConfigWriter();
writer.setAppClipLocalizedInfo('en-US', {
subtitle: 'Quick experience',
headerImage: 'store/apple/app-clip/en-US/header.png',
});
expect(writer.schema.appClip?.defaultExperience?.info?.['en-US']).toEqual({
subtitle: 'Quick experience',
headerImage: 'store/apple/app-clip/en-US/header.png',
});
});
it('omits empty locale entries', () => {
const writer = new AppleConfigWriter();
writer.setAppClipLocalizedInfo('en-US', { subtitle: 'Quick' });
writer.setAppClipLocalizedInfo('en-US', {});
expect(writer.schema.appClip?.defaultExperience?.info?.['en-US']).toBeUndefined();
});
it('keeps multiple locales independent', () => {
const writer = new AppleConfigWriter();
writer.setAppClipLocalizedInfo('en-US', { subtitle: 'Quick' });
writer.setAppClipLocalizedInfo('fr-FR', { subtitle: 'Rapide' });
expect(writer.schema.appClip?.defaultExperience?.info).toMatchObject({
'en-US': { subtitle: 'Quick' },
'fr-FR': { subtitle: 'Rapide' },
});
});
});
@@ -16,15 +16,7 @@ import {
import uniq from '../../../utils/expodash/uniq';
import { AttributesOf } from '../../utils/asc';
import { removeDatePrecision } from '../../utils/date';
import {
AppleAppClip,
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
} from '../types';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
@@ -65,15 +57,6 @@ export class AppleConfigReader {
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
advertising: attributes.advertising ?? false,
ageAssurance: attributes.ageAssurance ?? false,
ageRatingOverrideV2: attributes.ageRatingOverrideV2 ?? null,
developerAgeRatingInfoUrl: attributes.developerAgeRatingInfoUrl ?? null,
gunsOrOtherWeapons: attributes.gunsOrOtherWeapons ?? Rating.NONE,
healthOrWellnessTopics: attributes.healthOrWellnessTopics ?? false,
messagingAndChat: attributes.messagingAndChat ?? false,
parentalControls: attributes.parentalControls ?? false,
userGeneratedContent: attributes.userGeneratedContent ?? false,
};
}
@@ -238,32 +221,4 @@ export class AppleConfigReader {
public getPreviews(locale: string): ApplePreviews | null {
return this.schema.info?.[locale]?.previews ?? null;
}
/** Get the App Clip configuration block, or null if no App Clip is configured. */
public getAppClip(): AppleAppClip | null {
return this.schema.appClip ?? null;
}
/** Get the App Clip default experience, or null if not configured. */
public getAppClipDefaultExperience(): AppleAppClipDefaultExperience | null {
return this.schema.appClip?.defaultExperience ?? null;
}
/** Locales that have App Clip default-experience info configured. */
public getAppClipLocales(): string[] {
return uniq(Object.keys(this.schema.appClip?.defaultExperience?.info ?? {}));
}
/** Get the localized App Clip info (subtitle + header image) for a specific locale. */
public getAppClipLocalizedInfo(locale: string): AppleAppClipLocalizedInfo | null {
return this.schema.appClip?.defaultExperience?.info?.[locale] ?? null;
}
/**
* Get the privacy data usage block (Privacy Nutrition Labels), or `null` if
* not configured. When `null`, the data usage task is a no-op.
*/
public getDataUsage(): AppleDataUsage | null {
return this.schema.privacy?.dataUsage ?? null;
}
}
@@ -13,15 +13,7 @@ import {
} from '@expo/apple-utils';
import { AttributesOf } from '../../utils/asc';
import {
AppleAppClipDefaultExperience,
AppleAppClipLocalizedInfo,
AppleAppClipReviewDetail,
AppleDataUsage,
AppleMetadata,
ApplePreviews,
AppleScreenshots,
} from '../types';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
/**
* Serializes the Apple ASC entities into the metadata configuration schema.
@@ -63,15 +55,6 @@ export class AppleConfigWriter {
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
advertising: attributes.advertising ?? false,
ageAssurance: attributes.ageAssurance ?? false,
ageRatingOverrideV2: attributes.ageRatingOverrideV2 ?? null,
developerAgeRatingInfoUrl: attributes.developerAgeRatingInfoUrl ?? null,
gunsOrOtherWeapons: attributes.gunsOrOtherWeapons ?? Rating.NONE,
healthOrWellnessTopics: attributes.healthOrWellnessTopics ?? false,
messagingAndChat: attributes.messagingAndChat ?? false,
parentalControls: attributes.parentalControls ?? false,
userGeneratedContent: attributes.userGeneratedContent ?? false,
};
}
@@ -218,62 +201,6 @@ export class AppleConfigWriter {
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
/**
* Set the privacy data usage block. Pass `null` to clear it (e.g. when the
* remote app has no data usage entries declared yet).
*/
public setDataUsage(dataUsage: AppleDataUsage | null): void {
if (!dataUsage) {
if (this.schema.privacy) {
delete this.schema.privacy.dataUsage;
if (Object.keys(this.schema.privacy).length === 0) {
delete this.schema.privacy;
}
}
return;
}
this.schema.privacy = this.schema.privacy ?? {};
this.schema.privacy.dataUsage = dataUsage;
}
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
public setAppClipDefaultExperience(
attributes: Pick<AppleAppClipDefaultExperience, 'action' | 'releaseWithAppStoreVersion'>
): void {
this.schema.appClip = this.schema.appClip ?? {};
this.schema.appClip.defaultExperience = {
...this.schema.appClip.defaultExperience,
action: attributes.action,
releaseWithAppStoreVersion: attributes.releaseWithAppStoreVersion,
};
}
/** Set the App Clip App Store review detail (invocation URLs). */
public setAppClipReviewDetail(reviewDetail: AppleAppClipReviewDetail | null): void {
this.schema.appClip = this.schema.appClip ?? {};
this.schema.appClip.defaultExperience = this.schema.appClip.defaultExperience ?? {};
if (reviewDetail && reviewDetail.invocationUrls.length > 0) {
this.schema.appClip.defaultExperience.reviewDetail = reviewDetail;
} else {
delete this.schema.appClip.defaultExperience.reviewDetail;
}
}
/** Set per-locale App Clip info (subtitle + header image). */
public setAppClipLocalizedInfo(locale: string, info: AppleAppClipLocalizedInfo): void {
this.schema.appClip = this.schema.appClip ?? {};
this.schema.appClip.defaultExperience = this.schema.appClip.defaultExperience ?? {};
this.schema.appClip.defaultExperience.info = this.schema.appClip.defaultExperience.info ?? {};
if (info.subtitle || info.headerImage) {
this.schema.appClip.defaultExperience.info[locale] = {
subtitle: optional(info.subtitle ?? null),
headerImage: optional(info.headerImage ?? null),
};
} else {
delete this.schema.appClip.defaultExperience.info[locale];
}
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+1 -5
View File
@@ -1,11 +1,9 @@
import type { App } from '@expo/apple-utils';
import type { AgeRatingData } from './tasks/age-rating';
import type { AppClipData } from './tasks/app-clip';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { DataUsageData } from './tasks/data-usage';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
@@ -18,9 +16,7 @@ export type AppleData = { app: App; projectDir: string } & AppInfoData &
AgeRatingData &
AppReviewData &
ScreenshotsData &
PreviewsData &
AppClipData &
DataUsageData;
PreviewsData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
@@ -1,315 +0,0 @@
import {
App,
AppClip,
AppClipAppStoreReviewDetail,
AppClipDefaultExperience,
AppClipDefaultExperienceLocalization,
AppStoreVersion,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData, PartialAppleData } from '../../data';
import { AppClipTask } from '../app-clip';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeExperience(
id: string,
attrs: Partial<AppClipDefaultExperience['attributes']> = {}
): AppClipDefaultExperience {
return new AppClipDefaultExperience(requestContext, id, {
action: null,
...attrs,
} as any);
}
function makeLocalization(
id: string,
locale: string,
subtitle: string | null = null
): AppClipDefaultExperienceLocalization {
return new AppClipDefaultExperienceLocalization(requestContext, id, {
locale,
subtitle,
} as any);
}
function makeVersion(id: string): AppStoreVersion {
return new AppStoreVersion(requestContext, id, { versionString: '1.0.0' } as any);
}
const APP_CLIPS_EMPTY_RESPONSE = {
data: [],
links: { self: 'https://api.appstoreconnect.apple.com/v1/apps/stub-id/appClips' },
meta: { paging: { total: 0, limit: 50 } },
};
describe(AppClipTask, () => {
describe('prepareAsync', () => {
it('initializes empty state when the app has no app clips', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(uri => uri.startsWith(`/v1/${App.type}/stub-id/${AppClip.type}`))
.reply(200, APP_CLIPS_EMPTY_RESPONSE);
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppClipTask().prepareAsync({ context });
expect(context.appClip).toBeNull();
expect(context.appClipDefaultExperience).toBeNull();
expect(context.appClipLocalizations?.size).toBe(0);
expect(context.appClipHeaderImages?.size).toBe(0);
expect(context.appClipReviewDetail).toBeNull();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when no default experience is loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as any,
});
expect(writer.setAppClipDefaultExperience).not.toBeCalled();
expect(writer.setAppClipLocalizedInfo).not.toBeCalled();
expect(writer.setAppClipReviewDetail).not.toBeCalled();
});
it('writes from current version experience when present', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const experience = makeExperience('exp-current', { action: 'PLAY' as any });
const localization = makeLocalization('loc-1', 'en-US', 'Quick play');
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
appClipDefaultExperience: experience,
appClipTemplateExperience: null,
appClipLocalizations: new Map([['en-US', localization]]),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
projectDir: '/test/project',
} as any,
});
expect(writer.setAppClipDefaultExperience).toBeCalledWith(
expect.objectContaining({ action: 'PLAY' })
);
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
'en-US',
expect.objectContaining({ subtitle: 'Quick play' })
);
});
it('falls back to template experience when current version has none', async () => {
// Regression test: when bumping the app version, the new version has no
// default experience yet. Pull should still populate config from the
// most recent prior experience (the template) so the user has data to
// edit, instead of writing nothing.
const writer = jest.mocked(new AppleConfigWriter());
const template = makeExperience('exp-template', { action: 'OPEN' as any });
const localization = makeLocalization('loc-1', 'en-US', 'Old subtitle');
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
appClipDefaultExperience: null,
appClipTemplateExperience: template,
appClipLocalizations: new Map([['en-US', localization]]),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
projectDir: '/test/project',
} as any,
});
expect(writer.setAppClipDefaultExperience).toBeCalledWith(
expect.objectContaining({ action: 'OPEN' })
);
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
'en-US',
expect.objectContaining({ subtitle: 'Old subtitle' })
);
});
it('preserves expected local path when header image is not yet downloadable', async () => {
// Regression test for the App Clip header image race condition: ASC
// hasn't yet rendered the imageAsset (typically right after a fresh
// upload), so the download URL is null. We should still preserve the
// headerImage path in config so subsequent pushes don't try to delete
// the in-progress upload.
const writer = jest.mocked(new AppleConfigWriter());
const experience = makeExperience('exp-1', { action: 'OPEN' as any });
const localization = makeLocalization('loc-1', 'en-US', 'Sub');
// Header image with no rendered imageAsset (still processing).
const headerImage = {
attributes: { fileName: 'header.png', imageAsset: null },
getImageAssetUrl: () => null,
} as any;
await new AppClipTask().downloadAsync({
config: writer,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
appClipDefaultExperience: experience,
appClipTemplateExperience: null,
appClipLocalizations: new Map([['en-US', localization]]),
appClipHeaderImages: new Map([['en-US', headerImage]]),
appClipReviewDetail: null,
projectDir: '/test/project',
} as any,
});
expect(writer.setAppClipLocalizedInfo).toBeCalledWith(
'en-US',
expect.objectContaining({
subtitle: 'Sub',
headerImage: 'store/apple/app-clip/en-US/header.png',
})
);
});
});
describe('uploadAsync', () => {
it('skips when no app clip is configured in store config', async () => {
const reader = new AppleConfigReader({});
await new AppClipTask().uploadAsync({
config: reader,
context: {
appClip: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as AppleData,
});
// No HTTP calls should be made when nothing is configured.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('warns and aborts when ASC has no app clip but config does', async () => {
const reader = new AppleConfigReader({
appClip: {
defaultExperience: { action: 'OPEN' },
},
});
await new AppClipTask().uploadAsync({
config: reader,
context: {
appClip: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as AppleData,
});
// No HTTP calls when there's no clip in ASC to update.
expect(nock.pendingMocks()).toHaveLength(0);
});
it('warns and aborts when no editable app store version is loaded', async () => {
const reader = new AppleConfigReader({
appClip: { defaultExperience: { action: 'OPEN' } },
});
await new AppClipTask().uploadAsync({
config: reader,
context: {
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
version: null,
appClipDefaultExperience: null,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as any,
});
expect(nock.pendingMocks()).toHaveLength(0);
});
it('skips PATCH when default experience action already matches config', async () => {
// Regression test: Apple rejects PATCHes on `action` once the linked
// version is locked (in review/released), so we must skip the call when
// nothing changed to avoid spurious failures on round-trip pushes.
const reader = new AppleConfigReader({
appClip: { defaultExperience: { action: 'PLAY' } },
});
const existing = makeExperience('exp-current', { action: 'PLAY' as any });
// Spy on updateAsync to assert it's not invoked.
const updateSpy = jest.spyOn(existing, 'updateAsync');
await new AppClipTask().uploadAsync({
config: reader,
context: {
app: new App(requestContext, 'app-1', {} as any),
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
version: makeVersion('ver-1'),
appClipDefaultExperience: existing,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: null,
} as any,
});
expect(updateSpy).not.toBeCalled();
});
it('skips review detail PATCH when invocation URLs already match', async () => {
const reader = new AppleConfigReader({
appClip: {
defaultExperience: {
action: 'OPEN',
reviewDetail: { invocationUrls: ['https://a.example/', 'https://b.example/'] },
},
},
});
const existing = makeExperience('exp-current', { action: 'OPEN' as any });
const review = new AppClipAppStoreReviewDetail(requestContext, 'rev-1', {
invocationUrls: ['https://a.example/', 'https://b.example/'],
} as any);
const updateSpy = jest.spyOn(review, 'updateAsync');
await new AppClipTask().uploadAsync({
config: reader,
context: {
app: new App(requestContext, 'app-1', {} as any),
appClip: new AppClip(requestContext, 'clip-1', { bundleId: 'com.x.Clip' }),
version: makeVersion('ver-1'),
appClipDefaultExperience: existing,
appClipTemplateExperience: null,
appClipLocalizations: new Map(),
appClipHeaderImages: new Map(),
appClipReviewDetail: review,
} as any,
});
expect(updateSpy).not.toBeCalled();
});
});
});
@@ -1,403 +0,0 @@
import {
App,
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppleData } from '../../data';
import { DataUsageTask } from '../data-usage';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
function makeRow(
id: string,
category: AppDataUsageCategoryId | null,
purpose: AppDataUsagePurposeId | null,
protection: AppDataUsageDataProtectionId | null
): AppDataUsage {
const row = new AppDataUsage(requestContext, id, {
category: category ? ({ id: category } as any) : undefined,
purpose: purpose ? ({ id: purpose } as any) : undefined,
dataProtection: protection ? ({ id: protection } as any) : undefined,
} as any);
// Stub deleteAsync so the diff path doesn't actually hit the network.
(row as any).deleteAsync = jest.fn().mockResolvedValue(undefined);
return row;
}
function makeApp(): App {
const app = new App(requestContext, 'app-id', {} as any);
(app as any).getAppDataUsagesAsync = jest.fn().mockResolvedValue([]);
(app as any).getAppDataUsagesPublishStateAsync = jest.fn().mockResolvedValue([]);
(app as any).createAppDataUsageAsync = jest.fn(async (params: any) => {
return makeRow(
`created-${params.appDataUsageCategory ?? 'none'}-${params.appDataUsagePurpose ?? 'none'}-${
params.appDataUsageProtection ?? 'none'
}`,
params.appDataUsageCategory ?? null,
params.appDataUsagePurpose ?? null,
params.appDataUsageProtection ?? null
);
});
return app;
}
function makePublishState(): AppDataUsagesPublishState {
const state = new AppDataUsagesPublishState(requestContext, 'app-id', {
published: false,
lastPublished: '',
lastPublishedBy: '',
} as any);
(state as any).updateAsync = jest.fn().mockResolvedValue(state);
return state;
}
describe(DataUsageTask, () => {
describe('prepareAsync', () => {
it('loads existing data usage rows and publish state', async () => {
const app = makeApp();
const existing = [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
(app as any).getAppDataUsagesAsync.mockResolvedValue(existing);
const publishState = makePublishState();
(app as any).getAppDataUsagesPublishStateAsync.mockResolvedValue([publishState]);
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toBe(existing);
expect(context.dataUsagesPublishState).toBe(publishState);
});
it('treats 404s as empty state', async () => {
const app = makeApp();
(app as any).getAppDataUsagesAsync.mockRejectedValue({ response: { status: 404 } });
(app as any).getAppDataUsagesPublishStateAsync.mockRejectedValue({
response: { status: 404 },
});
const context: any = { app, projectDir: '/p' };
await new DataUsageTask().prepareAsync({ context });
expect(context.dataUsages).toEqual([]);
expect(context.dataUsagesPublishState).toBeNull();
});
});
describe('downloadAsync', () => {
it('writes nothing when there are no rows', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: { dataUsages: [], dataUsagesPublishState: null } as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith(null);
});
it('collapses a single row into a category entry', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'row-1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
});
it('groups multiple rows by category and dedupes purposes/protections', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r3',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_NOT_LINKED_TO_YOU
),
],
dataUsagesPublishState: null,
} as any,
});
const arg = (writer.setDataUsage as jest.Mock).mock.calls[0][0];
expect(arg.categories).toHaveLength(2);
const contacts = arg.categories.find((c: any) => c.category === 'CONTACTS');
expect(contacts.purposes.sort()).toEqual(['ANALYTICS', 'APP_FUNCTIONALITY']);
expect(contacts.protections).toEqual(['DATA_LINKED_TO_YOU']);
const location = arg.categories.find((c: any) => c.category === 'PRECISE_LOCATION');
expect(location.purposes).toEqual(['APP_FUNCTIONALITY']);
expect(location.protections).toEqual(['DATA_NOT_LINKED_TO_YOU']);
});
it('emits dataNotCollected when only the sentinel row is present', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new DataUsageTask().downloadAsync({
config: writer,
context: {
dataUsages: [makeRow('r1', null, null, AppDataUsageDataProtectionId.DATA_NOT_COLLECTED)],
dataUsagesPublishState: null,
} as any,
});
expect(writer.setDataUsage).toHaveBeenCalledWith({ dataNotCollected: true });
});
});
describe('uploadAsync', () => {
it('skips when no data usage is configured', async () => {
const app = makeApp();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: null,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
});
it('creates rows for each (category × purpose × protection) tuple', async () => {
const app = makeApp();
const publishState = makePublishState();
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(2);
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('deletes rows that are no longer in the config and creates new ones', async () => {
const app = makeApp();
const publishState = makePublishState();
const stale = makeRow(
'stale',
AppDataUsageCategoryId.PRECISE_LOCATION,
AppDataUsagePurposeId.OTHER_PURPOSES,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const kept = makeRow(
'kept',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
(app as any).getAppDataUsagesAsync.mockResolvedValue([stale, kept]);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [stale, kept],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
{
category: 'EMAIL_ADDRESS',
purposes: ['APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
// stale row deleted, EMAIL_ADDRESS row created, CONTACTS untouched.
expect((stale as any).deleteAsync).toHaveBeenCalled();
expect((kept as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledTimes(1);
expect((app as any).createAppDataUsageAsync).toHaveBeenCalledWith({
appDataUsageCategory: 'EMAIL_ADDRESS',
appDataUsagePurpose: 'APP_FUNCTIONALITY',
appDataUsageProtection: 'DATA_LINKED_TO_YOU',
});
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
it('makes no API calls when the config matches existing rows exactly', async () => {
const app = makeApp();
const publishState = makePublishState();
const existing = makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
);
const ctx: AppleData = {
app,
projectDir: '/p',
dataUsages: [existing],
dataUsagesPublishState: publishState,
} as any;
await new DataUsageTask().uploadAsync({
config: new AppleConfigReader({
privacy: {
dataUsage: {
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
},
},
}),
context: ctx,
});
expect((existing as any).deleteAsync).not.toHaveBeenCalled();
expect((app as any).createAppDataUsageAsync).not.toHaveBeenCalled();
// Publish state is still flipped to ensure published.
expect((publishState as any).updateAsync).toHaveBeenCalledWith({ published: true });
});
});
describe('round-trip', () => {
it('downloads then uploads without making mutation calls', async () => {
// Use the real writer (not the auto-mock) so we can read back the
// serialized schema and feed it into the upload path.
const RealWriter = jest.requireActual('../../config/writer')
.AppleConfigWriter as typeof AppleConfigWriter;
// Pull side: collapse rows into config.
const writer = new RealWriter();
const downloadApp = makeApp();
const rows = [
makeRow(
'r1',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.ANALYTICS,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
makeRow(
'r2',
AppDataUsageCategoryId.CONTACTS,
AppDataUsagePurposeId.APP_FUNCTIONALITY,
AppDataUsageDataProtectionId.DATA_LINKED_TO_YOU
),
];
await new DataUsageTask().downloadAsync({
config: writer,
context: { app: downloadApp, dataUsages: rows, dataUsagesPublishState: null } as any,
});
// The serialized config should now contain the rows.
expect(writer.schema.privacy?.dataUsage).toEqual({
categories: [
{
category: 'CONTACTS',
purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'],
protections: ['DATA_LINKED_TO_YOU'],
},
],
});
// Push side: feed the config back through the reader and verify nothing
// is mutated since the existing rows already match.
const uploadApp = makeApp();
const publishState = makePublishState();
const reader = new AppleConfigReader(writer.schema);
await new DataUsageTask().uploadAsync({
config: reader,
context: {
app: uploadApp,
projectDir: '/p',
dataUsages: rows,
dataUsagesPublishState: publishState,
} as any,
});
expect((uploadApp as any).createAppDataUsageAsync).not.toHaveBeenCalled();
for (const row of rows) {
expect((row as any).deleteAsync).not.toHaveBeenCalled();
}
});
});
});
@@ -219,48 +219,6 @@ describe(PreviewsTask, () => {
},
});
});
it('preserves entries with placeholder paths when videoUrl is null (broken state)', async () => {
// Same regression as for screenshots: previews stuck in AWAITING_UPLOAD
// with no rendered videoUrl used to be dropped from config. Now we
// preserve the entry so the user can recover.
const writer = jest.mocked(new AppleConfigWriter());
const broken = new AppPreview(requestContext, 'PV_BROKEN', {
fileName: 'demo.mp4',
fileSize: 12345,
videoUrl: null,
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
} as any);
jest.spyOn(broken, 'getVideoUrl').mockReturnValue(null);
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [broken],
} as any)
);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets: new Map([['en-US', previewTypeMap]]),
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(mockFetch).not.toBeCalled();
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
});
});
});
describe('uploadAsync', () => {
@@ -170,95 +170,6 @@ describe(ScreenshotsTask, () => {
],
});
});
it('preserves entries with placeholder paths when imageAsset is null (broken state)', async () => {
// Regression test for screenshots stuck in AWAITING_UPLOAD with no
// rendered imageAsset. Pull used to drop these from config entirely,
// which made it impossible to recover via push (since push only acts on
// entries present in config). Now pull writes a placeholder path so the
// user can drop in a replacement file or remove the entry to delete the
// broken ASC record.
const writer = jest.mocked(new AppleConfigWriter());
const broken = new AppScreenshot(requestContext, 'SS_BROKEN', {
fileName: '01.png',
fileSize: 599307,
imageAsset: null,
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
} as any);
// getImageAssetUrl returns null when imageAsset is null.
jest.spyOn(broken, 'getImageAssetUrl').mockReturnValue(null);
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [broken],
} as any);
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
const screenshotSets = new Map([['en-US', displayTypeMap]]);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
// No fetch should have been attempted (URL was null).
expect(mockFetch).not.toBeCalled();
// Entry should still be present in config with the original filename.
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPHONE_67]: [
'store/apple/screenshot/en-US/APP_IPHONE_67/01.png',
],
});
});
it('uses index-based fallback filename when fileName is also null', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const broken = new AppScreenshot(requestContext, 'SS_BROKEN', {
fileName: null,
fileSize: 0,
imageAsset: null,
assetDeliveryState: { state: 'AWAITING_UPLOAD', errors: [], warnings: [] },
} as any);
jest.spyOn(broken, 'getImageAssetUrl').mockReturnValue(null);
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
displayTypeMap.set(
ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129,
new AppScreenshotSet(requestContext, 'SET_2', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129,
appScreenshots: [broken],
} as any)
);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets: new Map([['en-US', displayTypeMap]]),
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPAD_PRO_3GEN_129]: [
'store/apple/screenshot/en-US/APP_IPAD_PRO_3GEN_129/01.png',
],
});
});
});
describe('uploadAsync', () => {
@@ -1,522 +0,0 @@
import {
AppClip,
AppClipAppStoreReviewDetail,
AppClipDefaultExperience,
AppClipDefaultExperienceLocalization,
AppClipHeaderImage,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AppClipData = {
/** The App Clip resource for the current app, if one exists. */
appClip: AppClip | null;
/**
* The default experience attached to the *current editable* app store
* version, if one exists. App Clip default experiences are versioned per
* app store version, so this is null when the user has bumped the version
* but not yet pushed an App Clip configuration for it.
*/
appClipDefaultExperience: AppClipDefaultExperience | null;
/**
* The most recent default experience from any other version, used as a
* template when creating a new default experience for the current version
* (and as the source for `metadata:pull` when the current version has none).
*/
appClipTemplateExperience: AppClipDefaultExperience | null;
/** Existing localizations keyed by locale (e.g. `en-US`). */
appClipLocalizations: Map<string, AppClipDefaultExperienceLocalization>;
/** Existing header images keyed by locale. */
appClipHeaderImages: Map<string, AppClipHeaderImage>;
/** App Store review detail (invocation URLs), if one exists. */
appClipReviewDetail: AppClipAppStoreReviewDetail | null;
};
/**
* Task for managing App Clip metadata (default experience, localized
* subtitles + header images, App Store review invocation URLs).
*
* No-op when the app does not have an App Clip target.
*/
export class AppClipTask extends AppleTask {
public name = (): string => 'app clip';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.appClip = null;
context.appClipDefaultExperience = null;
context.appClipTemplateExperience = null;
context.appClipLocalizations = new Map();
context.appClipHeaderImages = new Map();
context.appClipReviewDetail = null;
const appClips = await context.app.getAppClipsAsync();
if (appClips.length === 0) {
return;
}
// Apps may have multiple App Clip bundles registered. Pick the first
// clip that has any default experiences attached.
let chosenClip: AppClip | null = null;
let allExperiences: AppClipDefaultExperience[] = [];
for (const clip of appClips) {
const experiences = await clip.getAppClipDefaultExperiencesAsync();
if (experiences.length > 0) {
chosenClip = clip;
allExperiences = experiences;
break;
}
}
chosenClip ??= appClips[0];
context.appClip = chosenClip;
if (allExperiences.length === 0) {
return;
}
// App Clip default experiences are versioned per app store version. Find
// the experience linked to the current editable version. Fall back to the
// first experience as a template (used as the source of truth on pull
// when the current version has no experience yet, and as a template when
// creating a new experience on push).
const currentVersionId = context.version?.id;
const currentVersionExperience = currentVersionId
? (allExperiences.find(
exp => exp.attributes.releaseWithAppStoreVersion?.id === currentVersionId
) ?? null)
: null;
context.appClipDefaultExperience = currentVersionExperience;
// The template is only used when we need to CREATE a new default
// experience for the current version on push — pick any other existing
// experience (most recent first by sort order). When the current version
// already has an experience, no template is needed.
context.appClipTemplateExperience = currentVersionExperience
? null
: (allExperiences[0] ?? null);
// Use the current version's experience for downstream data when present;
// otherwise fall back to the template so `metadata:pull` still produces a
// populated `store.config.json`.
const sourceExperience = currentVersionExperience ?? context.appClipTemplateExperience;
if (!sourceExperience) {
return;
}
const [localizations, reviewDetail] = await Promise.all([
sourceExperience.getAppClipDefaultExperienceLocalizationsAsync(),
sourceExperience.getAppClipAppStoreReviewDetailAsync(),
]);
for (const localization of localizations) {
context.appClipLocalizations.set(localization.attributes.locale, localization);
}
context.appClipReviewDetail = reviewDetail;
// Header images may be missing from the included payload depending on
// ASC's whims; fetch any that weren't pre-populated.
await Promise.all(
localizations.map(async localization => {
const included = localization.attributes.appClipHeaderImage;
if (included) {
context.appClipHeaderImages!.set(localization.attributes.locale, included);
return;
}
const headerImage = await localization.getAppClipHeaderImageAsync();
if (headerImage) {
context.appClipHeaderImages!.set(localization.attributes.locale, headerImage);
}
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
// Pull data from the current version's experience when present, otherwise
// fall back to the template experience. The localizations and review
// detail in `context` were populated from whichever was used.
const experience = context.appClipDefaultExperience ?? context.appClipTemplateExperience;
if (!experience) {
return;
}
config.setAppClipDefaultExperience({
action: experience.attributes.action ?? undefined,
// ASC does not expose `releaseWithAppStoreVersion` directly on the
// attributes — it's a relationship. We treat presence of an included
// version as `true`. When unset we leave the field undefined so the
// serializer omits it.
releaseWithAppStoreVersion:
experience.attributes.releaseWithAppStoreVersion != null ? true : undefined,
});
const reviewDetail = context.appClipReviewDetail;
if (reviewDetail?.attributes.invocationUrls?.length) {
config.setAppClipReviewDetail({
invocationUrls: reviewDetail.attributes.invocationUrls,
});
} else {
config.setAppClipReviewDetail(null);
}
for (const [locale, localization] of context.appClipLocalizations) {
const headerImage = context.appClipHeaderImages.get(locale);
let headerImagePath: string | undefined;
if (headerImage) {
const downloaded = await downloadAppClipHeaderImageAsync(
context.projectDir,
locale,
headerImage
);
if (downloaded) {
headerImagePath = downloaded;
} else {
// Image exists in ASC but isn't downloadable yet (still processing,
// or Apple's CDN hasn't caught up). Preserve the expected local path
// so subsequent pushes don't try to delete the in-progress upload.
// Filename match in syncAppClipHeaderImageAsync will skip re-upload.
const fileName = headerImage.attributes.fileName || 'header.png';
headerImagePath = path.join('store', 'apple', 'app-clip', locale, fileName);
}
}
config.setAppClipLocalizedInfo(locale, {
subtitle: localization.attributes.subtitle ?? undefined,
headerImage: headerImagePath,
});
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getAppClipDefaultExperience();
if (!desired) {
Log.log(chalk`{dim - Skipped app clip, not configured}`);
return;
}
if (!context.appClip) {
Log.warn(
chalk`{yellow Skipping app clip - no App Clip is registered for this app in App Store Connect}`
);
return;
}
if (!context.version) {
Log.warn(chalk`{yellow Skipping app clip - no editable app store version available}`);
return;
}
// App Clip default experiences are versioned per app store version. If
// the current editable version doesn't have one yet, create a new one,
// optionally cloning the most recent prior experience as a template.
let experience = context.appClipDefaultExperience;
// We always link the default experience to the current editable version,
// regardless of whether the user opted into `releaseWithAppStoreVersion`
// — every default experience must belong to a version in ASC.
const releaseWithAppStoreVersionId = context.version.id;
if (!experience) {
const appClipId = context.appClip.id;
const templateId = context.appClipTemplateExperience?.id;
experience = await logAsync(
() =>
AppClipDefaultExperience.createAsync(context.app.context, {
appClipId,
releaseWithAppStoreVersionId,
appClipDefaultExperienceTemplateId: templateId,
attributes: { action: (desired.action as any) ?? null },
}),
{
pending: templateId
? `Creating App Clip default experience for ${chalk.bold(context.version.attributes.versionString)} (cloned from previous version)...`
: `Creating App Clip default experience for ${chalk.bold(context.version.attributes.versionString)}...`,
success: `Created App Clip default experience for ${chalk.bold(context.version.attributes.versionString)}`,
failure: `Failed creating App Clip default experience for ${chalk.bold(context.version.attributes.versionString)}`,
}
);
context.appClipDefaultExperience = experience;
// Apple cloned the previous experience's localizations + review detail
// into the new experience. Re-fetch them so subsequent diff/sync logic
// operates on the new resource ids.
const [localizations, reviewDetail] = await Promise.all([
experience.getAppClipDefaultExperienceLocalizationsAsync(),
experience.getAppClipAppStoreReviewDetailAsync(),
]);
context.appClipLocalizations = new Map();
context.appClipHeaderImages = new Map();
for (const localization of localizations) {
context.appClipLocalizations.set(localization.attributes.locale, localization);
const included = localization.attributes.appClipHeaderImage;
if (included) {
context.appClipHeaderImages.set(localization.attributes.locale, included);
} else {
const headerImage = await localization.getAppClipHeaderImageAsync();
if (headerImage) {
context.appClipHeaderImages.set(localization.attributes.locale, headerImage);
}
}
}
context.appClipReviewDetail = reviewDetail;
} else {
const currentAction = experience.attributes.action ?? null;
const desiredAction = (desired.action as any) ?? null;
// Apple rejects PATCHes on `action` once the linked app store version is
// locked (in review / released). Skip the call when nothing actually
// changed to avoid spurious failures on round-trip pushes.
if (currentAction !== desiredAction) {
const existing = experience;
experience = await logAsync(
() =>
existing.updateAsync({
action: desiredAction,
releaseWithAppStoreVersionId: desired.releaseWithAppStoreVersion
? (releaseWithAppStoreVersionId ?? null)
: null,
}),
{
pending: 'Updating App Clip default experience...',
success: 'Updated App Clip default experience',
failure: 'Failed updating App Clip default experience',
}
);
context.appClipDefaultExperience = experience;
} else {
Log.log(chalk`{dim - Skipped App Clip default experience, no changes}`);
}
}
// Sync App Store review detail (invocation URLs).
const desiredReview = desired.reviewDetail;
if (desiredReview && desiredReview.invocationUrls.length > 0) {
if (context.appClipReviewDetail) {
const currentUrls = context.appClipReviewDetail.attributes.invocationUrls ?? [];
const desiredUrls = desiredReview.invocationUrls;
const unchanged =
currentUrls.length === desiredUrls.length &&
currentUrls.every((url, i) => url === desiredUrls[i]);
if (!unchanged) {
const existingReview = context.appClipReviewDetail;
context.appClipReviewDetail = await logAsync(
() =>
existingReview.updateAsync({
invocationUrls: desiredUrls,
}),
{
pending: 'Updating App Clip review invocation URLs...',
success: 'Updated App Clip review invocation URLs',
failure: 'Failed updating App Clip review invocation URLs',
}
);
}
} else {
context.appClipReviewDetail = await logAsync(
() =>
AppClipAppStoreReviewDetail.createAsync(context.app.context, {
appClipDefaultExperienceId: experience.id,
attributes: { invocationUrls: desiredReview.invocationUrls },
}),
{
pending: 'Creating App Clip review invocation URLs...',
success: 'Created App Clip review invocation URLs',
failure: 'Failed creating App Clip review invocation URLs',
}
);
}
} else if (context.appClipReviewDetail) {
await logAsync(() => context.appClipReviewDetail!.deleteAsync(), {
pending: 'Removing App Clip review invocation URLs...',
success: 'Removed App Clip review invocation URLs',
failure: 'Failed removing App Clip review invocation URLs',
});
context.appClipReviewDetail = null;
}
// Sync localizations: create/update from config, delete locales that
// exist in ASC but were removed from the config.
const desiredLocales = config.getAppClipLocales();
const desiredLocaleSet = new Set(desiredLocales);
for (const locale of desiredLocales) {
const desiredInfo = config.getAppClipLocalizedInfo(locale);
if (!desiredInfo) {
continue;
}
let localization = context.appClipLocalizations.get(locale);
if (!localization) {
localization = await logAsync(
() =>
experience.createAppClipDefaultExperienceLocalizationAsync({
locale,
subtitle: desiredInfo.subtitle ?? null,
}),
{
pending: `Creating App Clip localization for ${chalk.bold(locale)}...`,
success: `Created App Clip localization for ${chalk.bold(locale)}`,
failure: `Failed creating App Clip localization for ${chalk.bold(locale)}`,
}
);
context.appClipLocalizations.set(locale, localization);
} else if (localization.attributes.subtitle !== (desiredInfo.subtitle ?? null)) {
localization = await logAsync(
() => localization!.updateAsync({ subtitle: desiredInfo.subtitle ?? null }),
{
pending: `Updating App Clip localization for ${chalk.bold(locale)}...`,
success: `Updated App Clip localization for ${chalk.bold(locale)}`,
failure: `Failed updating App Clip localization for ${chalk.bold(locale)}`,
}
);
context.appClipLocalizations.set(locale, localization);
}
// Sync header image.
await syncAppClipHeaderImageAsync({
projectDir: context.projectDir,
localization,
existing: context.appClipHeaderImages.get(locale),
desiredPath: desiredInfo.headerImage,
onUploaded: image => context.appClipHeaderImages.set(locale, image),
onDeleted: () => context.appClipHeaderImages.delete(locale),
});
}
// Delete localizations no longer in config.
for (const [locale, localization] of context.appClipLocalizations) {
if (!desiredLocaleSet.has(locale)) {
await logAsync(() => localization.deleteAsync(), {
pending: `Deleting App Clip localization for ${chalk.bold(locale)}...`,
success: `Deleted App Clip localization for ${chalk.bold(locale)}`,
failure: `Failed deleting App Clip localization for ${chalk.bold(locale)}`,
});
context.appClipLocalizations.delete(locale);
context.appClipHeaderImages.delete(locale);
}
}
}
}
/**
* Upload, replace, or delete the header image for a single App Clip
* localization based on the desired config path.
*/
async function syncAppClipHeaderImageAsync({
projectDir,
localization,
existing,
desiredPath,
onUploaded,
onDeleted,
}: {
projectDir: string;
localization: AppClipDefaultExperienceLocalization;
existing: AppClipHeaderImage | undefined;
desiredPath: string | undefined;
onUploaded: (image: AppClipHeaderImage) => void;
onDeleted: () => void;
}): Promise<void> {
const locale = localization.attributes.locale;
if (!desiredPath) {
if (existing) {
await logAsync(() => existing.deleteAsync(), {
pending: `Deleting App Clip header image for ${chalk.bold(locale)}...`,
success: `Deleted App Clip header image for ${chalk.bold(locale)}`,
failure: `Failed deleting App Clip header image for ${chalk.bold(locale)}`,
});
onDeleted();
}
return;
}
const absolutePath = path.resolve(projectDir, desiredPath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow App Clip header image not found: ${absolutePath}}`);
return;
}
const fileName = path.basename(absolutePath);
// Skip upload if the existing image already has the same filename and is
// either fully processed or still being processed (i.e. anything that
// isn't AWAITING_UPLOAD or FAILED). Apple does not expose the original
// source bytes (only re-rendered copies), so we can't reliably compare
// file size or checksum after a round-trip pull — filename is the only
// stable identity available.
if (
existing &&
!existing.isAwaitingUpload() &&
!existing.isFailed() &&
existing.attributes.fileName === fileName
) {
return;
}
if (existing) {
await logAsync(() => existing.deleteAsync(), {
pending: `Replacing App Clip header image for ${chalk.bold(locale)}...`,
success: `Removed previous App Clip header image for ${chalk.bold(locale)}`,
failure: `Failed removing previous App Clip header image for ${chalk.bold(locale)}`,
});
onDeleted();
}
const uploaded = await logAsync(
() =>
AppClipHeaderImage.uploadAsync(localization.context, {
id: localization.id,
filePath: absolutePath,
waitForProcessing: true,
}),
{
pending: `Uploading App Clip header image ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded App Clip header image ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading App Clip header image ${chalk.bold(fileName)} (${locale})`,
}
);
onUploaded(uploaded);
}
/**
* Download an App Clip header image to the local filesystem.
* Returns the relative path to the downloaded file, or null on failure.
*/
async function downloadAppClipHeaderImageAsync(
projectDir: string,
locale: string,
headerImage: AppClipHeaderImage
): Promise<string | null> {
const imageUrl = headerImage.getImageAssetUrl({ type: 'png' });
if (!imageUrl) {
Log.warn(chalk`{yellow Could not get download URL for App Clip header image (${locale})}`);
return null;
}
const targetDir = path.join(projectDir, 'store', 'apple', 'app-clip', locale);
await fs.promises.mkdir(targetDir, { recursive: true });
const fileName = headerImage.attributes.fileName || 'header.png';
const outputPath = path.join(targetDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded App Clip header image: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(
chalk`{yellow Failed to download App Clip header image (${locale}): ${error.message}}`
);
return null;
}
}
@@ -1,322 +0,0 @@
import {
AppDataUsage,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
AppDataUsagesPublishState,
} from '@expo/apple-utils';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import {
AppleDataUsage,
AppleDataUsageCategoryEntry,
AppleDataUsageCategoryId as AppleDataUsageCategoryIdString,
AppleDataUsageDataProtectionId as AppleDataUsageDataProtectionIdString,
AppleDataUsagePurposeId as AppleDataUsagePurposeIdString,
} from '../types';
export type DataUsageData = {
/**
* Existing AppDataUsage rows on App Store Connect for the current app.
* One row per (category, purpose, dataProtection) tuple. Populated during
* `prepareAsync` and refreshed after `uploadAsync` makes mutations.
*/
dataUsages: AppDataUsage[];
/**
* The publish-state envelope for the data usage section. Apple gates the
* Privacy Nutrition Labels behind a separate `publish` flag — until this is
* set to `published: true`, the values authored above won't take effect on
* the storefront. The model is keyed by the App id (the IRIS endpoint is
* non-standard and there's only ever one).
*/
dataUsagesPublishState: AppDataUsagesPublishState | null;
};
/**
* Sync App Privacy Nutrition Labels (data usage) to/from App Store Connect.
*
* Apple has required every new app submission since 2021 to declare what data
* the app collects, the purposes it's used for, and how it's linked to the
* user. Each declaration is a tuple of (category, purpose, protection) — for
* example "CONTACTS used for ANALYTICS, linked to the user". The local config
* groups these by category for ergonomics; on push we expand each row into the
* cartesian product of (category × purpose × protection) and reconcile against
* the existing rows in App Store Connect.
*
* The task is declarative: any rows in ASC that aren't present in the local
* config are deleted, any rows in the local config that aren't in ASC are
* created, and existing rows are left untouched. After a successful push the
* publish-state envelope is flipped to `published: true` so the changes
* actually appear on the App Store.
*/
export class DataUsageTask extends AppleTask {
public name = (): string => 'data usage (privacy nutrition labels)';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.dataUsages = [];
context.dataUsagesPublishState = null;
try {
context.dataUsages = await context.app.getAppDataUsagesAsync();
} catch (error: any) {
// Apps that have never opened the App Privacy section yet may return
// 404 / NOT_FOUND from the iris endpoint. That's fine — treat it as
// "no rows" and continue. Anything else is unexpected and surfaces.
if (!isNotFoundError(error)) {
throw error;
}
}
try {
const states = await context.app.getAppDataUsagesPublishStateAsync();
context.dataUsagesPublishState = states[0] ?? null;
} catch (error: any) {
if (!isNotFoundError(error)) {
throw error;
}
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
const dataUsage = collapseDataUsageRows(context.dataUsages ?? []);
if (!dataUsage) {
// Don't write anything when the app has no rows declared at all — leave
// `privacy.dataUsage` absent in the schema rather than emitting an empty
// object that round-trips into a no-op publish.
config.setDataUsage(null);
return;
}
config.setDataUsage(dataUsage);
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const desired = config.getDataUsage();
if (!desired) {
Log.log(chalk`{dim - Skipped data usage, not configured}`);
return;
}
const desiredRows = expandDataUsageRows(desired);
const existingRows = context.dataUsages ?? [];
// Build a stable key for each (category, purpose, protection) tuple so we
// can diff existing vs desired rows. Categories and protections may be
// null on the existing rows when the relationship isn't included.
const existingByKey = new Map<string, AppDataUsage>();
for (const row of existingRows) {
const key = rowKey(
(row.attributes.category?.id as AppDataUsageCategoryId | undefined) ?? null,
(row.attributes.purpose?.id as AppDataUsagePurposeId | undefined) ?? null,
(row.attributes.dataProtection?.id as AppDataUsageDataProtectionId | undefined) ?? null
);
existingByKey.set(key, row);
}
const desiredByKey = new Map<
string,
{
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}
>();
for (const row of desiredRows) {
desiredByKey.set(rowKey(row.category, row.purpose, row.protection), row);
}
// Delete rows that exist on ASC but aren't in the local config.
let deleted = 0;
for (const [key, row] of existingByKey) {
if (!desiredByKey.has(key)) {
await row.deleteAsync();
deleted += 1;
}
}
// Create rows that are in the local config but missing from ASC.
let created = 0;
const createdRows: AppDataUsage[] = [];
for (const [key, row] of desiredByKey) {
if (existingByKey.has(key)) {
continue;
}
const newRow = await context.app.createAppDataUsageAsync({
appDataUsageCategory: row.category ?? undefined,
appDataUsageProtection: row.protection ?? undefined,
appDataUsagePurpose: row.purpose ?? undefined,
});
createdRows.push(newRow);
created += 1;
}
if (created === 0 && deleted === 0) {
Log.log(chalk`{dim - Skipped data usage, no changes}`);
} else {
Log.log(
chalk`{dim - Synced data usage: ${String(created)} created, ${String(deleted)} deleted}`
);
// Refresh in-memory state so subsequent invocations see the new rows.
context.dataUsages = [
...existingRows.filter(row => {
const key = rowKey(
(row.attributes.category?.id as AppDataUsageCategoryId | undefined) ?? null,
(row.attributes.purpose?.id as AppDataUsagePurposeId | undefined) ?? null,
(row.attributes.dataProtection?.id as AppDataUsageDataProtectionId | undefined) ?? null
);
return desiredByKey.has(key);
}),
...createdRows,
];
}
// Apple gates the Privacy Nutrition Labels behind a separate publish
// state. Without flipping `published: true` after a write, the rows above
// won't take effect on the storefront. The publish state model is keyed
// by the app id and there's only ever one — refetch it after the writes
// in case it's drifted.
const publishState =
context.dataUsagesPublishState ?? (await context.app.getAppDataUsagesPublishStateAsync())[0];
if (!publishState) {
Log.warn(
chalk`{yellow Could not load data usage publish state — changes may not appear on the App Store. Publish manually from App Store Connect.}`
);
return;
}
context.dataUsagesPublishState = await logAsync(
() => publishState.updateAsync({ published: true }),
{
pending: 'Publishing data usage (privacy nutrition labels)...',
success: 'Published data usage (privacy nutrition labels)',
failure: 'Failed publishing data usage (privacy nutrition labels)',
}
);
}
}
/**
* Build a deterministic key for a (category, purpose, protection) tuple.
* Nulls are folded in so that rows with missing relationships still diff
* correctly against the desired state.
*/
function rowKey(
category: string | null | undefined,
purpose: string | null | undefined,
protection: string | null | undefined
): string {
return `${category ?? ''}::${purpose ?? ''}::${protection ?? ''}`;
}
/**
* Expand a config-shaped {@link AppleDataUsage} into the flat list of
* (category, purpose, protection) tuples that App Store Connect actually
* stores. When `dataNotCollected` is true we emit a single sentinel row that
* declares the app collects no data; this matches Apple's "Data Not Collected"
* toggle, which is just a special row with no category/purpose/protection.
*/
function expandDataUsageRows(dataUsage: AppleDataUsage): {
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}[] {
if (dataUsage.dataNotCollected) {
return [{ category: null, purpose: null, protection: null }];
}
const rows: {
category: AppDataUsageCategoryId | null;
purpose: AppDataUsagePurposeId | null;
protection: AppDataUsageDataProtectionId | null;
}[] = [];
for (const entry of dataUsage.categories ?? []) {
const purposes = entry.purposes && entry.purposes.length > 0 ? entry.purposes : [null];
const protections =
entry.protections && entry.protections.length > 0 ? entry.protections : [null];
for (const purpose of purposes) {
for (const protection of protections) {
rows.push({
category: entry.category as AppDataUsageCategoryId,
purpose: (purpose as AppDataUsagePurposeId | null) ?? null,
protection: (protection as AppDataUsageDataProtectionId | null) ?? null,
});
}
}
}
return rows;
}
/**
* Collapse the flat list of {@link AppDataUsage} rows from App Store Connect
* back into the grouped {@link AppleDataUsage} schema we serialize to the
* config. Rows are grouped by category; purposes and protections within a
* category are deduped. Returns `null` when there are no rows.
*/
function collapseDataUsageRows(rows: AppDataUsage[]): AppleDataUsage | null {
if (rows.length === 0) {
return null;
}
const byCategory = new Map<string, { purposes: Set<string>; protections: Set<string> }>();
let dataNotCollected = false;
for (const row of rows) {
const categoryId = row.attributes.category?.id;
const purposeId = row.attributes.purpose?.id;
const protectionId = row.attributes.dataProtection?.id;
// A row with no category/purpose/protection at all is the "Data Not
// Collected" sentinel. We don't surface its DATA_NOT_COLLECTED protection
// either — when present we just flip the top-level toggle.
if (!categoryId && !purposeId && protectionId === 'DATA_NOT_COLLECTED') {
dataNotCollected = true;
continue;
}
if (!categoryId) {
continue;
}
let bucket = byCategory.get(categoryId);
if (!bucket) {
bucket = { purposes: new Set(), protections: new Set() };
byCategory.set(categoryId, bucket);
}
if (purposeId) {
bucket.purposes.add(purposeId);
}
if (protectionId) {
bucket.protections.add(protectionId);
}
}
if (dataNotCollected && byCategory.size === 0) {
return { dataNotCollected: true };
}
if (byCategory.size === 0) {
return null;
}
const categories: AppleDataUsageCategoryEntry[] = [];
for (const [category, { purposes, protections }] of byCategory) {
categories.push({
category: category as AppleDataUsageCategoryIdString,
purposes:
purposes.size > 0 ? (Array.from(purposes) as AppleDataUsagePurposeIdString[]) : undefined,
protections:
protections.size > 0
? (Array.from(protections) as AppleDataUsageDataProtectionIdString[])
: undefined,
});
}
// Stable category ordering keeps the generated config diff-friendly.
categories.sort((a, b) => a.category.localeCompare(b.category));
return { categories };
}
function isNotFoundError(error: any): boolean {
if (!error) {
return false;
}
const status = error?.response?.status ?? error?.status;
if (status === 404) {
return true;
}
const message = String(error?.message ?? '');
return /not.?found|404/i.test(message);
}
@@ -1,9 +1,7 @@
import { AgeRatingTask } from './age-rating';
import { AppClipTask } from './app-clip';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { DataUsageTask } from './data-usage';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
@@ -23,7 +21,5 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new AppReviewDetailTask(),
new ScreenshotsTask(),
new PreviewsTask(),
new AppClipTask(),
new DataUsageTask(),
];
}
@@ -89,7 +89,7 @@ export class PreviewsTask extends AppleTask {
// For now, we only handle the first preview per set (App Store allows up to 3)
// We can extend this later to support multiple previews
const preview = previewModels[0];
const downloaded = await downloadPreviewAsync(
const relativePath = await downloadPreviewAsync(
context.projectDir,
localeCode,
previewType,
@@ -97,22 +97,16 @@ export class PreviewsTask extends AppleTask {
0
);
// When the download succeeds, write the real path. When it fails
// (e.g. the preview is in a broken AWAITING_UPLOAD state with no
// rendered videoUrl), preserve the entry in config so the user can
// either drop in a replacement file or remove the entry to delete
// the broken ASC record.
const fileName = preview.attributes.fileName || 'preview.mp4';
const relativePath =
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = relativePath;
if (relativePath) {
// Include preview frame time code if available
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = relativePath;
}
}
}
@@ -73,32 +73,20 @@ export class ScreenshotsTask extends AppleTask {
continue;
}
// Download screenshots and save to local filesystem. When a screenshot
// is in a broken state (AWAITING_UPLOAD with no rendered imageAsset)
// the download will fail, but we still preserve the entry pointing at
// its expected local path so users can either drop in a replacement
// file or remove the entry to delete the broken ASC record.
// Download screenshots and save to local filesystem
const paths: string[] = [];
for (let i = 0; i < screenshotModels.length; i++) {
const screenshot = screenshotModels[i];
const downloaded = await downloadScreenshotAsync(
const relativePath = await downloadScreenshotAsync(
context.projectDir,
localeCode,
displayType,
screenshot,
i
);
if (downloaded) {
paths.push(downloaded);
continue;
if (relativePath) {
paths.push(relativePath);
}
// Fall back to a placeholder path so the entry isn't lost from
// config. Push will detect that the existing screenshot isn't
// complete and either re-upload (if a local file exists at this
// path) or warn and skip (if it doesn't).
const fileName =
screenshot.attributes.fileName || `${String(i + 1).padStart(2, '0')}.png`;
paths.push(path.join('store', 'apple', 'screenshot', localeCode, displayType, fileName));
}
if (paths.length > 0) {
@@ -1,9 +1,5 @@
import type {
AgeRatingDeclarationProps,
AppClipAction,
AppDataUsageCategoryId,
AppDataUsageDataProtectionId,
AppDataUsagePurposeId,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
@@ -55,96 +51,6 @@ export interface AppleMetadata {
/** @deprecated Use screenshots/previews in AppleInfo instead */
preview?: Record<string, string[]>;
review?: AppleReview;
/** App Clip metadata. Only applies to apps that ship an App Clip target. */
appClip?: AppleAppClip;
/** Privacy metadata, including App Privacy Nutrition Labels (data usage). */
privacy?: ApplePrivacy;
}
/** Top-level privacy block. Currently only contains data usage (Privacy Nutrition Labels). */
export interface ApplePrivacy {
/**
* Privacy Nutrition Labels (App Privacy details). Required for new app
* submissions since 2021. When set, the local config is treated as the
* source of truth: existing data usage entries on App Store Connect are
* replaced to match, then published.
*/
dataUsage?: AppleDataUsage;
}
/** Data usage category enum (e.g. CONTACTS, PRECISE_LOCATION). */
export type AppleDataUsageCategoryId = `${AppDataUsageCategoryId}`;
/** Data usage purpose enum (e.g. ANALYTICS, APP_FUNCTIONALITY). */
export type AppleDataUsagePurposeId = `${AppDataUsagePurposeId}`;
/** Data protection / linkage enum (e.g. DATA_LINKED_TO_YOU). */
export type AppleDataUsageDataProtectionId = `${AppDataUsageDataProtectionId}`;
/**
* Privacy Nutrition Labels — declarative declaration of data collected by the
* app. When `dataNotCollected` is true, no data is collected and `categories`
* must be omitted (or empty).
*/
export interface AppleDataUsage {
/**
* Whether the app collects no data at all. When set, `categories` must be
* empty. Mirrors Apple's "Data Not Collected" toggle in App Store Connect.
*/
dataNotCollected?: boolean;
/** Per-category declarations. Order is not significant. */
categories?: AppleDataUsageCategoryEntry[];
}
/**
* One declaration row in the App Privacy details. Each row pairs a data
* category with the purposes it's used for and the protection / linkage
* applied to it.
*/
export interface AppleDataUsageCategoryEntry {
/** Apple data category, e.g. `CONTACTS`, `PRECISE_LOCATION`. */
category: AppleDataUsageCategoryId;
/**
* Purposes the category is used for, e.g. `ANALYTICS`, `APP_FUNCTIONALITY`.
* At least one entry is required when the category is collected.
*/
purposes?: AppleDataUsagePurposeId[];
/**
* Data protection / linkage classification, e.g. `DATA_LINKED_TO_YOU`,
* `DATA_USED_TO_TRACK_YOU`. Multiple values are allowed: a single category
* can be both linked to the user and used for tracking.
*/
protections?: AppleDataUsageDataProtectionId[];
}
/** App Clip action enum values from App Store Connect API */
export type AppleAppClipAction = `${AppClipAction}`;
export interface AppleAppClip {
/** The default experience for this App Clip. There is exactly one per app. */
defaultExperience?: AppleAppClipDefaultExperience;
}
export interface AppleAppClipDefaultExperience {
/** Action button shown in the App Clip card. Defaults to OPEN if unset. */
action?: AppleAppClipAction;
/** Whether to release this default experience alongside the next App Store version. */
releaseWithAppStoreVersion?: boolean;
/** App Store review invocation URLs (used by App Review to launch the clip). */
reviewDetail?: AppleAppClipReviewDetail;
/** Per-locale subtitle and header image. */
info?: Record<AppleLocale, AppleAppClipLocalizedInfo>;
}
export interface AppleAppClipReviewDetail {
invocationUrls: string[];
}
export interface AppleAppClipLocalizedInfo {
/** Subtitle shown in the App Clip card. Apple limits this to 43 characters. */
subtitle?: string;
/** Relative path (from project root) to the App Clip header image PNG. */
headerImage?: string;
}
// The omited properties are deprecated
@@ -85,16 +85,6 @@ export default class SessionManager {
}
public async logoutAsync(): Promise<void> {
const sessionSecret = this.getSessionSecret();
if (sessionSecret) {
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
try {
await apiV2Client.postAsync('auth/logout', { body: {} });
} catch (e) {
// Best-effort: clear the local session even if the server request fails
Log.debug('Failed to invalidate session secret on server:', e);
}
}
this.currentActor = undefined;
await this.setSessionAsync(undefined);
}
@@ -219,38 +219,6 @@ describe(SessionManager, () => {
await sessionManager.logoutAsync();
expect(sessionManager['getSessionSecret']()).toBe(null);
});
it('calls the server logout endpoint when session secret exists', async () => {
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
sessionSecret: 'SESSION_SECRET',
id: 'USER_ID',
username: 'USERNAME',
});
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
const sessionManager = new SessionManager(analytics);
await sessionManager['loginAsync']({ username: 'USERNAME', password: 'PASSWORD' });
await sessionManager.logoutAsync();
expect(apiV2PostSpy).toHaveBeenCalledWith('auth/logout', { body: {} });
});
it('clears the local session even if the server logout call fails', async () => {
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
sessionSecret: 'SESSION_SECRET',
id: 'USER_ID',
username: 'USERNAME',
});
jest
.spyOn(ApiV2Client.prototype, 'postAsync')
.mockRejectedValueOnce(new Error('Network error'));
const sessionManager = new SessionManager(analytics);
await sessionManager['loginAsync']({ username: 'USERNAME', password: 'PASSWORD' });
await sessionManager.logoutAsync();
expect(sessionManager['getSessionSecret']()).toBe(null);
});
});
describe('showLoginPromptAsync', () => {
@@ -0,0 +1,66 @@
function workerCapabilitiesB64(payload: Record<string, unknown>): string {
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
}
function loadConfigFresh(): typeof import('../config').default {
jest.resetModules();
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../config').default;
}
describe('config capabilities (WORKER_CAPABILITIES_BASE64)', () => {
beforeEach(() => {
delete process.env.WORKER_CAPABILITIES_BASE64;
jest.resetModules();
});
afterEach(() => {
delete process.env.WORKER_CAPABILITIES_BASE64;
jest.resetModules();
});
it('defaults nestedVirtualizationEnabled to false when the env var is unset', () => {
const config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(false);
});
it('treats empty or whitespace WORKER_CAPABILITIES_BASE64 as nested virt disabled', () => {
process.env.WORKER_CAPABILITIES_BASE64 = '';
let config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(false);
process.env.WORKER_CAPABILITIES_BASE64 = ' \t ';
config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(false);
});
it('reads nested_virtualization_enabled true from base64 JSON', () => {
process.env.WORKER_CAPABILITIES_BASE64 = workerCapabilitiesB64({
nested_virtualization_enabled: true,
});
const config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(true);
});
it('reads nested_virtualization_enabled false from base64 JSON', () => {
process.env.WORKER_CAPABILITIES_BASE64 = workerCapabilitiesB64({
nested_virtualization_enabled: false,
});
const config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(false);
});
it('treats non-boolean nested_virtualization_enabled as false', () => {
process.env.WORKER_CAPABILITIES_BASE64 = workerCapabilitiesB64({
nested_virtualization_enabled: '1',
});
const config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(false);
});
it('treats missing nested_virtualization_enabled as false', () => {
process.env.WORKER_CAPABILITIES_BASE64 = workerCapabilitiesB64({});
const config = loadConfigFresh();
expect(config.capabilities.nestedVirtualizationEnabled).toBe(false);
});
});
+66
View File
@@ -5,9 +5,11 @@ import { getBuildEnv } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalNestedVirt = config.capabilities.nestedVirtualizationEnabled;
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
config.capabilities.nestedVirtualizationEnabled = originalNestedVirt;
});
it('passes the CLI sentry DSN to worker child processes', () => {
@@ -57,4 +59,68 @@ describe(getBuildEnv.name, () => {
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('sets EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED from worker capabilities when known', () => {
config.capabilities.nestedVirtualizationEnabled = true;
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: { env: {} },
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED).toBe('1');
});
it('sets EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED to 0 when nested virt is disabled', () => {
config.capabilities.nestedVirtualizationEnabled = false;
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: { env: {} },
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED).toBe('0');
});
it('defaults EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED to 0 when capability defaults to false', () => {
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: { env: {} },
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED).toBe('0');
});
});
+27
View File
@@ -21,6 +21,27 @@ function createBase64EnvTransformer<TFieldName extends string>(
};
}
/** JSON inside `WORKER_CAPABILITIES_BASE64` (see workflow-orchestration `worker_capabilities.go`). */
type WorkerCapabilitiesJson = {
nested_virtualization_enabled?: boolean;
};
function createWorkerCapabilitiesEnvTransformer<TFieldName extends keyof WorkerCapabilitiesJson>(
field: TFieldName
): (valueRaw: string) => boolean {
return (valueRaw: string) => {
// Set-but-empty behaves like unset (`defaultValue: false` is not applied when the key exists).
if (!valueRaw.trim()) {
return false;
}
const json = JSON.parse(
Buffer.from(valueRaw, 'base64').toString('utf8')
) as WorkerCapabilitiesJson;
const value = json?.[field];
return typeof value === 'boolean' ? value : false;
};
}
export default {
env: env<Environment>('ENVIRONMENT', { oneOf: Environment }),
port: env('PORT', { defaultValue: 3005, transform: Number }),
@@ -98,6 +119,12 @@ export default {
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
defaultValue: null,
}),
capabilities: {
nestedVirtualizationEnabled: env<boolean>('WORKER_CAPABILITIES_BASE64', {
transform: createWorkerCapabilitiesEnvTransformer('nested_virtualization_enabled'),
defaultValue: false,
}),
},
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('runMetricsServer'),
defaultValue: null,
+6
View File
@@ -48,6 +48,12 @@ export function getBuildEnv({
setEnv(env, 'EAS_BUILD_WORKINGDIR', path.join(config.workingdir, 'build'));
setEnv(env, 'EAS_BUILD_PROJECT_ID', projectId);
setEnv(
env,
'EAS_BUILD_NESTED_VIRTUALIZATION_ENABLED',
config.capabilities.nestedVirtualizationEnabled ? '1' : '0'
);
const runnerPlatform =
job.platform ?? (config.resourceClass && ResourceClassToPlatform[config.resourceClass]);
if (runnerPlatform === Platform.IOS) {
Generated
+5 -5
View File
@@ -2847,12 +2847,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/apple-utils@npm:2.1.19":
version: 2.1.19
resolution: "@expo/apple-utils@npm:2.1.19"
"@expo/apple-utils@npm:2.1.16":
version: 2.1.16
resolution: "@expo/apple-utils@npm:2.1.16"
bin:
apple-utils: bin.js
checksum: 10c0/42eb1038bd0906d53b37d830499c7a8ee7ec9f9b9ef4078ff898ca84cb5af1e6ac39d3a392818825151aa2ea237a44698f539b973936733350f314efff020858
checksum: 10c0/9d3915f3f25c32e1f1ab323676cabccc2998046be6f30deebc68a0706836bd50bc5145662fa13441e0283f82c56e50f55002d50dcc43aa78e9fa7327d1676a54
languageName: node
linkType: hard
@@ -10998,7 +10998,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli@workspace:packages/eas-cli"
dependencies:
"@expo/apple-utils": "npm:2.1.19"
"@expo/apple-utils": "npm:2.1.16"
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"