mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d93c6ed94 | ||
|
|
8c170af829 | ||
|
|
ad4c5299e3 | ||
|
|
dee07ae0a2 | ||
|
|
4ad12340f2 | ||
|
|
c6d4faefe7 | ||
|
|
2f8712a0f3 | ||
|
|
a439721054 | ||
|
|
2e2dddb59a | ||
|
|
b7df3ff4f8 | ||
|
|
8a74758d56 | ||
|
|
26fdf8fb75 | ||
|
|
9328063547 | ||
|
|
fc66b8251e | ||
|
|
394576368a | ||
|
|
f80e34cffe | ||
|
|
59ad2c2ca0 |
No files matched your search
+20
-2
@@ -8,18 +8,36 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
|
||||
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -35,19 +35,19 @@
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.4.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -19,12 +19,5 @@ export { PackageManager } from './utils/packageManager';
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
export {
|
||||
maybeStartPreparingPrecompiledModules,
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
shouldUsePrecompiledDependencies,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} from './utils/precompiledModules';
|
||||
|
||||
export * from './generic';
|
||||
@@ -165,6 +165,52 @@ describe(configureXcodeProject, () => {
|
||||
'Info.plist application target'
|
||||
);
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
const ctx = {
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
logger: { info: jest.fn() },
|
||||
job: {},
|
||||
};
|
||||
await configureXcodeProject(ctx as any, options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials and versions for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import {
|
||||
startPreparingPrecompiledDependencies,
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
} from '../../utils/precompiledModules';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installPods.name, () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
let mkdtempCallCount = 0;
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => {
|
||||
mkdtempCallCount += 1;
|
||||
return mkdtempCallCount === 1
|
||||
? ('/tmp/precompiled-modules-archive' as any)
|
||||
: ('/tmp/precompiled-modules-staging' as any);
|
||||
});
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for precompiled dependencies before running pod install', async () => {
|
||||
let resolvePreparation: (() => void) | undefined;
|
||||
const preparationPromise = new Promise<void>(resolve => {
|
||||
resolvePreparation = resolve;
|
||||
});
|
||||
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingPrecompiledDependencies(ctx, [
|
||||
'https://example.com/precompiled-modules-0.zip',
|
||||
'https://example.com/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const installPodsPromise = installPods(ctx, {});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
|
||||
resolvePreparation?.();
|
||||
await installPodsPromise;
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('continues with pod install when precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
await expect(installPods(ctx, {})).resolves.toEqual({
|
||||
spawnPromise: expect.any(Promise),
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
async function configureXcodeProject(
|
||||
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -137,6 +138,10 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
@@ -3,21 +3,11 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
try {
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{ err },
|
||||
'Precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
|
||||
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
|
||||
@@ -17,7 +17,6 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
@@ -81,8 +80,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
|
||||
import { createInstallPodsBuildFunction } from '../installPods';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/precompiledModules', () => ({
|
||||
...jest.requireActual('../../../utils/precompiledModules'),
|
||||
waitForPrecompiledModulesPreparationAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe(createInstallPodsBuildFunction, () => {
|
||||
it('waits for precompiled dependencies before running pod install', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('continues with pod install when precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest
|
||||
.mocked(waitForPrecompiledModulesPreparationAsync)
|
||||
.mockRejectedValue(new Error('precompiled dependencies failed'));
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawnAsync = jest.mocked(spawnAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
|
||||
|
||||
describe(createInternalEasMaestroTestFunction, () => {
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
|
||||
mockedSpawnAsync.mockResolvedValue(undefined as any);
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: null });
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
|
||||
try {
|
||||
return await fn(attemptCount);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
});
|
||||
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
|
||||
]);
|
||||
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
|
||||
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
|
||||
{ serialId: 'emulator-5554', state: 'device' } as any,
|
||||
]);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue({
|
||||
serialId: 'emulator-5556',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
|
||||
});
|
||||
|
||||
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
};
|
||||
const fn = createInternalEasMaestroTestFunction(ctx as any);
|
||||
return fn.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
flow_paths: JSON.stringify(['.maestro']),
|
||||
...overrides?.callInputs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('sets junit_report_directory output when output_format is junit', async () => {
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toMatch(/maestro-reports-/);
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory output when output_format is not junit', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory when function throws before reaching output code', async () => {
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
|
||||
try {
|
||||
await step.executeAsync();
|
||||
} catch {
|
||||
// Expected - no booted device found
|
||||
}
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries Android clone startup with increasing timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-2',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
deviceName: 'eas-simulator-0-0',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('generates per-attempt report filenames when retrying', async () => {
|
||||
// Mock sequence:
|
||||
// 1. xcrun simctl shutdown → succeeds (beforeEach default is fine)
|
||||
// 2. maestro test attempt 0 → fails (triggers retry)
|
||||
// 3. maestro test attempt 1 → succeeds (beforeEach default)
|
||||
mockedSpawnAsync
|
||||
.mockResolvedValueOnce(undefined as any) // xcrun simctl shutdown
|
||||
.mockRejectedValueOnce(new Error('Maestro test failed')); // maestro attempt 0
|
||||
// Attempt 1 falls through to beforeEach's mockResolvedValue default
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit', retries: 2 },
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Filter to just the maestro test command calls (skip xcrun simctl shutdown)
|
||||
const maestroCalls = mockedSpawnAsync.mock.calls.filter(([cmd]) => cmd === 'maestro');
|
||||
expect(maestroCalls).toHaveLength(2);
|
||||
|
||||
// Extract --output paths from maestro calls
|
||||
const outputArgs = maestroCalls.map(([, args]) => args[args.indexOf('--output') + 1]);
|
||||
|
||||
expect(outputArgs[0]).toContain('attempt-0');
|
||||
expect(outputArgs[1]).toContain('attempt-1');
|
||||
// Filenames must be unique (not overwriting)
|
||||
expect(outputArgs[0]).not.toBe(outputArgs[1]);
|
||||
});
|
||||
|
||||
it('fails Android flow when clone startup exhausts all attempts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-3',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-3',
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
|
||||
|
||||
export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -10,14 +8,6 @@ export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
name: 'Install Pods',
|
||||
__metricsId: 'eas/install_pods',
|
||||
fn: async (stepsCtx, { env }) => {
|
||||
try {
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
stepsCtx.logger.warn(
|
||||
{ err },
|
||||
'Precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
stepsCtx.logger.info('Installing pods');
|
||||
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag =
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
import { GenericArtifactType } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { Result, asyncResult, result } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidDeviceSerialId,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { PlatformToProperNounMap } from '../../utils/strings';
|
||||
|
||||
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: '__maestro_test',
|
||||
__metricsId: 'eas/__maestro_test',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'platform',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
id: 'flow_paths',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'retries',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'shards',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
id: 'record_screen',
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'test_reports_artifact_id',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'junit_report_directory',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
|
||||
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
|
||||
const {
|
||||
platform,
|
||||
flow_paths,
|
||||
retries,
|
||||
include_tags,
|
||||
exclude_tags,
|
||||
shards,
|
||||
output_format,
|
||||
record_screen,
|
||||
} = z
|
||||
.object({
|
||||
platform: z.enum(['ios', 'android']),
|
||||
flow_paths: z.array(z.string()),
|
||||
retries: z.number().default(1),
|
||||
include_tags: z.string().optional(),
|
||||
exclude_tags: z.string().optional(),
|
||||
shards: z.number().default(1),
|
||||
output_format: z.string().optional(),
|
||||
record_screen: z.boolean().default(false),
|
||||
})
|
||||
.parse(
|
||||
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
|
||||
);
|
||||
|
||||
const flowPathsToExecute: string[] = [];
|
||||
for (const flowPath of flow_paths) {
|
||||
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
flowPath,
|
||||
logger: stepCtx.logger,
|
||||
includeTags: include_tags ? include_tags.split(',') : undefined,
|
||||
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
|
||||
});
|
||||
if (flowPaths.length === 0) {
|
||||
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
|
||||
continue;
|
||||
}
|
||||
stepCtx.logger.info(
|
||||
`Marking for execution:\n- ${flowPaths
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
stepCtx.logger.info('');
|
||||
flowPathsToExecute.push(...flowPaths);
|
||||
}
|
||||
|
||||
// TODO: Add support for shards. (Shouldn't be too difficult.)
|
||||
if (shards > 1) {
|
||||
stepCtx.logger.warn(
|
||||
'Sharding support has been temporarily disabled. Running tests on a single shard.'
|
||||
);
|
||||
}
|
||||
|
||||
// eas/__maestro_test does not start devices, it expects a single device to be already running
|
||||
// and configured with the app. Here we find the booted device and stop it.
|
||||
|
||||
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (bootedDevices.length === 0) {
|
||||
throw new Error('No booted iOS Simulator found.');
|
||||
} else if (bootedDevices.length > 1) {
|
||||
throw new Error('Multiple booted iOS Simulators found.');
|
||||
}
|
||||
|
||||
const device = bootedDevices[0];
|
||||
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Simulator for tests...`);
|
||||
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
|
||||
logger: stepCtx.logger,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
sourceDeviceIdentifier = device.name;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
if (connectedDevices.length === 0) {
|
||||
throw new Error('No booted Android Emulator found.');
|
||||
} else if (connectedDevices.length > 1) {
|
||||
throw new Error('Multiple booted Android Emulators found.');
|
||||
}
|
||||
|
||||
const { serialId } = connectedDevices[0];
|
||||
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
mode: PipeMode.COMBINED,
|
||||
env,
|
||||
});
|
||||
const avdName = adbEmuAvdNameResult.stdout
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')[0] as AndroidVirtualDeviceName;
|
||||
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Emulator for tests...`);
|
||||
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
|
||||
await setTimeout(1000);
|
||||
|
||||
sourceDeviceIdentifier = avdName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// During tests we generate reports and device logs. We store them in temporary directories
|
||||
// and upload them once all tests are done. When a test is retried, new reports overwrite
|
||||
// the old ones. The files are named "flow-${index}" for easier identification.
|
||||
const maestroReportsDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-reports-')
|
||||
);
|
||||
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
|
||||
|
||||
const failedFlows: string[] = [];
|
||||
|
||||
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
|
||||
// Generate unique report path per attempt (not overwritten on retry)
|
||||
const outputPath = path.join(
|
||||
maestroReportsDir,
|
||||
[
|
||||
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
|
||||
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('.')
|
||||
);
|
||||
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
|
||||
| IosSimulatorName
|
||||
| AndroidVirtualDeviceName;
|
||||
|
||||
// If the test passes, but the recording fails, we don't want to make the test fail,
|
||||
// so we return two separate results.
|
||||
const {
|
||||
fnResult: { fnResult, recordingResult },
|
||||
logsResult,
|
||||
} = await withCleanDeviceAsync({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async ({ deviceIdentifier }) => {
|
||||
return await maybeWithScreenRecordingAsync({
|
||||
shouldRecord: record_screen,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async () => {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
const [command, ...args] = getMaestroTestCommand({
|
||||
flow_path: flowPath,
|
||||
output_format,
|
||||
output_path: outputPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnAsync(command, args, {
|
||||
logger: stepCtx.logger,
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} finally {
|
||||
stepCtx.logger.info('');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Move device logs to the device logs directory.
|
||||
if (logsResult?.ok) {
|
||||
try {
|
||||
const extension = path.extname(logsResult.value.outputPath);
|
||||
const destinationPath = path.join(
|
||||
deviceLogsDir,
|
||||
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
|
||||
);
|
||||
|
||||
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
|
||||
}
|
||||
} else if (logsResult?.reason) {
|
||||
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
|
||||
}
|
||||
|
||||
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
|
||||
if (isLastAttempt && recordingResult.value) {
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
|
||||
name: `Screen Recording (${flowIndex}-${path.basename(
|
||||
flowPath,
|
||||
path.extname(flowPath)
|
||||
)})`,
|
||||
paths: [recordingResult.value],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
|
||||
}
|
||||
}
|
||||
|
||||
if (fnResult.ok) {
|
||||
stepCtx.logger.info(`Test passed.`);
|
||||
// Break out of the retry loop.
|
||||
break;
|
||||
}
|
||||
|
||||
if (attemptCount < retries - 1) {
|
||||
stepCtx.logger.info(`Retrying test...`);
|
||||
stepCtx.logger.info('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
|
||||
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
|
||||
failedFlows.push(flowPath);
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// When all tests are done, we upload the reports and device logs.
|
||||
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
|
||||
if (generatedMaestroReports.length === 0) {
|
||||
stepCtx.logger.warn('No reports were generated.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading reports...`);
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
|
||||
paths: [maestroReportsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
if (artifactId) {
|
||||
outputs.test_reports_artifact_id.set(artifactId);
|
||||
}
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload reports.');
|
||||
}
|
||||
}
|
||||
|
||||
if (output_format === 'junit') {
|
||||
outputs.junit_report_directory.set(maestroReportsDir);
|
||||
}
|
||||
|
||||
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
|
||||
if (generatedDeviceLogs.length === 0) {
|
||||
stepCtx.logger.warn('No device logs were successfully collected.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading device logs...`);
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `Maestro Test Device Logs`,
|
||||
paths: [deviceLogsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If any tests failed, we throw an error to mark the step as failed.
|
||||
if (failedFlows.length > 0) {
|
||||
throw new Error(
|
||||
`Some Maestro tests failed:\n- ${failedFlows
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
} else {
|
||||
stepCtx.logger.info('All Maestro tests passed.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getMaestroTestCommand(params: {
|
||||
flow_path: string;
|
||||
output_format: string | undefined;
|
||||
/** Unused if `output_format` is undefined */
|
||||
output_path: string;
|
||||
}): [command: string, ...args: string[]] {
|
||||
let outputFormatFlags: string[] = [];
|
||||
if (params.output_format) {
|
||||
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
|
||||
}
|
||||
|
||||
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
|
||||
command: string,
|
||||
...args: string[],
|
||||
];
|
||||
}
|
||||
|
||||
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
|
||||
junit: 'xml',
|
||||
html: 'html',
|
||||
};
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
|
||||
async function withCleanDeviceAsync<TResult>({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
platform: 'ios' | 'android';
|
||||
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
fn: ({
|
||||
deviceIdentifier,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
}) => Promise<TResult>;
|
||||
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
|
||||
// Clone and start the device
|
||||
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.cloneAsync({
|
||||
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
|
||||
destinationDeviceName: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid,
|
||||
env,
|
||||
});
|
||||
localDeviceIdentifier = localDeviceName as IosSimulatorName;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
try {
|
||||
logger.info(
|
||||
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
|
||||
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
localDeviceIdentifier = serialId;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(
|
||||
{ err: cleanupErr },
|
||||
`Failed to clean up Android Emulator ${localDeviceName}.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!localDeviceIdentifier) {
|
||||
throw new Error('Device did not return an identifier after startup.');
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
|
||||
|
||||
// Stop the device
|
||||
|
||||
let logsResult: Result<{ outputPath: string }> | null = null;
|
||||
|
||||
try {
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
IosSimulatorUtils.collectLogsAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.deleteAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
AndroidEmulatorUtils.collectLogsAsync({
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error cleaning up device: ${err}`);
|
||||
}
|
||||
|
||||
return { fnResult: fnResult.enforceValue(), logsResult };
|
||||
}
|
||||
|
||||
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
|
||||
async function maybeWithScreenRecordingAsync<TResult>({
|
||||
shouldRecord,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
// As weird as it is, it's more convenient to have this function like `maybeWith...`
|
||||
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
|
||||
shouldRecord: boolean;
|
||||
platform: 'ios' | 'android';
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
fn: () => Promise<TResult>;
|
||||
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
|
||||
if (!shouldRecord) {
|
||||
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
|
||||
}
|
||||
|
||||
let recordingResult: Result<{
|
||||
recordingSpawn: SpawnPromise<SpawnResult>;
|
||||
outputPath?: string;
|
||||
}>;
|
||||
|
||||
// Start screen recording
|
||||
|
||||
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
recordingResult = await asyncResult(
|
||||
IosSimulatorUtils.startScreenRecordingAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
recordingResult = await asyncResult(
|
||||
AndroidEmulatorUtils.startScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
logger.warn('Failed to start screen recording.', recordingResult.reason);
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn());
|
||||
|
||||
// If recording failed there's nothing to stop, so we return the results
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
return { fnResult, recordingResult: result(recordingResult.reason) };
|
||||
}
|
||||
|
||||
// If recording started, finish it
|
||||
|
||||
try {
|
||||
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
await IosSimulatorUtils.stopScreenRecordingAsync({
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
});
|
||||
return {
|
||||
fnResult,
|
||||
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
|
||||
recordingResult: result(recordingResult.value.outputPath!),
|
||||
};
|
||||
}
|
||||
case 'android': {
|
||||
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
env,
|
||||
});
|
||||
return { fnResult, recordingResult: result(outputPath) };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to stop screen recording.', err);
|
||||
|
||||
return { fnResult, recordingResult: result(err as Error) };
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,47 @@ describe(configureCredentialsAsync, () => {
|
||||
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
|
||||
export async function configureCredentialsAsync(
|
||||
logger: bunyan,
|
||||
@@ -29,6 +30,9 @@ export async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -133,6 +134,10 @@ export default class ProvisioningProfile {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
|
||||
-400
@@ -1,400 +0,0 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import bunyan from 'bunyan';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
|
||||
|
||||
// We use node:fs
|
||||
jest.unmock('node:fs');
|
||||
// and fg uses fs.
|
||||
jest.unmock('fs');
|
||||
jest.setTimeout(15000);
|
||||
|
||||
/**
|
||||
*
|
||||
* You need to have a Simulator running to run these tests.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('findMaestroPathsFlowsToExecuteAsync', () => {
|
||||
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
|
||||
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
|
||||
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
|
||||
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
|
||||
|
||||
let logger: bunyan;
|
||||
|
||||
beforeAll(() => {
|
||||
logger = createMockLogger();
|
||||
});
|
||||
|
||||
// Helper function to run maestro test and determine which flows it executed
|
||||
// by parsing the output
|
||||
async function getMaestroFlowList(
|
||||
flowPath: string,
|
||||
includeTags: string[] = [],
|
||||
excludeTags: string[] = []
|
||||
): Promise<string[]> {
|
||||
const args = ['test', flowPath, '--no-ansi'];
|
||||
|
||||
if (includeTags.length > 0) {
|
||||
args.push('--include-tags', includeTags.join(','));
|
||||
}
|
||||
|
||||
if (excludeTags.length > 0) {
|
||||
args.push('--exclude-tags', excludeTags.join(','));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAsync('maestro', args, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseMaestroExecutedFlows(result.stdout + result.stderr);
|
||||
} catch (error: any) {
|
||||
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
|
||||
return parseMaestroExecutedFlows(output);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMaestroExecutedFlows(output: string): string[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
|
||||
.map(line => {
|
||||
if (line.includes('[Passed]')) {
|
||||
return line.split('[Passed]')[1].trim().split(' ')[0];
|
||||
}
|
||||
return line.split('> Flow')[1].trim();
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Helper function to run our implementation
|
||||
async function getOurFlowList(
|
||||
flowPath: string,
|
||||
includeTags: string[] = [],
|
||||
excludeTags: string[] = []
|
||||
): Promise<string[]> {
|
||||
const result = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: process.cwd(),
|
||||
flowPath: path.relative(process.cwd(), flowPath),
|
||||
includeTags,
|
||||
excludeTags,
|
||||
logger,
|
||||
});
|
||||
|
||||
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
|
||||
}
|
||||
|
||||
describe('Directory-based flow discovery', () => {
|
||||
it('should discover all flows when no tags are specified', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir);
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by include tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by both include and exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should handle multiple include tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should handle multiple exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should not include nested flows in subdirectories', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should exclude config files from flow discovery', async () => {
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
|
||||
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude non-YAML files from flow discovery', async () => {
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows).not.toContain('non-flow-file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single file scenarios', () => {
|
||||
it('should handle single file input', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
const maestroFlows = await getMaestroFlowList(singleFile);
|
||||
const ourFlows = await getOurFlowList(singleFile);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
|
||||
});
|
||||
|
||||
it('should handle single file with tag filtering (include)', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
// This file has 'smoke' tag, so should be included
|
||||
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
|
||||
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle single file with tag filtering (exclude)', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
// This file has 'smoke' tag, so should be excluded
|
||||
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
|
||||
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle non-existent paths gracefully', async () => {
|
||||
const nonExistentPath = '/path/that/does/not/exist';
|
||||
|
||||
// Our implementation should return empty array for non-existent paths
|
||||
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
|
||||
await fs.mkdir(emptyDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const ourFlows = await getOurFlowList(emptyDir);
|
||||
expect(ourFlows).toEqual([]);
|
||||
} finally {
|
||||
await fs.rmdir(emptyDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle flows that cannot be parsed', async () => {
|
||||
// The invalid-flow.yaml has extra fields that might cause parsing issues
|
||||
// but should still be discovered if tag filtering passes
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
|
||||
|
||||
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
|
||||
// The important thing is that it doesn't crash
|
||||
expect(Array.isArray(ourFlows)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance comparison', () => {
|
||||
it('should complete discovery in reasonable time', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await getOurFlowList(fixturesDir);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailed flow content verification', () => {
|
||||
it('should correctly identify flows with specific tag combinations', async () => {
|
||||
// Test complex tag filtering scenarios
|
||||
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
|
||||
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
|
||||
|
||||
// Verify specific files are included/excluded correctly
|
||||
expect(authFlows).toContain('tagged-flow-auth');
|
||||
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
|
||||
expect(smokeFlows).toContain('tagged-flow-smoke');
|
||||
expect(criticalFlows).toContain('tagged-flow-smoke');
|
||||
});
|
||||
|
||||
it('should handle flows without tags correctly', async () => {
|
||||
// When no filters are applied, flows without tags should be included
|
||||
const allFlows = await getOurFlowList(fixturesDir);
|
||||
expect(allFlows).toContain('no-tags-flow');
|
||||
expect(allFlows).toContain('basic-flow');
|
||||
|
||||
// When include tags are specified, flows without tags should be excluded
|
||||
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
expect(taggedFlows).not.toContain('no-tags-flow');
|
||||
expect(taggedFlows).not.toContain('basic-flow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.yaml flow patterns', () => {
|
||||
it('should discover flows using config.yaml patterns', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configFlowsDir);
|
||||
const ourFlows = await getOurFlowList(configFlowsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Verify specific files are included based on config patterns
|
||||
expect(ourFlows).toContain('login'); // features/auth/login.yaml
|
||||
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
|
||||
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
|
||||
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
|
||||
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
|
||||
|
||||
// Verify top-level file is excluded (not matching patterns)
|
||||
expect(ourFlows).not.toContain('ignored-top-level');
|
||||
});
|
||||
|
||||
it('should handle tag filtering with config.yaml patterns', async () => {
|
||||
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
|
||||
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
|
||||
|
||||
expect(authFlows).toEqual(maestroAuthFlows);
|
||||
expect(authFlows).toContain('login');
|
||||
expect(authFlows).toContain('logout');
|
||||
expect(authFlows).not.toContain('navigation');
|
||||
expect(authFlows).not.toContain('api.spec');
|
||||
});
|
||||
|
||||
it('should handle exclude tags with config.yaml patterns', async () => {
|
||||
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
|
||||
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
|
||||
|
||||
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
|
||||
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
|
||||
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
|
||||
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
|
||||
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
|
||||
});
|
||||
|
||||
it('should handle multiple include tags with config.yaml patterns', async () => {
|
||||
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
|
||||
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
|
||||
'auth',
|
||||
'integration',
|
||||
]);
|
||||
|
||||
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
|
||||
expect(multiTagFlows).toContain('login'); // auth tag
|
||||
expect(multiTagFlows).toContain('logout'); // auth tag
|
||||
expect(multiTagFlows).toContain('api.spec'); // integration tag
|
||||
expect(multiTagFlows).toContain('ui.spec'); // integration tag
|
||||
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.yaml includeTags/excludeTags merging', () => {
|
||||
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
|
||||
|
||||
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir);
|
||||
const ourFlows = await getOurFlowList(configTagsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-neutral');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
expect(ourFlows).not.toContain('flow-cli-include');
|
||||
});
|
||||
|
||||
it('should merge CLI includeTags with config includeTags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).toContain('flow-cli-include');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
});
|
||||
|
||||
it('should merge CLI excludeTags with config excludeTags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
// Exclude both cfgExclude and cliExclude
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
});
|
||||
|
||||
it('should apply both merged include and exclude tags correctly', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility (no config.yaml)', () => {
|
||||
it('should use default "*" pattern when no config.yaml exists', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
|
||||
const ourFlows = await getOurFlowList(noConfigFlowsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Should include top-level files
|
||||
expect(ourFlows).toContain('top-level-flow');
|
||||
|
||||
// Should exclude subdirectory files (default "*" behavior)
|
||||
expect(ourFlows).not.toContain('nested-flow');
|
||||
});
|
||||
|
||||
it('should handle tag filtering without config.yaml', async () => {
|
||||
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
|
||||
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
|
||||
|
||||
expect(basicFlows).toEqual(maestroBasicFlows);
|
||||
expect(basicFlows).toContain('top-level-flow');
|
||||
|
||||
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
|
||||
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
|
||||
|
||||
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
|
||||
expect(noIncludeTagsFlows).toContain('top-level-flow');
|
||||
expect(noIncludeTagsFlows).not.toContain('nested-flow');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
flows:
|
||||
- 'features/**/*.yaml'
|
||||
- 'specs/*.spec.yaml'
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "login"
|
||||
tags: ["auth", "smoke"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "logout"
|
||||
tags: ["auth"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "navigation"
|
||||
tags: ["core", "smoke"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "ignored-top-level"
|
||||
tags: ["ignored"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "api.spec"
|
||||
tags: ["api", "integration"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "ui.spec"
|
||||
tags: ["ui", "integration"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "basic-flow"
|
||||
---
|
||||
- assertTrue: true
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "invalid-flow"
|
||||
tags:
|
||||
- invalid
|
||||
---
|
||||
- assertTrue: true
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "no-tags-flow"
|
||||
---
|
||||
- assertTrue: true
|
||||
-1
@@ -1 +0,0 @@
|
||||
This is not a YAML file and should be ignored by flow discovery.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "nested-flow"
|
||||
tags:
|
||||
- nested
|
||||
- auth
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-auth"
|
||||
tags:
|
||||
- auth
|
||||
- login
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-regression"
|
||||
tags:
|
||||
- regression
|
||||
- slow
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-smoke"
|
||||
tags:
|
||||
- smoke
|
||||
- critical
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "nested-flow"
|
||||
tags: ["nested"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "top-level-flow"
|
||||
tags: ["basic"]
|
||||
---
|
||||
- assertTrue: true
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "standalone-flow"
|
||||
tags:
|
||||
- standalone
|
||||
- smoke
|
||||
---
|
||||
- assertTrue: true
|
||||
@@ -1,120 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import nock from 'nock';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('precompiledModules integration', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('merges the extracted debug and release xcframework trees', async () => {
|
||||
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = homeDirectory;
|
||||
|
||||
try {
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
|
||||
const debugArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
|
||||
);
|
||||
const releaseArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
|
||||
);
|
||||
|
||||
nock('https://fixtures.expo.test')
|
||||
.get('/xcframeworks-Debug.zip')
|
||||
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
|
||||
.get('/xcframeworks-Release.zip')
|
||||
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('os', () => ({
|
||||
...jest.requireActual('os'),
|
||||
homedir: () => homeDirectory,
|
||||
}));
|
||||
const {
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} = await import('../precompiledModules');
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
{
|
||||
logger: createMockLogger(),
|
||||
env: {},
|
||||
} as any,
|
||||
[
|
||||
'https://fixtures.expo.test/xcframeworks-Debug.zip',
|
||||
'https://fixtures.expo.test/xcframeworks-Release.zip',
|
||||
]
|
||||
);
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(PRECOMPILED_MODULES_PATH).toBe(
|
||||
path.join(homeDirectory, '.expo', 'precompiled-modules')
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-camera fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-application fixture\n');
|
||||
} finally {
|
||||
jest.dontMock('os');
|
||||
jest.resetModules();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.remove(homeDirectory);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import {
|
||||
PRECOMPILED_MODULES_PATH,
|
||||
startPreparingPrecompiledDependencies,
|
||||
waitForPrecompiledModulesPreparationAsync,
|
||||
} from '../precompiledModules';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('precompiledModules', () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
const getMkdtempPath = jest.fn();
|
||||
|
||||
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
|
||||
({
|
||||
logger: createMockLogger(),
|
||||
env,
|
||||
}) as unknown as BuildContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
getMkdtempPath.mockReset();
|
||||
getMkdtempPath
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('downloads through CocoaPods proxy when enabled', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
|
||||
jest
|
||||
.mocked(downloadFile)
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts all configured archives into the well-known destination path', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
await waitForPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
|
||||
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
|
||||
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('leaves the destination empty when preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
extract.mockRejectedValueOnce(new Error('extract failed'));
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]);
|
||||
|
||||
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
|
||||
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
|
||||
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
|
||||
|
||||
startPreparingPrecompiledDependencies(createCtx(), [
|
||||
'https://example.com/xcframeworks-Debug.zip',
|
||||
]);
|
||||
|
||||
const waitPromise = waitForPrecompiledModulesPreparationAsync();
|
||||
const waitExpectation = expect(waitPromise).rejects.toThrow(
|
||||
'Timed out waiting for precompiled dependencies after 30 seconds'
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await waitExpectation;
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,24 @@ import { ExpoConfig } from '@expo/config';
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { PackageManager } from '../packageManager';
|
||||
import { readEasJsonContents, runExpoCliCommand } from '../project';
|
||||
import { PackageManager, findPackagerRootDir } from '../packageManager';
|
||||
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../packageManager', () => ({
|
||||
...jest.requireActual('../packageManager'),
|
||||
findPackagerRootDir: jest.fn((dir: string) => dir),
|
||||
}));
|
||||
|
||||
describe(runExpoCliCommand, () => {
|
||||
describe('Expo SDK >= 46', () => {
|
||||
@@ -75,6 +81,61 @@ describe(runExpoCliCommand, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isUsingModernYarnVersion, () => {
|
||||
const projectDir = '/project';
|
||||
const mockedFindPackagerRootDir = findPackagerRootDir as jest.MockedFunction<
|
||||
typeof findPackagerRootDir
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFindPackagerRootDir.mockReturnValue(projectDir);
|
||||
jest.spyOn(fs, 'createReadStream').mockImplementation(((
|
||||
filePath: string,
|
||||
options?: { start?: number; end?: number }
|
||||
) => {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const start = options?.start ?? 0;
|
||||
const end = options?.end != null ? options.end + 1 : content.length;
|
||||
return Readable.from(Buffer.from(content.slice(start, end)));
|
||||
}) as any);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists in project directory', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, '.yarnrc.yml')]: '' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists in workspace root directory', async () => {
|
||||
const workspaceRoot = '/workspace-root';
|
||||
mockedFindPackagerRootDir.mockReturnValue(workspaceRoot);
|
||||
vol.fromJSON({ [path.join(workspaceRoot, '.yarnrc.yml')]: '' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no .yarnrc.yml and no yarn.lock exist', async () => {
|
||||
vol.fromJSON({ [projectDir]: null });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when yarn.lock contains classic v1 header', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when yarn.lock does not contain classic v1 header', async () => {
|
||||
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '__metadata:\n version: 8\n' });
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when .yarnrc.yml exists even with classic yarn.lock', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(projectDir, '.yarnrc.yml')]: '',
|
||||
[path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n',
|
||||
});
|
||||
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readEasJsonContents, () => {
|
||||
it('returns the raw eas.json contents even when it is not valid JSON', async () => {
|
||||
const projectDir = '/project';
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fg from 'fast-glob';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as yaml from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const FlowConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const WorkspaceConfigSchema = z.object({
|
||||
flows: z.array(z.string()).optional(),
|
||||
executionOrder: z.record(z.string(), z.unknown()).optional(),
|
||||
includeTags: z.array(z.string()).optional(),
|
||||
excludeTags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
||||
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
|
||||
|
||||
export async function findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory,
|
||||
flowPath,
|
||||
includeTags: _includeTags,
|
||||
excludeTags: _excludeTags,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
flowPath: string;
|
||||
includeTags: string[] | undefined;
|
||||
excludeTags: string[] | undefined;
|
||||
logger: bunyan;
|
||||
}): Promise<string[]> {
|
||||
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
|
||||
// If it's a file, just return it (no validation needed)
|
||||
const stat = await fs.stat(absoluteFlowPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
|
||||
return [absoluteFlowPath];
|
||||
}
|
||||
|
||||
// It's a directory - discover flow files
|
||||
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
|
||||
|
||||
// Check for workspace config
|
||||
logger.info(`Searching for workspace config...`);
|
||||
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
|
||||
dirPath: absoluteFlowPath,
|
||||
workingDirectory,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
|
||||
|
||||
if (workspaceConfig?.executionOrder) {
|
||||
logger.warn(`Execution order is not supported yet. Ignoring.`);
|
||||
}
|
||||
|
||||
logger.info(`Searching for flow files...`);
|
||||
const { flows } = await findAndParseFlowFilesAsync({
|
||||
dirPath: absoluteFlowPath,
|
||||
workingDirectory,
|
||||
workspaceConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (flows.length === 0) {
|
||||
logger.info(
|
||||
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
|
||||
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
|
||||
|
||||
if (includeTags.length === 0 && excludeTags.length === 0) {
|
||||
logger.info(`No tags provided, returning all flows.`);
|
||||
return flows.map(({ path }) => path);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
|
||||
JSON.stringify(excludeTags) ?? 'none'
|
||||
}.`
|
||||
);
|
||||
return flows
|
||||
.filter(({ config, path: flowPath }) => {
|
||||
const shouldInclude = matchesTags({
|
||||
flowTags: config?.tags ?? [],
|
||||
includeTags,
|
||||
excludeTags,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
shouldInclude
|
||||
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
|
||||
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
|
||||
);
|
||||
|
||||
return shouldInclude;
|
||||
})
|
||||
.map(({ path }) => path);
|
||||
}
|
||||
|
||||
async function findAndParseWorkspaceConfigAsync({
|
||||
dirPath,
|
||||
workingDirectory,
|
||||
logger,
|
||||
}: {
|
||||
dirPath: string;
|
||||
workingDirectory: string;
|
||||
logger: bunyan;
|
||||
}): Promise<WorkspaceConfig | null> {
|
||||
const configPaths = await fg(['config.yaml', 'config.yml'], {
|
||||
cwd: dirPath,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const configDoc = yaml.parse(content);
|
||||
if (!configDoc) {
|
||||
logger.warn(
|
||||
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
|
||||
return WorkspaceConfigSchema.parse(configDoc);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findAndParseFlowFilesAsync({
|
||||
workingDirectory,
|
||||
dirPath,
|
||||
workspaceConfig,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
dirPath: string;
|
||||
workspaceConfig: WorkspaceConfig | null;
|
||||
logger: bunyan;
|
||||
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
|
||||
const flows: { config: FlowConfig; path: string }[] = [];
|
||||
|
||||
// Determine flow patterns from config or use default
|
||||
const flowPatterns = workspaceConfig?.flows ?? ['*'];
|
||||
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
|
||||
|
||||
// Use fast-glob to find matching files
|
||||
const matchedFiles = await fg(flowPatterns, {
|
||||
cwd: dirPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
|
||||
});
|
||||
|
||||
logger.info(`Found ${matchedFiles.length} potential flow files`);
|
||||
|
||||
// Parse each matched file
|
||||
for (const filePath of matchedFiles) {
|
||||
// Skip non-YAML files
|
||||
const ext = path.extname(filePath);
|
||||
if (ext !== '.yaml' && ext !== '.yml') {
|
||||
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await asyncResult(parseFlowFile(filePath));
|
||||
if (result.ok) {
|
||||
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
|
||||
flows.push({ config: result.value, path: filePath });
|
||||
} else {
|
||||
logger.info(
|
||||
{ err: result.reason },
|
||||
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { flows };
|
||||
}
|
||||
|
||||
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const documents = yaml.parseAllDocuments(content);
|
||||
const configDoc = documents[0];
|
||||
if (!configDoc) {
|
||||
throw new Error(`No config section found in ${filePath}`);
|
||||
}
|
||||
return FlowConfigSchema.parse(configDoc.toJS());
|
||||
}
|
||||
|
||||
function matchesTags({
|
||||
flowTags,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
}: {
|
||||
flowTags: string[];
|
||||
includeTags: string[];
|
||||
excludeTags: string[];
|
||||
}): boolean {
|
||||
// Include logic: if includeTags is empty OR flow has any of the include tags
|
||||
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
|
||||
|
||||
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
|
||||
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
|
||||
|
||||
return includeMatch && excludeMatch;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
let precompiledModulesPreparationPromise: Promise<void> | null = null;
|
||||
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
|
||||
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
|
||||
|
||||
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
|
||||
return env.EAS_USE_PRECOMPILED_MODULES === '1';
|
||||
}
|
||||
|
||||
export function maybeStartPreparingPrecompiledModules(
|
||||
ctx: BuildContext,
|
||||
config: { precompiledModulesUrls: string[] }
|
||||
): void {
|
||||
if (!shouldUsePrecompiledDependencies(ctx.env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
|
||||
}
|
||||
|
||||
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
|
||||
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
|
||||
logger: ctx.logger,
|
||||
urls,
|
||||
destinationDirectory: PRECOMPILED_MODULES_PATH,
|
||||
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
|
||||
if (!precompiledModulesPreparationPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
precompiledModulesPreparationPromise,
|
||||
new Promise<void>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
|
||||
)
|
||||
);
|
||||
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
|
||||
timeoutHandle.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePrecompiledDependenciesAsync({
|
||||
logger,
|
||||
urls,
|
||||
destinationDirectory,
|
||||
cocoapodsProxyUrl,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
urls: string[];
|
||||
destinationDirectory: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
}): Promise<void> {
|
||||
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
|
||||
const destinationParentDirectory = path.dirname(destinationDirectory);
|
||||
await fs.mkdirp(destinationParentDirectory);
|
||||
const stagingDirectory = await fs.mkdtemp(
|
||||
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
|
||||
);
|
||||
|
||||
logger.info('');
|
||||
logger.info(
|
||||
{
|
||||
destinationDirectory,
|
||||
urls,
|
||||
},
|
||||
'Starting precompiled dependencies download'
|
||||
);
|
||||
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
|
||||
try {
|
||||
const archives = urls.map((url, index) => ({
|
||||
archiveName: path.basename(new URL(url).pathname),
|
||||
url,
|
||||
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
archives.map(async ({ url, archivePath }) => {
|
||||
await downloadPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
for (const { archiveName, url, archivePath } of archives) {
|
||||
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
|
||||
await extractZipAsync(archivePath, stagingDirectory);
|
||||
logger.info(
|
||||
{ destinationDirectory: stagingDirectory },
|
||||
`Extracted ${archiveName} into staging precompiled dependencies directory`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.move(stagingDirectory, destinationDirectory);
|
||||
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
|
||||
} catch (error) {
|
||||
await fs.remove(stagingDirectory);
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.remove(archiveDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
}: {
|
||||
url: string;
|
||||
archivePath: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const proxiedUrl = cocoapodsProxyUrl
|
||||
? (() => {
|
||||
const parsedUrl = new URL(url);
|
||||
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
})()
|
||||
: null;
|
||||
if (proxiedUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, proxiedUrl },
|
||||
'Failed to download through CocoaPods proxy, retrying directly'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await downloadFile(url, archivePath, { retry: 3 });
|
||||
}
|
||||
|
||||
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
|
||||
const zip = new StreamZip.async({ file: archivePath });
|
||||
try {
|
||||
await zip.extract(null, destinationDirectory);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,43 @@ import path from 'path';
|
||||
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
|
||||
async function readFirstChars(filePath: string, chars: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = fs.createReadStream(filePath, {
|
||||
start: 0,
|
||||
end: chars - 1,
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('data', chunk => {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* check if .yarnrc.yml exists in the project dir or in the workspace root dir
|
||||
* check if .yarnrc.yml exists in the project dir or in the workspace root dir,
|
||||
* or if the `yarn.lock` file is classic one, not a modern one
|
||||
*/
|
||||
export async function isUsingModernYarnVersion(projectDir: string): Promise<boolean> {
|
||||
const rootDir = findPackagerRootDir(projectDir);
|
||||
const yarnrcPath = path.join(projectDir, '.yarnrc.yml');
|
||||
const yarnrcRootPath = path.join(findPackagerRootDir(projectDir), '.yarnrc.yml');
|
||||
return (await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath));
|
||||
if ((await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const yarnlockPath = path.join(rootDir, 'yarn.lock');
|
||||
if (!(await fs.pathExists(yarnlockPath))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The yarn.lock file is for Yarn Classic, not Modern, if it contains "# yarn lockfile v1"
|
||||
const startOfLockfile = await readFirstChars(yarnlockPath, 100);
|
||||
return !/yarn lockfile v1/i.test(startOfLockfile);
|
||||
}
|
||||
|
||||
export function runExpoCliCommand({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.4.0"
|
||||
"@expo/steps": "18.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "18.0.2",
|
||||
"version": "18.5.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -24,6 +24,98 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context without build_upload', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect build_upload context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: 'not-a-uuid',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app and account context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -259,6 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
app_store_connect: z
|
||||
.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
+92
-90
@@ -178,7 +178,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +195,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +217,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +234,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +248,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +300,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +321,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +341,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +361,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +384,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +422,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +442,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +460,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +480,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -501,7 +501,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -522,7 +522,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -560,7 +560,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -600,7 +600,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -630,7 +630,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -656,7 +656,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -708,7 +708,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -726,7 +726,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -744,7 +744,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -761,7 +761,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -782,7 +782,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -803,7 +803,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -825,7 +825,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -845,7 +845,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -867,7 +867,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -889,7 +889,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -924,7 +924,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -947,7 +947,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -968,7 +968,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -985,7 +985,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1003,7 +1003,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1032,7 +1032,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1057,7 +1057,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1078,7 +1078,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1099,7 +1099,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1136,7 +1136,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1156,7 +1156,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1177,7 +1177,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1198,7 +1198,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1212,7 +1212,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1226,7 +1226,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1258,7 +1258,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1284,7 +1284,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1306,7 +1306,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1334,7 +1334,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1362,7 +1362,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1385,7 +1385,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1407,7 +1407,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1441,7 +1441,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1485,7 +1485,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1516,7 +1516,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1625,7 +1625,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1633,16 +1633,17 @@ generate the local store configuration from the app stores
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas metadata:pull [-e <value>]
|
||||
$ eas metadata:pull [-e <value>] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1650,16 +1651,17 @@ sync the local store configuration to the app stores
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas metadata:push [-e <value>]
|
||||
$ eas metadata:push [-e <value>] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1711,7 +1713,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1725,7 +1727,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1748,7 +1750,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1772,7 +1774,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1790,7 +1792,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1823,7 +1825,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1866,7 +1868,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1887,7 +1889,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1908,7 +1910,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1933,7 +1935,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1960,7 +1962,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1998,7 +2000,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2026,7 +2028,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2056,7 +2058,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2077,7 +2079,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2097,7 +2099,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2118,7 +2120,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2140,7 +2142,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2160,7 +2162,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2179,7 +2181,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2202,7 +2204,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2219,7 +2221,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2340,7 +2342,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2360,7 +2362,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2384,7 +2386,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2424,7 +2426,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2446,7 +2448,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2474,7 +2476,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2494,7 +2496,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2516,5 +2518,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.4.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -43,15 +43,15 @@
|
||||
"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.15",
|
||||
"@expo/apple-utils": "2.1.19",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/eas-json": "18.4.0",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/eas-json": "18.5.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.4.0",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -315,6 +315,42 @@
|
||||
"liveEdits": true,
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"screenshots": {
|
||||
"type": "object",
|
||||
"description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previews": {
|
||||
"type": "object",
|
||||
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Video file path (relative to project root)"
|
||||
},
|
||||
"previewFrameTimeCode": {
|
||||
"type": "string",
|
||||
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -479,6 +515,57 @@
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -601,6 +688,230 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -614,6 +925,16 @@
|
||||
"FIFTEEN_PLUS",
|
||||
"NINETEEN_PLUS"
|
||||
]
|
||||
},
|
||||
"AppleAgeRatingOverrideV2": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
"NINE_PLUS",
|
||||
"THIRTEEN_PLUS",
|
||||
"SIXTEEN_PLUS",
|
||||
"EIGHTEEN_PLUS",
|
||||
"UNRATED"
|
||||
]
|
||||
}
|
||||
},
|
||||
"AppleConfig": {
|
||||
@@ -821,6 +1142,14 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): P
|
||||
},
|
||||
{
|
||||
configPath: 'exp.notification.icon',
|
||||
pngPath: ctx.exp.notification?.icon,
|
||||
pngPath: (ctx.exp as Record<string, any>).notification?.icon,
|
||||
},
|
||||
];
|
||||
await validatePNGsAsync(pngs);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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,6 +6,7 @@ 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';
|
||||
@@ -21,10 +22,7 @@ export default class MetadataPull extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -19,10 +20,7 @@ export default class MetadataPush extends EasCommand {
|
||||
description:
|
||||
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
}),
|
||||
'non-interactive': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Run the command in non-interactive mode.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
|
||||
@@ -10,7 +10,11 @@ import { getAppFromContextAsync } from './actions/BuildCredentialsUtils';
|
||||
import { SetUpBuildCredentials } from './actions/SetUpBuildCredentials';
|
||||
import { SetUpPushKey } from './actions/SetUpPushKey';
|
||||
import { App, IosCredentials, Target } from './types';
|
||||
import { isAdHocProfile, isEnterpriseUniversalProfile } from './utils/provisioningProfile';
|
||||
import {
|
||||
isAdHocProfile,
|
||||
isDevelopmentProfile,
|
||||
isEnterpriseUniversalProfile,
|
||||
} from './utils/provisioningProfile';
|
||||
import { CommonIosAppCredentialsFragment } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findApplicationTarget } from '../../project/ios/target';
|
||||
@@ -150,6 +154,7 @@ export default class IosCredentialsProvider {
|
||||
|
||||
private assertProvisioningProfileType(provisioningProfile: string, targetName?: string): void {
|
||||
const isAdHoc = isAdHocProfile(provisioningProfile);
|
||||
const isDevelopment = isDevelopmentProfile(provisioningProfile);
|
||||
const isEnterprise = isEnterpriseUniversalProfile(provisioningProfile);
|
||||
if (this.options.distribution === 'internal') {
|
||||
if (this.options.enterpriseProvisioning === 'universal' && !isEnterprise) {
|
||||
@@ -164,16 +169,21 @@ export default class IosCredentialsProvider {
|
||||
targetName ? ` (target '${targetName})'` : ''
|
||||
} for internal distribution if you specified "enterpriseProvisioning": "adhoc" in eas.json`
|
||||
);
|
||||
} else if (!this.options.enterpriseProvisioning && !isEnterprise && !isAdHoc) {
|
||||
} else if (
|
||||
!this.options.enterpriseProvisioning &&
|
||||
!isEnterprise &&
|
||||
!isAdHoc &&
|
||||
!isDevelopment
|
||||
) {
|
||||
throw new Error(
|
||||
`You must use an adhoc provisioning profile${
|
||||
`You must use an adhoc or development provisioning profile${
|
||||
targetName ? ` (target '${targetName})'` : ''
|
||||
} for internal distribution.`
|
||||
);
|
||||
}
|
||||
} else if (isAdHoc) {
|
||||
} else if (isAdHoc || isDevelopment) {
|
||||
throw new Error(
|
||||
`You can't use an adhoc provisioning profile${
|
||||
`You can't use ${isAdHoc ? 'an adhoc' : 'a development'} provisioning profile${
|
||||
targetName ? ` (target '${targetName}')` : ''
|
||||
} for app store distribution.`
|
||||
);
|
||||
|
||||
@@ -23,7 +23,21 @@ export function readProfileName(dataBase64: string): string {
|
||||
export function isAdHocProfile(dataBase64: string): boolean {
|
||||
const profilePlist = parse(dataBase64);
|
||||
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
|
||||
return Array.isArray(provisionedDevices);
|
||||
if (!Array.isArray(provisionedDevices)) {
|
||||
return false;
|
||||
}
|
||||
const entitlements = profilePlist['Entitlements'] as PlistObject | undefined;
|
||||
return !entitlements?.['get-task-allow'];
|
||||
}
|
||||
|
||||
export function isDevelopmentProfile(dataBase64: string): boolean {
|
||||
const profilePlist = parse(dataBase64);
|
||||
const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined;
|
||||
if (!Array.isArray(provisionedDevices)) {
|
||||
return false;
|
||||
}
|
||||
const entitlements = profilePlist['Entitlements'] as PlistObject | undefined;
|
||||
return !!entitlements?.['get-task-allow'];
|
||||
}
|
||||
|
||||
export function isEnterpriseUniversalProfile(dataBase64: string): boolean {
|
||||
|
||||
@@ -34,6 +34,15 @@ 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 = {
|
||||
@@ -55,6 +64,15 @@ 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 = {
|
||||
@@ -76,6 +94,15 @@ 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 = {
|
||||
@@ -97,4 +124,13 @@ 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,3 +176,43 @@ 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,6 +12,7 @@ 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', () => {
|
||||
@@ -42,6 +43,22 @@ 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', () => {
|
||||
@@ -329,3 +346,72 @@ 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,7 +16,15 @@ import {
|
||||
import uniq from '../../../utils/expodash/uniq';
|
||||
import { AttributesOf } from '../../utils/asc';
|
||||
import { removeDatePrecision } from '../../utils/date';
|
||||
import { AppleMetadata } from '../types';
|
||||
import {
|
||||
AppleAppClip,
|
||||
AppleAppClipDefaultExperience,
|
||||
AppleAppClipLocalizedInfo,
|
||||
AppleDataUsage,
|
||||
AppleMetadata,
|
||||
ApplePreviews,
|
||||
AppleScreenshots,
|
||||
} from '../types';
|
||||
|
||||
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
|
||||
|
||||
@@ -57,6 +65,15 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,4 +228,42 @@ export class AppleConfigReader {
|
||||
// TODO: add attachment
|
||||
};
|
||||
}
|
||||
|
||||
/** Get screenshots configuration for a specific locale */
|
||||
public getScreenshots(locale: string): AppleScreenshots | null {
|
||||
return this.schema.info?.[locale]?.screenshots ?? null;
|
||||
}
|
||||
|
||||
/** Get video previews configuration for a specific locale */
|
||||
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,7 +13,15 @@ import {
|
||||
} from '@expo/apple-utils';
|
||||
|
||||
import { AttributesOf } from '../../utils/asc';
|
||||
import { AppleMetadata } from '../types';
|
||||
import {
|
||||
AppleAppClipDefaultExperience,
|
||||
AppleAppClipLocalizedInfo,
|
||||
AppleAppClipReviewDetail,
|
||||
AppleDataUsage,
|
||||
AppleMetadata,
|
||||
ApplePreviews,
|
||||
AppleScreenshots,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Serializes the Apple ASC entities into the metadata configuration schema.
|
||||
@@ -55,6 +63,15 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +203,77 @@ export class AppleConfigWriter {
|
||||
// TODO: add attachment
|
||||
};
|
||||
}
|
||||
|
||||
/** Set screenshots for a specific locale */
|
||||
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
|
||||
this.schema.info = this.schema.info ?? {};
|
||||
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
|
||||
this.schema.info[locale].screenshots =
|
||||
Object.keys(screenshots).length > 0 ? screenshots : undefined;
|
||||
}
|
||||
|
||||
/** Set video previews for a specific locale */
|
||||
public setPreviews(locale: string, previews: ApplePreviews): void {
|
||||
this.schema.info = this.schema.info ?? {};
|
||||
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,19 +1,32 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
|
||||
* It contains references to each individual models, to either upload or download App Store data.
|
||||
*/
|
||||
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
|
||||
export type AppleData = { app: App; projectDir: string } & AppInfoData &
|
||||
AppVersionData &
|
||||
AgeRatingData &
|
||||
AppReviewData &
|
||||
ScreenshotsData &
|
||||
PreviewsData &
|
||||
AppClipData &
|
||||
DataUsageData;
|
||||
|
||||
/**
|
||||
* The unprepared partial apple data, used within the `prepareAsync` tasks.
|
||||
* It contains a reference to the app, each task should populate the necessary data.
|
||||
* If an entity fails to prepare the data, individual tasks should raise errors about the missing data.
|
||||
*/
|
||||
export type PartialAppleData = { app: App } & Partial<Omit<AppleData, 'app'>>;
|
||||
export type PartialAppleData = { app: App; projectDir: string } & Partial<
|
||||
Omit<AppleData, 'app' | 'projectDir'>
|
||||
>;
|
||||
@@ -1,8 +1,14 @@
|
||||
import { truthy } from '../../../utils/expodash/filter';
|
||||
import { IssueRule } from '../../config/issue';
|
||||
import { AppleInfo } from '../types';
|
||||
|
||||
const RESTRICTED_PROPERTIES: (keyof AppleInfo)[] = ['title', 'subtitle', 'description', 'keywords'];
|
||||
/** Only check text properties that may contain restricted words */
|
||||
type AppleInfoTextProperty = 'title' | 'subtitle' | 'description' | 'keywords';
|
||||
const RESTRICTED_PROPERTIES: AppleInfoTextProperty[] = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'description',
|
||||
'keywords',
|
||||
];
|
||||
const RESTRICTED_WORDS = {
|
||||
beta: 'Apple restricts the word "beta" and synonyms implying incomplete functionality.',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AgeRatingDeclaration, AppStoreVersion, Rating } from '@expo/apple-utils';
|
||||
import { AgeRatingDeclaration, AppInfo, Rating } from '@expo/apple-utils';
|
||||
import nock from 'nock';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
@@ -12,13 +12,13 @@ jest.mock('../../config/writer');
|
||||
|
||||
describe(AgeRatingTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('loads age rating from app version', async () => {
|
||||
it('loads age rating from app info', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
|
||||
.get(`/v1/${AppInfo.type}/stub-id/ageRatingDeclaration`)
|
||||
.reply(200, require('./fixtures/appStoreVersions/get-ageRatingDeclaration-200.json'));
|
||||
|
||||
const context: any = {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
info: new AppInfo(requestContext, 'stub-id', {} as any),
|
||||
};
|
||||
|
||||
await new AgeRatingTask().prepareAsync({ context });
|
||||
@@ -26,6 +26,16 @@ describe(AgeRatingTask, () => {
|
||||
expect(context.ageRating).toBeInstanceOf(AgeRatingDeclaration);
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips when info is not available', async () => {
|
||||
const context: any = {
|
||||
info: undefined,
|
||||
};
|
||||
|
||||
await new AgeRatingTask().prepareAsync({ context });
|
||||
|
||||
expect(context.ageRating).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
@@ -54,13 +64,13 @@ describe(AgeRatingTask, () => {
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
it('aborts when age rating is not loaded', async () => {
|
||||
const promise = new AgeRatingTask().uploadAsync({
|
||||
config: new AppleConfigReader({}),
|
||||
context: { ageRating: undefined } as any,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('rating not initialized');
|
||||
it('skips when age rating is not loaded', async () => {
|
||||
await expect(
|
||||
new AgeRatingTask().uploadAsync({
|
||||
config: new AppleConfigReader({}),
|
||||
context: { ageRating: undefined } as any,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('skips updating age rating when not configured', async () => {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ describe(AppInfoTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppInfoTask().prepareAsync({ context });
|
||||
|
||||
@@ -72,20 +72,20 @@ describe(AppReviewDetailTask, () => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: 'review@example.com',
|
||||
phone: '+1 555 555 5555',
|
||||
},
|
||||
}),
|
||||
context: {} as any,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('version not init');
|
||||
it('skips when version is not loaded', async () => {
|
||||
await expect(
|
||||
new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
},
|
||||
}),
|
||||
context: {} as any,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('updates review details when loaded', async () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
|
||||
@@ -83,6 +84,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
|
||||
@@ -118,6 +120,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppVersionTask().prepareAsync({ context });
|
||||
@@ -153,6 +156,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppVersionTask().prepareAsync({ context });
|
||||
@@ -194,6 +198,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppVersionTask({ version: '2.0' }).prepareAsync({ context });
|
||||
@@ -243,6 +248,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
};
|
||||
|
||||
await new AppVersionTask({ version: '3.0' }).prepareAsync({ context });
|
||||
@@ -371,6 +377,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
|
||||
};
|
||||
|
||||
@@ -399,6 +406,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
|
||||
versionPhasedRelease: null, // Not enabled yet
|
||||
};
|
||||
@@ -417,6 +425,7 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
|
||||
// Enabled, and not completed yet
|
||||
versionPhasedRelease: new AppStoreVersionPhasedRelease(
|
||||
@@ -440,8 +449,9 @@ describe(AppVersionTask, () => {
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: new App(requestContext, 'stub-id', {} as any),
|
||||
projectDir: '/test/project',
|
||||
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
|
||||
// Enabled, and not completed yet
|
||||
// Enabled, and completed
|
||||
versionPhasedRelease: new AppStoreVersionPhasedRelease(
|
||||
requestContext,
|
||||
'APP_STORE_VERSION_PHASED_RELEASE_1',
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
AppPreview,
|
||||
AppPreviewSet,
|
||||
AppStoreVersionLocalization,
|
||||
PreviewType,
|
||||
} from '@expo/apple-utils';
|
||||
import nock from 'nock';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
import { AppleConfigReader } from '../../config/reader';
|
||||
import { AppleConfigWriter } from '../../config/writer';
|
||||
import { PartialAppleData } from '../../data';
|
||||
import { PreviewsTask } from '../previews';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
|
||||
const mockFetch = jest.fn();
|
||||
jest.mock('../../../../fetch', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: any[]) => mockFetch(...args),
|
||||
}));
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 10240 } as any);
|
||||
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
|
||||
describe(PreviewsTask, () => {
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('prepareAsync', () => {
|
||||
it('initializes empty preview sets when no locales are available', async () => {
|
||||
const context: PartialAppleData = {
|
||||
app: {} as any,
|
||||
projectDir: '/test/project',
|
||||
versionLocales: undefined,
|
||||
};
|
||||
|
||||
await new PreviewsTask().prepareAsync({ context });
|
||||
|
||||
expect(context.previewSets).toBeDefined();
|
||||
expect(context.previewSets!.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fetches preview sets for each locale', async () => {
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppPreviewSet.type}`)
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'PSET_1',
|
||||
type: AppPreviewSet.type,
|
||||
attributes: {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: {} as any,
|
||||
projectDir: '/test/project',
|
||||
versionLocales: [locale],
|
||||
};
|
||||
|
||||
await new PreviewsTask().prepareAsync({ context });
|
||||
|
||||
expect(context.previewSets).toBeDefined();
|
||||
expect(context.previewSets!.size).toBe(1);
|
||||
expect(context.previewSets!.get('en-US')).toBeDefined();
|
||||
expect(context.previewSets!.get('en-US')!.has(PreviewType.IPHONE_67)).toBeTruthy();
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('skips when preview sets are not prepared', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new PreviewsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { previewSets: undefined, versionLocales: undefined } as any,
|
||||
});
|
||||
|
||||
expect(writer.setPreviews).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('skips locales with no preview sets', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new PreviewsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPreviews).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('downloads previews and sets config with simple path', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const preview = new AppPreview(requestContext, 'PV_1', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
videoUrl: 'https://example.com/demo.mp4',
|
||||
assetDeliveryState: {
|
||||
state: 'COMPLETE',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [preview],
|
||||
} as any);
|
||||
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', previewTypeMap);
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
|
||||
});
|
||||
|
||||
await new PreviewsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPreviews).toBeCalledWith('en-US', {
|
||||
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads previews with previewFrameTimeCode as object config', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const preview = new AppPreview(requestContext, 'PV_1', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: '00:05:00',
|
||||
videoUrl: 'https://example.com/demo.mp4',
|
||||
assetDeliveryState: {
|
||||
state: 'COMPLETE',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [preview],
|
||||
} as any);
|
||||
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', previewTypeMap);
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
|
||||
});
|
||||
|
||||
await new PreviewsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPreviews).toBeCalledWith('en-US', {
|
||||
[PreviewType.IPHONE_67]: {
|
||||
path: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
|
||||
previewFrameTimeCode: '00:05:00',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('skips when preview sets are not prepared', async () => {
|
||||
const config = new AppleConfigReader({});
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: { previewSets: undefined, versionLocales: undefined } as any,
|
||||
});
|
||||
|
||||
// Should not throw, just log and return
|
||||
});
|
||||
|
||||
it('skips when no locales are configured', async () => {
|
||||
const config = new AppleConfigReader({});
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets: new Map(),
|
||||
versionLocales: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
// Should not throw, just log and return
|
||||
});
|
||||
|
||||
it('skips locales without previews in config', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
// No previews
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets: new Map(),
|
||||
versionLocales: [locale],
|
||||
} as any,
|
||||
});
|
||||
|
||||
// Should complete without uploading
|
||||
});
|
||||
|
||||
it('uploads new preview for configured locale with string path', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: './previews/demo.mp4',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
// Create preview set
|
||||
.post(`/v1/${AppPreviewSet.type}`)
|
||||
.reply(201, {
|
||||
data: {
|
||||
id: 'NEW_PSET_1',
|
||||
type: AppPreviewSet.type,
|
||||
attributes: {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock AppPreview.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
|
||||
requestContext,
|
||||
expect.objectContaining({
|
||||
id: 'NEW_PSET_1',
|
||||
filePath: '/test/project/previews/demo.mp4',
|
||||
waitForProcessing: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads new preview with object config including previewFrameTimeCode', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: {
|
||||
path: './previews/demo.mp4',
|
||||
previewFrameTimeCode: '00:05:00',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
|
||||
fileName: 'demo.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: '00:05:00',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
// Create preview set
|
||||
.post(`/v1/${AppPreviewSet.type}`)
|
||||
.reply(201, {
|
||||
data: {
|
||||
id: 'NEW_PSET_1',
|
||||
type: AppPreviewSet.type,
|
||||
attributes: {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock AppPreview.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
|
||||
requestContext,
|
||||
expect.objectContaining({
|
||||
id: 'NEW_PSET_1',
|
||||
filePath: '/test/project/previews/demo.mp4',
|
||||
waitForProcessing: true,
|
||||
previewFrameTimeCode: '00:05:00',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
AppScreenshot,
|
||||
AppScreenshotSet,
|
||||
AppStoreVersionLocalization,
|
||||
ScreenshotDisplayType,
|
||||
} from '@expo/apple-utils';
|
||||
import nock from 'nock';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
import { AppleConfigReader } from '../../config/reader';
|
||||
import { AppleConfigWriter } from '../../config/writer';
|
||||
import { PartialAppleData } from '../../data';
|
||||
import { ScreenshotsTask } from '../screenshots';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
|
||||
const mockFetch = jest.fn();
|
||||
jest.mock('../../../../fetch', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: any[]) => mockFetch(...args),
|
||||
}));
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1024 } as any);
|
||||
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
|
||||
describe(ScreenshotsTask, () => {
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('prepareAsync', () => {
|
||||
it('initializes empty screenshot sets when no locales are available', async () => {
|
||||
const context: PartialAppleData = {
|
||||
app: {} as any,
|
||||
projectDir: '/test/project',
|
||||
versionLocales: undefined,
|
||||
};
|
||||
|
||||
await new ScreenshotsTask().prepareAsync({ context });
|
||||
|
||||
expect(context.screenshotSets).toBeDefined();
|
||||
expect(context.screenshotSets!.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fetches screenshot sets for each locale', async () => {
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppScreenshotSet.type}`)
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'SET_1',
|
||||
type: AppScreenshotSet.type,
|
||||
attributes: {
|
||||
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
|
||||
appScreenshots: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context: PartialAppleData = {
|
||||
app: {} as any,
|
||||
projectDir: '/test/project',
|
||||
versionLocales: [locale],
|
||||
};
|
||||
|
||||
await new ScreenshotsTask().prepareAsync({ context });
|
||||
|
||||
expect(context.screenshotSets).toBeDefined();
|
||||
expect(context.screenshotSets!.size).toBe(1);
|
||||
expect(context.screenshotSets!.get('en-US')).toBeDefined();
|
||||
expect(
|
||||
context.screenshotSets!.get('en-US')!.has(ScreenshotDisplayType.APP_IPHONE_67)
|
||||
).toBeTruthy();
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('skips when screenshot sets are not prepared', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new ScreenshotsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { screenshotSets: undefined, versionLocales: undefined } as any,
|
||||
});
|
||||
|
||||
expect(writer.setScreenshots).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('skips locales with no screenshot sets', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const screenshotSets = new Map();
|
||||
screenshotSets.set('en-US', new Map());
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new ScreenshotsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
screenshotSets,
|
||||
versionLocales: [locale],
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setScreenshots).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('downloads screenshots and sets config', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const screenshot = new AppScreenshot(requestContext, 'SS_1', {
|
||||
fileName: 'home.png',
|
||||
fileSize: 1024,
|
||||
assetDeliveryState: {
|
||||
state: 'COMPLETE',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Mock getImageAssetUrl
|
||||
jest.spyOn(screenshot, 'getImageAssetUrl').mockReturnValue('https://example.com/home.png');
|
||||
|
||||
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
|
||||
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
|
||||
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
|
||||
appScreenshots: [screenshot],
|
||||
} as any);
|
||||
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
|
||||
|
||||
const screenshotSets = new Map();
|
||||
screenshotSets.set('en-US', displayTypeMap);
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
// Mock fetch response
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
buffer: () => Promise.resolve(Buffer.from('fake-image-data')),
|
||||
});
|
||||
|
||||
await new ScreenshotsTask().downloadAsync({
|
||||
config: writer,
|
||||
context: {
|
||||
screenshotSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setScreenshots).toBeCalledWith('en-US', {
|
||||
[ScreenshotDisplayType.APP_IPHONE_67]: [
|
||||
'store/apple/screenshot/en-US/APP_IPHONE_67/home.png',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('skips when screenshot sets are not prepared', async () => {
|
||||
const config = new AppleConfigReader({});
|
||||
|
||||
await new ScreenshotsTask().uploadAsync({
|
||||
config,
|
||||
context: { screenshotSets: undefined, versionLocales: undefined } as any,
|
||||
});
|
||||
|
||||
// Should not throw, just log and return
|
||||
});
|
||||
|
||||
it('skips when no locales are configured', async () => {
|
||||
const config = new AppleConfigReader({});
|
||||
|
||||
await new ScreenshotsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
screenshotSets: new Map(),
|
||||
versionLocales: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
// Should not throw, just log and return
|
||||
});
|
||||
|
||||
it('skips locales without screenshots in config', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
// No screenshots
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
await new ScreenshotsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
screenshotSets: new Map(),
|
||||
versionLocales: [locale],
|
||||
} as any,
|
||||
});
|
||||
|
||||
// Should complete without uploading
|
||||
});
|
||||
|
||||
it('uploads new screenshots for configured locale', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
screenshots: {
|
||||
APP_IPHONE_67: ['./screenshots/home.png'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const newScreenshot = new AppScreenshot(requestContext, 'NEW_SS_1', {
|
||||
fileName: 'home.png',
|
||||
fileSize: 1024,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
// Mock createAppScreenshotSetAsync on the locale
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
// Create screenshot set
|
||||
.post(`/v1/${AppScreenshotSet.type}`)
|
||||
.reply(201, {
|
||||
data: {
|
||||
id: 'NEW_SET_1',
|
||||
type: AppScreenshotSet.type,
|
||||
attributes: {
|
||||
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
|
||||
appScreenshots: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock AppScreenshot.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppScreenshot, 'uploadAsync').mockResolvedValue(newScreenshot);
|
||||
|
||||
// Mock AppScreenshotSet.infoAsync for reorder step
|
||||
jest.spyOn(AppScreenshotSet, 'infoAsync').mockResolvedValue(
|
||||
new AppScreenshotSet(requestContext, 'NEW_SET_1', {
|
||||
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
|
||||
appScreenshots: [newScreenshot],
|
||||
} as any)
|
||||
);
|
||||
|
||||
// Mock reorderScreenshotsAsync
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppScreenshotSet.prototype.reorderScreenshotsAsync = reorderMock;
|
||||
|
||||
const screenshotSets = new Map();
|
||||
screenshotSets.set('en-US', new Map());
|
||||
|
||||
await new ScreenshotsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
screenshotSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
expect(AppScreenshot.uploadAsync).toHaveBeenCalledWith(
|
||||
requestContext,
|
||||
expect.objectContaining({
|
||||
id: 'NEW_SET_1',
|
||||
filePath: '/test/project/screenshots/home.png',
|
||||
waitForProcessing: true,
|
||||
})
|
||||
);
|
||||
// Reorder is skipped because the current order already matches
|
||||
expect(reorderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AgeRatingDeclaration } from '@expo/apple-utils';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../../log';
|
||||
@@ -7,7 +6,7 @@ import { logAsync } from '../../utils/log';
|
||||
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
|
||||
|
||||
export type AgeRatingData = {
|
||||
/** The app age rating declaration for the app version */
|
||||
/** The app age rating declaration for the app info */
|
||||
ageRating: AgeRatingDeclaration;
|
||||
};
|
||||
|
||||
@@ -15,7 +14,22 @@ export class AgeRatingTask extends AppleTask {
|
||||
public name = (): string => 'age rating declarations';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
|
||||
if (!context.info) {
|
||||
return;
|
||||
}
|
||||
// The ageRatingDeclaration relationship is on appInfos (not appStoreVersions).
|
||||
try {
|
||||
context.ageRating = (await context.info.getAgeRatingDeclarationAsync()) ?? undefined;
|
||||
} catch (error: any) {
|
||||
// Gracefully handle cases where the relationship is not available
|
||||
if (error?.message?.includes('ageRatingDeclaration')) {
|
||||
Log.warn(
|
||||
chalk`{yellow Skipped age rating - not available for this app. This may require updating through App Store Connect directly.}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
@@ -25,7 +39,10 @@ export class AgeRatingTask extends AppleTask {
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
assert(context.ageRating, `Age rating not initialized, can't update age rating`);
|
||||
if (!context.ageRating) {
|
||||
Log.log(chalk`{dim - Skipped age rating update, not available}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ageRating = config.getAgeRating();
|
||||
if (!ageRating) {
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
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,5 +1,4 @@
|
||||
import { AppStoreReviewDetail } from '@expo/apple-utils';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import Log from '../../../log';
|
||||
@@ -16,7 +15,10 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
public name = (): string => 'app review detail';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
|
||||
if (!context.version) {
|
||||
return;
|
||||
}
|
||||
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
@@ -32,7 +34,10 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(context.version, `App version not initialized, can't upload store review details`);
|
||||
if (!context.version) {
|
||||
Log.log(chalk`{dim - Skipped store review details, no version available}`);
|
||||
return;
|
||||
}
|
||||
const { versionString } = context.version.attributes;
|
||||
|
||||
if (!context.reviewDetail) {
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
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,7 +1,11 @@
|
||||
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';
|
||||
|
||||
type AppleTaskOptions = {
|
||||
@@ -17,5 +21,9 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
|
||||
new AppInfoTask(),
|
||||
new AgeRatingTask(),
|
||||
new AppReviewDetailTask(),
|
||||
new ScreenshotsTask(),
|
||||
new PreviewsTask(),
|
||||
new AppClipTask(),
|
||||
new DataUsageTask(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
AppPreview,
|
||||
AppPreviewSet,
|
||||
AppStoreVersionLocalization,
|
||||
PreviewType,
|
||||
} 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';
|
||||
import { ApplePreviewConfig, ApplePreviews } from '../types';
|
||||
|
||||
/** Locale -> PreviewType -> AppPreviewSet */
|
||||
export type PreviewSetsMap = Map<string, Map<PreviewType, AppPreviewSet>>;
|
||||
|
||||
export type PreviewsData = {
|
||||
/** Map of locales to their preview sets */
|
||||
previewSets: PreviewSetsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
|
||||
*/
|
||||
function normalizePreviewConfig(config: ApplePreviewConfig): {
|
||||
path: string;
|
||||
previewFrameTimeCode?: string;
|
||||
} {
|
||||
if (typeof config === 'string') {
|
||||
return { path: config };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task for managing App Store video previews.
|
||||
* Downloads existing previews and uploads new ones based on store configuration.
|
||||
*/
|
||||
export class PreviewsTask extends AppleTask {
|
||||
public name = (): string => 'video previews';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
// Initialize the preview sets map
|
||||
context.previewSets = new Map();
|
||||
|
||||
if (!context.versionLocales) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch preview sets for all locales in parallel
|
||||
await Promise.all(
|
||||
context.versionLocales.map(async locale => {
|
||||
const sets = await locale.getAppPreviewSetsAsync();
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
|
||||
for (const set of sets) {
|
||||
previewTypeMap.set(set.attributes.previewType, set);
|
||||
}
|
||||
|
||||
context.previewSets!.set(locale.attributes.locale, previewTypeMap);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
if (!context.previewSets || !context.versionLocales) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const locale of context.versionLocales) {
|
||||
const localeCode = locale.attributes.locale;
|
||||
const previewTypeMap = context.previewSets.get(localeCode);
|
||||
|
||||
if (!previewTypeMap || previewTypeMap.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previews: ApplePreviews = {};
|
||||
|
||||
for (const [previewType, set] of previewTypeMap) {
|
||||
const previewModels = set.attributes.appPreviews;
|
||||
if (!previewModels || previewModels.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
previewType,
|
||||
preview,
|
||||
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 (Object.keys(previews).length > 0) {
|
||||
config.setPreviews(localeCode, previews);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
if (!context.previewSets || !context.versionLocales) {
|
||||
Log.log(chalk`{dim - Skipped video previews, no version available}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const locales = config.getLocales();
|
||||
if (locales.length <= 0) {
|
||||
Log.log(chalk`{dim - Skipped video previews, no locales configured}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const localeCode of locales) {
|
||||
const previews = config.getPreviews(localeCode);
|
||||
if (!previews || Object.keys(previews).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
|
||||
if (!localization) {
|
||||
Log.warn(chalk`{yellow Skipping video previews for ${localeCode} - locale not found}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await syncPreviewSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
context.previewSets.get(localeCode)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a preview set - upload new preview, delete old one if changed.
|
||||
*/
|
||||
async function syncPreviewSetAsync(
|
||||
projectDir: string,
|
||||
localization: AppStoreVersionLocalization,
|
||||
previewType: PreviewType,
|
||||
previewConfig: { path: string; previewFrameTimeCode?: string },
|
||||
existingSets: Map<PreviewType, AppPreviewSet> | undefined
|
||||
): Promise<void> {
|
||||
const locale = localization.attributes.locale;
|
||||
const absolutePath = path.resolve(projectDir, previewConfig.path);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create the preview set
|
||||
let previewSet = existingSets?.get(previewType);
|
||||
|
||||
if (!previewSet) {
|
||||
previewSet = await logAsync(
|
||||
() =>
|
||||
localization.createAppPreviewSetAsync({
|
||||
previewType,
|
||||
}),
|
||||
{
|
||||
pending: `Creating preview set for ${chalk.bold(previewType)} (${locale})...`,
|
||||
success: `Created preview set for ${chalk.bold(previewType)} (${locale})`,
|
||||
failure: `Failed creating preview set for ${chalk.bold(previewType)} (${locale})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const existingPreviews = previewSet.attributes.appPreviews || [];
|
||||
|
||||
// Check if we need to update (different filename, size, or no existing preview)
|
||||
const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName);
|
||||
const localSize = fs.statSync(absolutePath).size;
|
||||
|
||||
if (
|
||||
existingPreview &&
|
||||
existingPreview.isComplete() &&
|
||||
existingPreview.attributes.fileSize === localSize
|
||||
) {
|
||||
// Preview with same filename exists, check if we need to update preview frame time code
|
||||
if (
|
||||
previewConfig.previewFrameTimeCode &&
|
||||
existingPreview.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
|
||||
) {
|
||||
await logAsync(
|
||||
() =>
|
||||
existingPreview.updateAsync({
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing previews that don't match
|
||||
for (const preview of existingPreviews) {
|
||||
if (preview.attributes.fileName !== fileName) {
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
|
||||
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Upload new preview
|
||||
await logAsync(
|
||||
() =>
|
||||
AppPreview.uploadAsync(localization.context, {
|
||||
id: previewSet!.id,
|
||||
filePath: absolutePath,
|
||||
waitForProcessing: true,
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Uploading video preview ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Uploaded video preview ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed uploading video preview ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a video preview to the local filesystem.
|
||||
* Returns the relative path to the downloaded file.
|
||||
*/
|
||||
async function downloadPreviewAsync(
|
||||
projectDir: string,
|
||||
locale: string,
|
||||
previewType: PreviewType,
|
||||
preview: AppPreview,
|
||||
index: number
|
||||
): Promise<string | null> {
|
||||
const videoUrl = preview.getVideoUrl();
|
||||
if (!videoUrl) {
|
||||
Log.warn(chalk`{yellow Could not get download URL for preview ${preview.attributes.fileName}}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create directory structure: store/apple/preview/{locale}/{previewType}/
|
||||
const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType);
|
||||
await fs.promises.mkdir(previewsDir, { recursive: true });
|
||||
|
||||
// Use original filename for matching during sync
|
||||
const fileName = preview.attributes.fileName || `${String(index + 1).padStart(2, '0')}.mp4`;
|
||||
const outputPath = path.join(previewsDir, fileName);
|
||||
const relativePath = path.relative(projectDir, outputPath);
|
||||
|
||||
try {
|
||||
const response = await fetch(videoUrl);
|
||||
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 video preview: ${relativePath}}`);
|
||||
return relativePath;
|
||||
} catch (error: any) {
|
||||
Log.warn(chalk`{yellow Failed to download video preview ${fileName}: ${error.message}}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
AppScreenshot,
|
||||
AppScreenshotSet,
|
||||
AppStoreVersionLocalization,
|
||||
ScreenshotDisplayType,
|
||||
} 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';
|
||||
import { AppleScreenshots } from '../types';
|
||||
|
||||
/** Locale -> ScreenshotDisplayType -> AppScreenshotSet */
|
||||
export type ScreenshotSetsMap = Map<string, Map<ScreenshotDisplayType, AppScreenshotSet>>;
|
||||
|
||||
export type ScreenshotsData = {
|
||||
/** Map of locales to their screenshot sets */
|
||||
screenshotSets: ScreenshotSetsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Task for managing App Store screenshots.
|
||||
* Downloads existing screenshots and uploads new ones based on store configuration.
|
||||
*/
|
||||
export class ScreenshotsTask extends AppleTask {
|
||||
public name = (): string => 'screenshots';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
// Initialize the screenshot sets map
|
||||
context.screenshotSets = new Map();
|
||||
|
||||
if (!context.versionLocales) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch screenshot sets for all locales in parallel
|
||||
await Promise.all(
|
||||
context.versionLocales.map(async locale => {
|
||||
const sets = await locale.getAppScreenshotSetsAsync();
|
||||
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
|
||||
|
||||
for (const set of sets) {
|
||||
displayTypeMap.set(set.attributes.screenshotDisplayType, set);
|
||||
}
|
||||
|
||||
context.screenshotSets!.set(locale.attributes.locale, displayTypeMap);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
if (!context.screenshotSets || !context.versionLocales) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const locale of context.versionLocales) {
|
||||
const localeCode = locale.attributes.locale;
|
||||
const displayTypeMap = context.screenshotSets.get(localeCode);
|
||||
|
||||
if (!displayTypeMap || displayTypeMap.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const screenshots: AppleScreenshots = {};
|
||||
|
||||
for (const [displayType, set] of displayTypeMap) {
|
||||
const screenshotModels = set.attributes.appScreenshots;
|
||||
if (!screenshotModels || screenshotModels.length === 0) {
|
||||
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.
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < screenshotModels.length; i++) {
|
||||
const screenshot = screenshotModels[i];
|
||||
const downloaded = await downloadScreenshotAsync(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
displayType,
|
||||
screenshot,
|
||||
i
|
||||
);
|
||||
if (downloaded) {
|
||||
paths.push(downloaded);
|
||||
continue;
|
||||
}
|
||||
// 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) {
|
||||
screenshots[displayType] = paths;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(screenshots).length > 0) {
|
||||
config.setScreenshots(localeCode, screenshots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
if (!context.screenshotSets || !context.versionLocales) {
|
||||
Log.log(chalk`{dim - Skipped screenshots, no version available}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const locales = config.getLocales();
|
||||
if (locales.length <= 0) {
|
||||
Log.log(chalk`{dim - Skipped screenshots, no locales configured}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const localeCode of locales) {
|
||||
const screenshots = config.getScreenshots(localeCode);
|
||||
if (!screenshots || Object.keys(screenshots).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
|
||||
if (!localization) {
|
||||
Log.warn(chalk`{yellow Skipping screenshots for ${localeCode} - locale not found}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [displayType, paths] of Object.entries(screenshots)) {
|
||||
if (!paths || paths.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await syncScreenshotSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
displayType as ScreenshotDisplayType,
|
||||
paths,
|
||||
context.screenshotSets.get(localeCode)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a screenshot set - upload new screenshots, delete removed ones, reorder if needed.
|
||||
*/
|
||||
async function syncScreenshotSetAsync(
|
||||
projectDir: string,
|
||||
localization: AppStoreVersionLocalization,
|
||||
displayType: ScreenshotDisplayType,
|
||||
paths: string[],
|
||||
existingSets: Map<ScreenshotDisplayType, AppScreenshotSet> | undefined
|
||||
): Promise<void> {
|
||||
const locale = localization.attributes.locale;
|
||||
|
||||
// Get or create the screenshot set
|
||||
let screenshotSet = existingSets?.get(displayType);
|
||||
|
||||
if (!screenshotSet) {
|
||||
screenshotSet = await logAsync(
|
||||
() =>
|
||||
localization.createAppScreenshotSetAsync({
|
||||
screenshotDisplayType: displayType,
|
||||
}),
|
||||
{
|
||||
pending: `Creating screenshot set for ${chalk.bold(displayType)} (${locale})...`,
|
||||
success: `Created screenshot set for ${chalk.bold(displayType)} (${locale})`,
|
||||
failure: `Failed creating screenshot set for ${chalk.bold(displayType)} (${locale})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const existingScreenshots = screenshotSet.attributes.appScreenshots || [];
|
||||
|
||||
// Build a map of existing screenshots by filename for comparison
|
||||
const existingByFilename = new Map<string, AppScreenshot>();
|
||||
for (const screenshot of existingScreenshots) {
|
||||
existingByFilename.set(screenshot.attributes.fileName, screenshot);
|
||||
}
|
||||
|
||||
// Track which screenshots to keep, upload, and delete
|
||||
const screenshotIdsToKeep: string[] = [];
|
||||
const pathsToUpload: string[] = [];
|
||||
|
||||
for (const relativePath of paths) {
|
||||
const absolutePath = path.resolve(projectDir, relativePath);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
// Check if screenshot already exists with same name and file size
|
||||
const existing = existingByFilename.get(fileName);
|
||||
const localSize = fs.existsSync(absolutePath) ? fs.statSync(absolutePath).size : null;
|
||||
if (
|
||||
existing &&
|
||||
existing.isComplete() &&
|
||||
(localSize === null || existing.attributes.fileSize === localSize)
|
||||
) {
|
||||
screenshotIdsToKeep.push(existing.id);
|
||||
existingByFilename.delete(fileName);
|
||||
} else {
|
||||
pathsToUpload.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete screenshots that are no longer in config
|
||||
for (const screenshot of existingByFilename.values()) {
|
||||
await logAsync(() => screenshot.deleteAsync(), {
|
||||
pending: `Deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})...`,
|
||||
success: `Deleted screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
|
||||
failure: `Failed deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Upload new screenshots
|
||||
for (const absolutePath of pathsToUpload) {
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.warn(chalk`{yellow Screenshot not found: ${absolutePath}}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newScreenshot = await logAsync(
|
||||
() =>
|
||||
AppScreenshot.uploadAsync(localization.context, {
|
||||
id: screenshotSet!.id,
|
||||
filePath: absolutePath,
|
||||
waitForProcessing: true,
|
||||
}),
|
||||
{
|
||||
pending: `Uploading screenshot ${chalk.bold(fileName)} (${locale})...`,
|
||||
success: `Uploaded screenshot ${chalk.bold(fileName)} (${locale})`,
|
||||
failure: `Failed uploading screenshot ${chalk.bold(fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
|
||||
screenshotIdsToKeep.push(newScreenshot.id);
|
||||
}
|
||||
|
||||
// Reorder screenshots to match config order
|
||||
if (screenshotIdsToKeep.length > 0) {
|
||||
const refreshedSet = await AppScreenshotSet.infoAsync(localization.context, {
|
||||
id: screenshotSet.id,
|
||||
});
|
||||
const refreshedScreenshots = refreshedSet.attributes.appScreenshots || [];
|
||||
const screenshotsByFilename = new Map<string, AppScreenshot>();
|
||||
for (const s of refreshedScreenshots) {
|
||||
screenshotsByFilename.set(s.attributes.fileName, s);
|
||||
}
|
||||
|
||||
// Build the desired order based on config paths
|
||||
const orderedIds: string[] = [];
|
||||
for (const relativePath of paths) {
|
||||
const fileName = path.basename(relativePath);
|
||||
const screenshot = screenshotsByFilename.get(fileName);
|
||||
if (screenshot) {
|
||||
orderedIds.push(screenshot.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Only call reorder if the order actually differs from current
|
||||
const currentIds = refreshedScreenshots.map(s => s.id);
|
||||
if (
|
||||
orderedIds.length > 0 &&
|
||||
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
|
||||
) {
|
||||
await screenshotSet.reorderScreenshotsAsync({ appScreenshots: orderedIds });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a screenshot to the local filesystem.
|
||||
* Returns the relative path to the downloaded file.
|
||||
*/
|
||||
async function downloadScreenshotAsync(
|
||||
projectDir: string,
|
||||
locale: string,
|
||||
displayType: ScreenshotDisplayType,
|
||||
screenshot: AppScreenshot,
|
||||
index: number
|
||||
): Promise<string | null> {
|
||||
const imageUrl = screenshot.getImageAssetUrl({ type: 'png' });
|
||||
if (!imageUrl) {
|
||||
Log.warn(
|
||||
chalk`{yellow Could not get download URL for screenshot ${screenshot.attributes.fileName}}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create directory structure: store/apple/screenshot/{locale}/{displayType}/
|
||||
const screenshotsDir = path.join(projectDir, 'store', 'apple', 'screenshot', locale, displayType);
|
||||
await fs.promises.mkdir(screenshotsDir, { recursive: true });
|
||||
|
||||
// Use original filename for matching during sync
|
||||
const fileName = screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}.png`;
|
||||
const outputPath = path.join(screenshotsDir, 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 screenshot: ${relativePath}}`);
|
||||
return relativePath;
|
||||
} catch (error: any) {
|
||||
Log.warn(chalk`{yellow Failed to download screenshot ${fileName}: ${error.message}}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,50 @@
|
||||
import type { AgeRatingDeclarationProps } from '@expo/apple-utils';
|
||||
import type {
|
||||
AgeRatingDeclarationProps,
|
||||
AppClipAction,
|
||||
AppDataUsageCategoryId,
|
||||
AppDataUsageDataProtectionId,
|
||||
AppDataUsagePurposeId,
|
||||
PreviewType,
|
||||
ScreenshotDisplayType,
|
||||
} from '@expo/apple-utils';
|
||||
|
||||
export type AppleLocale = string;
|
||||
|
||||
/** Screenshot display type enum values from App Store Connect API */
|
||||
export type AppleScreenshotDisplayType = `${ScreenshotDisplayType}`;
|
||||
|
||||
/** Preview display type enum values from App Store Connect API */
|
||||
export type ApplePreviewType = `${PreviewType}`;
|
||||
|
||||
/**
|
||||
* Screenshots organized by display type.
|
||||
* Key is the display type (e.g., 'APP_IPHONE_67'), value is array of file paths.
|
||||
* @example { "APP_IPHONE_67": ["./screenshots/home.png", "./screenshots/profile.png"] }
|
||||
*/
|
||||
export type AppleScreenshots = Partial<Record<AppleScreenshotDisplayType, string[]>>;
|
||||
|
||||
/**
|
||||
* Video preview configuration - either a simple path string or an object with options.
|
||||
* @example "./previews/demo.mp4"
|
||||
* @example { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" }
|
||||
*/
|
||||
export type ApplePreviewConfig =
|
||||
| string
|
||||
| {
|
||||
/** Video file path (relative to project root) */
|
||||
path: string;
|
||||
/** Optional preview frame time code (e.g., '00:05:00' for 5 seconds) */
|
||||
previewFrameTimeCode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Video previews organized by display type.
|
||||
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
|
||||
* @example { "IPHONE_67": "./previews/demo.mp4" }
|
||||
* @example { "IPHONE_67": { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } }
|
||||
*/
|
||||
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
|
||||
|
||||
export interface AppleMetadata {
|
||||
version?: string;
|
||||
copyright?: string;
|
||||
@@ -9,8 +52,99 @@ export interface AppleMetadata {
|
||||
categories?: AppleCategory;
|
||||
release?: AppleRelease;
|
||||
advisory?: AppleAdvisory;
|
||||
/** @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
|
||||
@@ -40,6 +174,10 @@ export interface AppleInfo {
|
||||
privacyPolicyText?: string;
|
||||
privacyChoicesUrl?: string;
|
||||
supportUrl?: string;
|
||||
/** Screenshots for this locale, organized by display type */
|
||||
screenshots?: AppleScreenshots;
|
||||
/** Video previews for this locale, organized by display type */
|
||||
previews?: ApplePreviews;
|
||||
}
|
||||
|
||||
export interface AppleReview {
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function downloadMetadataAsync({
|
||||
const errors: Error[] = [];
|
||||
const config = createAppleWriter();
|
||||
const tasks = createAppleTasks();
|
||||
const taskCtx = { app };
|
||||
const taskCtx = { app, projectDir };
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function uploadMetadataAsync({
|
||||
version: config.getVersion()?.versionString,
|
||||
});
|
||||
|
||||
const taskCtx = { app };
|
||||
const taskCtx = { app, projectDir };
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { resolveVcsClient } from '../../vcs';
|
||||
import { resolveWorkflowAsync, resolveWorkflowPerPlatformAsync } from '../workflow';
|
||||
import {
|
||||
hasIgnoredIosProjectAsync,
|
||||
resolveWorkflowAsync,
|
||||
resolveWorkflowPerPlatformAsync,
|
||||
} from '../workflow';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
@@ -86,3 +90,21 @@ describe(resolveWorkflowPerPlatformAsync, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(hasIgnoredIosProjectAsync, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
test('returns true when ios project is ignored', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/helloworld.xcodeproj/project.pbxproj': 'fake',
|
||||
'./.easignore': 'ios/\n',
|
||||
},
|
||||
projectDir
|
||||
);
|
||||
|
||||
await expect(hasIgnoredIosProjectAsync(projectDir, vcsClient)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -137,81 +137,21 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
|
||||
})
|
||||
).rejects.toThrowError(/we can't update this file programmatically/);
|
||||
});
|
||||
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.jester.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
|
||||
expect(appJson).toMatchObject({
|
||||
expo: { android: { package: 'com.jester.myapp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
|
||||
it('throws an error in non-interactive mode', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
'app.json': '{ "blah": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.example.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: '' } as any,
|
||||
projectId: '',
|
||||
exp: {} as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
})
|
||||
|
||||
@@ -148,8 +148,15 @@ async function configureApplicationIdAsync({
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<string> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#package'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
// we can't automatically update app.config.js
|
||||
if (paths.dynamicConfigPath) {
|
||||
throw new Error(
|
||||
@@ -159,50 +166,25 @@ async function configureApplicationIdAsync({
|
||||
|
||||
assert(paths.staticConfigPath, 'app.json must exist');
|
||||
|
||||
let packageName: string;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/android-package')
|
||||
)}`
|
||||
);
|
||||
|
||||
if (nonInteractive) {
|
||||
let suggestedAndroidApplicationId: string | undefined;
|
||||
try {
|
||||
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
} catch {
|
||||
// If we can't get a suggestion, we'll throw the non-interactive error below
|
||||
}
|
||||
if (!suggestedAndroidApplicationId) {
|
||||
throw new Error(
|
||||
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#package'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
packageName = suggestedAndroidApplicationId;
|
||||
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
|
||||
} else {
|
||||
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/android-package')
|
||||
)}`
|
||||
);
|
||||
|
||||
const result = await promptAsync({
|
||||
name: 'packageName',
|
||||
type: 'text',
|
||||
message: `What would you like your Android application id to be?`,
|
||||
initial: suggestedAndroidApplicationId,
|
||||
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
|
||||
});
|
||||
packageName = result.packageName;
|
||||
}
|
||||
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
const { packageName } = await promptAsync({
|
||||
name: 'packageName',
|
||||
type: 'text',
|
||||
message: `What would you like your Android application id to be?`,
|
||||
initial: suggestedAndroidApplicationId,
|
||||
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
|
||||
});
|
||||
|
||||
const rawStaticConfig = readAppJson(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
|
||||
@@ -115,81 +115,20 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
|
||||
})
|
||||
).rejects.toThrowError(/we can't update this file programmatically/);
|
||||
});
|
||||
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
|
||||
it('throws an error in non-interactive mode', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
'app.json': '{ "blah": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp' } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.jester.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
|
||||
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
|
||||
expect(appJson).toMatchObject({
|
||||
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('com.example.myapp');
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'app.json': '{ "expo": {} }',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
|
||||
await expect(
|
||||
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
|
||||
graphqlClient,
|
||||
projectDir: '/app',
|
||||
projectId: '1234',
|
||||
exp: { slug: '' } as any,
|
||||
exp: {} as any,
|
||||
vcsClient,
|
||||
nonInteractive: true,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,23 @@ import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../exe
|
||||
jest.mock('fs');
|
||||
jest.mock('../../../prompts');
|
||||
|
||||
// @expo/require-utils uses require() to load JS configs, which bypasses the
|
||||
// mocked fs. Override loadModuleSync to read from the mocked fs and evaluate.
|
||||
jest.mock('@expo/require-utils', () => {
|
||||
const actual = jest.requireActual('@expo/require-utils');
|
||||
return {
|
||||
...actual,
|
||||
loadModuleSync: (filename: string) => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync(filename, 'utf-8');
|
||||
const mod = { exports: {} as any };
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('module', 'exports', content)(mod, mod.exports);
|
||||
return mod.exports;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
vol.reset();
|
||||
|
||||
@@ -137,8 +137,15 @@ async function configureBundleIdentifierAsync({
|
||||
exp: ExpoConfig;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<string> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
// we can't automatically update app.config.js
|
||||
if (paths.dynamicConfigPath) {
|
||||
throw new Error(
|
||||
@@ -148,51 +155,26 @@ async function configureBundleIdentifierAsync({
|
||||
|
||||
assert(paths.staticConfigPath, 'app.json must exist');
|
||||
|
||||
let bundleIdentifier: string;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/bundle-identifier')
|
||||
)}`
|
||||
);
|
||||
|
||||
if (nonInteractive) {
|
||||
let suggestedBundleIdentifier: string | undefined;
|
||||
try {
|
||||
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
} catch {
|
||||
// If we can't get a suggestion, we'll throw the non-interactive error below
|
||||
}
|
||||
if (!suggestedBundleIdentifier) {
|
||||
throw new Error(
|
||||
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
bundleIdentifier = suggestedBundleIdentifier;
|
||||
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
|
||||
} else {
|
||||
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
|
||||
learnMore('https://expo.fyi/bundle-identifier')
|
||||
)}`
|
||||
);
|
||||
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
|
||||
graphqlClient,
|
||||
exp,
|
||||
projectId
|
||||
);
|
||||
|
||||
const result = await promptAsync({
|
||||
name: 'bundleIdentifier',
|
||||
type: 'text',
|
||||
message: `What would you like your iOS bundle identifier to be?`,
|
||||
initial: suggestedBundleIdentifier,
|
||||
validate: value =>
|
||||
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
|
||||
});
|
||||
bundleIdentifier = result.bundleIdentifier;
|
||||
}
|
||||
const { bundleIdentifier } = await promptAsync({
|
||||
name: 'bundleIdentifier',
|
||||
type: 'text',
|
||||
message: `What would you like your iOS bundle identifier to be?`,
|
||||
initial: suggestedBundleIdentifier,
|
||||
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
|
||||
});
|
||||
|
||||
const rawStaticConfig = readAppJson(paths.staticConfigPath);
|
||||
rawStaticConfig.expo = {
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function hasIgnoredIosProjectAsync(
|
||||
try {
|
||||
const pbxProjectPath = IOSConfig.Paths.getPBXProjectPath(projectDir);
|
||||
return await vcsClient.isFileIgnoredAsync(path.relative(vcsRootPath, pbxProjectPath));
|
||||
} finally {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,16 @@ 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,6 +219,38 @@ 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', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,11 +26,11 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.4.0",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/build-tools": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"bunyan": "^1.8.15",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.4.0",
|
||||
"version": "18.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,18 +19,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.4.0",
|
||||
"@expo/config-plugins": "8.0.8",
|
||||
"@expo/config-types": "52.0.1",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/build-tools": "18.5.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.4.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"@sentry/node": "7.77.0",
|
||||
"check-disk-space": "3.4.0",
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user