mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ac890d2a | ||
|
|
1f4260cc47 |
No files matched your search
@@ -10,7 +10,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
- [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))
|
||||
- [eas-cli] Allow up to 3 video previews per device type in `metadata:push` and `metadata:pull`. Each preview type now accepts an array of preview configs in addition to the existing single-config form (which remains supported for backwards compatibility). ([@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
},
|
||||
"previews": {
|
||||
"type": "object",
|
||||
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129). Each device type accepts a single preview config (string or object) or an array of up to 3 preview configs.",
|
||||
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -348,31 +348,6 @@
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -710,6 +685,10 @@
|
||||
"markdownDescription": "Additional information about your app that can help during the review process.\n\n**Do not include demo account details.**",
|
||||
"minLength": 2,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"attachment": {
|
||||
"type": "string",
|
||||
"description": "Relative path (from project root) to a file shared with the App Store review team. The file is uploaded as an App Store review attachment and only re-uploaded when its content changes."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -216,3 +216,35 @@ describe('getAppClip', () => {
|
||||
expect(reader.getAppClipLocalizedInfo('de-DE')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReviewAttachment', () => {
|
||||
it('returns null when review is not configured', () => {
|
||||
const reader = new AppleConfigReader({});
|
||||
expect(reader.getReviewAttachment()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when review has no attachment', () => {
|
||||
const reader = new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
},
|
||||
});
|
||||
expect(reader.getReviewAttachment()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the configured attachment path', () => {
|
||||
const reader = new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
});
|
||||
expect(reader.getReviewAttachment()).toBe('store/apple/review-attachment/demo.pdf');
|
||||
});
|
||||
});
|
||||
@@ -345,6 +345,30 @@ describe('setReviewDetails', () => {
|
||||
demoRequired: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an existing review attachment when re-setting review details', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setReviewDetails(nameOnlyReviewDetails);
|
||||
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
|
||||
writer.setReviewDetails(nameAndDemoReviewDetails);
|
||||
expect(writer.schema.review?.attachment).toBe('store/apple/review-attachment/demo.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setReviewAttachment', () => {
|
||||
it('initializes a review block with the attachment path', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
|
||||
expect(writer.schema.review?.attachment).toBe('store/apple/review-attachment/demo.pdf');
|
||||
});
|
||||
|
||||
it('clears the attachment when null is passed', () => {
|
||||
const writer = new AppleConfigWriter();
|
||||
writer.setReviewDetails(nameOnlyReviewDetails);
|
||||
writer.setReviewAttachment('store/apple/review-attachment/demo.pdf');
|
||||
writer.setReviewAttachment(null);
|
||||
expect(writer.schema.review?.attachment).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppClipDefaultExperience', () => {
|
||||
|
||||
@@ -224,10 +224,14 @@ export class AppleConfigReader {
|
||||
demoAccountPassword: review.demoPassword,
|
||||
demoAccountRequired: review.demoRequired,
|
||||
notes: review.notes,
|
||||
// TODO: add attachment
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the configured review attachment path (relative to the project root), or null if not set. */
|
||||
public getReviewAttachment(): string | null {
|
||||
return this.schema.review?.attachment ?? null;
|
||||
}
|
||||
|
||||
/** Get screenshots configuration for a specific locale */
|
||||
public getScreenshots(locale: string): AppleScreenshots | null {
|
||||
return this.schema.info?.[locale]?.screenshots ?? null;
|
||||
|
||||
@@ -190,6 +190,7 @@ export class AppleConfigWriter {
|
||||
}
|
||||
|
||||
public setReviewDetails(attributes: AttributesOf<AppStoreReviewDetail>): void {
|
||||
const existingAttachment = this.schema.review?.attachment;
|
||||
this.schema.review = {
|
||||
firstName: attributes.contactFirstName ?? '',
|
||||
lastName: attributes.contactLastName ?? '',
|
||||
@@ -199,10 +200,25 @@ export class AppleConfigWriter {
|
||||
demoPassword: optional(attributes.demoAccountPassword),
|
||||
demoRequired: optional(attributes.demoAccountRequired),
|
||||
notes: optional(attributes.notes),
|
||||
// TODO: add attachment
|
||||
attachment: existingAttachment,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the review attachment path (relative to project root) on the review block. */
|
||||
public setReviewAttachment(attachmentPath: string | null): void {
|
||||
this.schema.review = this.schema.review ?? {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
};
|
||||
if (attachmentPath) {
|
||||
this.schema.review.attachment = attachmentPath;
|
||||
} else {
|
||||
delete this.schema.review.attachment;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set screenshots for a specific locale */
|
||||
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
|
||||
this.schema.info = this.schema.info ?? {};
|
||||
@@ -211,42 +227,11 @@ export class AppleConfigWriter {
|
||||
Object.keys(screenshots).length > 0 ? screenshots : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video previews for a specific locale.
|
||||
*
|
||||
* Accepts either the legacy single-config form or the multi-preview array form
|
||||
* for each preview type. When an array contains exactly one entry, it is
|
||||
* unwrapped to the legacy single-object form for stable, minimal config diffs.
|
||||
*/
|
||||
/** 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: '' };
|
||||
|
||||
if (Object.keys(previews).length === 0) {
|
||||
this.schema.info[locale].previews = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized: ApplePreviews = {};
|
||||
for (const [previewType, value] of Object.entries(previews)) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (value.length === 1) {
|
||||
normalized[previewType as keyof ApplePreviews] = value[0];
|
||||
} else {
|
||||
normalized[previewType as keyof ApplePreviews] = value;
|
||||
}
|
||||
} else {
|
||||
normalized[previewType as keyof ApplePreviews] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.schema.info[locale].previews = Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
|
||||
}
|
||||
|
||||
/** Set the App Clip default experience attributes (action, releaseWithAppStoreVersion). */
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AppStoreReviewAttachment, AppStoreReviewDetail, AppStoreVersion } from '@expo/apple-utils';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import nock from 'nock';
|
||||
|
||||
import { requestContext } from './fixtures/requestContext';
|
||||
@@ -9,6 +11,10 @@ import { AppReviewDetailTask } from '../app-review-detail';
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
|
||||
function md5(value: Buffer | string): string {
|
||||
return crypto.createHash('md5').update(value).digest('hex');
|
||||
}
|
||||
|
||||
describe(AppReviewDetailTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('loads review details from app version', async () => {
|
||||
@@ -51,6 +57,78 @@ describe(AppReviewDetailTask, () => {
|
||||
|
||||
expect(writer.setReviewDetails).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('records the review attachment placeholder path when one exists', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const attachment = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
|
||||
fileName: 'demo-instructions.pdf',
|
||||
fileSize: 1024,
|
||||
sourceFileChecksum: 'abc123',
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
|
||||
appStoreReviewAttachments: [attachment],
|
||||
} as any);
|
||||
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
await new AppReviewDetailTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { reviewDetail, projectDir: '/test/project' } as any,
|
||||
});
|
||||
|
||||
expect(writer.setReviewDetails).toBeCalledWith(reviewDetail.attributes);
|
||||
expect(writer.setReviewAttachment).toBeCalledWith(
|
||||
'store/apple/review-attachment/demo-instructions.pdf'
|
||||
);
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs an info message when attachment exists remotely but not on disk', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const attachment = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
|
||||
fileName: 'demo-instructions.pdf',
|
||||
fileSize: 1024,
|
||||
sourceFileChecksum: 'abc123',
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
|
||||
appStoreReviewAttachments: [attachment],
|
||||
} as any);
|
||||
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
const mkdirSpy = jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
|
||||
|
||||
await new AppReviewDetailTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { reviewDetail, projectDir: '/test/project' } as any,
|
||||
});
|
||||
|
||||
// Should still record the path in config for round-trip support
|
||||
expect(writer.setReviewAttachment).toBeCalledWith(
|
||||
'store/apple/review-attachment/demo-instructions.pdf'
|
||||
);
|
||||
// Should NOT create directories on disk (no placeholder file)
|
||||
expect(mkdirSpy).not.toBeCalled();
|
||||
|
||||
existsSyncSpy.mockRestore();
|
||||
mkdirSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not call setReviewAttachment when there is no attachment', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {
|
||||
appStoreReviewAttachments: [],
|
||||
} as any);
|
||||
|
||||
await new AppReviewDetailTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { reviewDetail, projectDir: '/test/project' } as any,
|
||||
});
|
||||
|
||||
expect(writer.setReviewAttachment).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsync', () => {
|
||||
@@ -141,5 +219,182 @@ describe(AppReviewDetailTask, () => {
|
||||
expect(createReviewDetailScope.isDone()).toBeTruthy();
|
||||
expect(updateReviewDetailScope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('attachment sync', () => {
|
||||
const fileBytes = Buffer.from('file-bytes');
|
||||
const fileChecksum = md5(fileBytes);
|
||||
|
||||
let existsSyncSpy: jest.SpyInstance;
|
||||
let readFileSyncSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
readFileSyncSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue(fileBytes as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSyncSpy.mockRestore();
|
||||
readFileSyncSpy.mockRestore();
|
||||
jest.restoreAllMocks();
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('uploads a new attachment when none exists', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(uploadSpy).toBeCalledTimes(1);
|
||||
expect(uploadSpy).toBeCalledWith(
|
||||
expect.stringContaining('store/apple/review-attachment/demo.pdf')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips upload when checksum matches existing attachment', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
const existing = new AppStoreReviewAttachment(requestContext, 'ATTACH_1', {
|
||||
fileName: 'demo.pdf',
|
||||
fileSize: fileBytes.length,
|
||||
sourceFileChecksum: fileChecksum,
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [existing] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
const deleteSpy = jest.spyOn(existing, 'deleteAsync').mockResolvedValue(undefined);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(uploadSpy).not.toBeCalled();
|
||||
expect(deleteSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('replaces stale attachment when checksum differs', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
const existing = new AppStoreReviewAttachment(requestContext, 'ATTACH_OLD', {
|
||||
fileName: 'old.pdf',
|
||||
fileSize: 5,
|
||||
sourceFileChecksum: 'different-checksum',
|
||||
uploaded: true,
|
||||
} as any);
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [existing] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
const deleteSpy = jest.spyOn(existing, 'deleteAsync').mockResolvedValue(undefined);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/demo.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(deleteSpy).toBeCalledTimes(1);
|
||||
expect(uploadSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns and skips when local attachment file is missing', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
|
||||
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
|
||||
|
||||
existsSyncSpy.mockReturnValue(false);
|
||||
|
||||
const reviewDetail = new AppStoreReviewDetail(
|
||||
requestContext,
|
||||
'APP_STORE_REVIEW_DETAILS_1',
|
||||
{ appStoreReviewAttachments: [] } as any
|
||||
);
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppStoreReviewDetail.prototype, 'uploadAttachmentAsync')
|
||||
.mockResolvedValue({} as any);
|
||||
|
||||
await new AppReviewDetailTask().uploadAsync({
|
||||
config: new AppleConfigReader({
|
||||
review: {
|
||||
firstName: 'Evan',
|
||||
lastName: 'Bacon',
|
||||
email: '[email protected]',
|
||||
phone: '+1 555 555 5555',
|
||||
attachment: 'store/apple/review-attachment/missing.pdf',
|
||||
},
|
||||
}),
|
||||
context: {
|
||||
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
|
||||
reviewDetail,
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(uploadSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -354,17 +354,6 @@ describe(PreviewsTask, () => {
|
||||
// Mock AppPreview.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
|
||||
|
||||
// Mock AppPreviewSet.infoAsync for the reorder step
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [newPreview],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
@@ -386,214 +375,6 @@ describe(PreviewsTask, () => {
|
||||
waitForProcessing: true,
|
||||
})
|
||||
);
|
||||
// Single preview, current order matches: reorder should be skipped.
|
||||
expect(reorderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads multiple previews when configured as an array', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: [
|
||||
'./previews/intro.mp4',
|
||||
{ path: './previews/features.mp4', previewFrameTimeCode: '00:02:00' },
|
||||
'./previews/outro.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const intro = new AppPreview(requestContext, 'NEW_PV_INTRO', {
|
||||
fileName: 'intro.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
const features = new AppPreview(requestContext, 'NEW_PV_FEATURES', {
|
||||
fileName: 'features.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: '00:02:00',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
const outro = new AppPreview(requestContext, 'NEW_PV_OUTRO', {
|
||||
fileName: 'outro.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.post(`/v1/${AppPreviewSet.type}`)
|
||||
.reply(201, {
|
||||
data: {
|
||||
id: 'NEW_PSET_1',
|
||||
type: AppPreviewSet.type,
|
||||
attributes: {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const uploadSpy = jest
|
||||
.spyOn(AppPreview, 'uploadAsync')
|
||||
.mockResolvedValueOnce(intro)
|
||||
.mockResolvedValueOnce(features)
|
||||
.mockResolvedValueOnce(outro);
|
||||
|
||||
// After upload Apple returns the previews in upload order, which already matches config.
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [intro, features, outro],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
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(uploadSpy).toHaveBeenCalledTimes(3);
|
||||
expect(uploadSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
requestContext,
|
||||
expect.objectContaining({ filePath: '/test/project/previews/intro.mp4' })
|
||||
);
|
||||
expect(uploadSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
requestContext,
|
||||
expect.objectContaining({
|
||||
filePath: '/test/project/previews/features.mp4',
|
||||
previewFrameTimeCode: '00:02:00',
|
||||
})
|
||||
);
|
||||
expect(uploadSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
requestContext,
|
||||
expect.objectContaining({ filePath: '/test/project/previews/outro.mp4' })
|
||||
);
|
||||
// Order already matches config order, no reorder call expected.
|
||||
expect(reorderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reorders previews when ASC order differs from config order', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: ['./previews/a.mp4', './previews/b.mp4'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
// Both previews already exist (matching filename + size), so no upload happens.
|
||||
const previewA = new AppPreview(requestContext, 'PV_A', {
|
||||
fileName: 'a.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
const previewB = new AppPreview(requestContext, 'PV_B', {
|
||||
fileName: 'b.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any);
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
// ASC reports them in [b, a] order — config wants [a, b].
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [previewB, previewA],
|
||||
} as any)
|
||||
);
|
||||
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(previewA);
|
||||
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [previewB, previewA],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', previewTypeMap);
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(AppPreview.uploadAsync).not.toHaveBeenCalled();
|
||||
expect(reorderMock).toHaveBeenCalledWith({ appPreviews: ['PV_A', 'PV_B'] });
|
||||
});
|
||||
|
||||
it('skips a preview type whose array is empty', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
IPHONE_67: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
jest.spyOn(AppPreview, 'uploadAsync');
|
||||
|
||||
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(AppPreview.uploadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads new preview with object config including previewFrameTimeCode', async () => {
|
||||
@@ -639,16 +420,6 @@ describe(PreviewsTask, () => {
|
||||
// Mock AppPreview.uploadAsync to avoid real file access
|
||||
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
|
||||
|
||||
jest.spyOn(AppPreviewSet, 'infoAsync').mockResolvedValue(
|
||||
new AppPreviewSet(requestContext, 'NEW_PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: [newPreview],
|
||||
} as any)
|
||||
);
|
||||
|
||||
const reorderMock = jest.fn().mockResolvedValue([]);
|
||||
AppPreviewSet.prototype.reorderPreviewsAsync = reorderMock;
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
@@ -673,67 +444,4 @@ describe(PreviewsTask, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsync (multi-preview)', () => {
|
||||
it('emits an array when there are multiple previews in a set', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
const previews = [
|
||||
new AppPreview(requestContext, 'PV_1', {
|
||||
fileName: 'intro.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: null,
|
||||
videoUrl: 'https://example.com/intro.mp4',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any),
|
||||
new AppPreview(requestContext, 'PV_2', {
|
||||
fileName: 'features.mp4',
|
||||
fileSize: 10240,
|
||||
previewFrameTimeCode: '00:01:00',
|
||||
videoUrl: 'https://example.com/features.mp4',
|
||||
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
|
||||
} as any),
|
||||
];
|
||||
|
||||
jest.spyOn(previews[0], 'getVideoUrl').mockReturnValue('https://example.com/intro.mp4');
|
||||
jest.spyOn(previews[1], 'getVideoUrl').mockReturnValue('https://example.com/features.mp4');
|
||||
|
||||
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
|
||||
previewTypeMap.set(
|
||||
PreviewType.IPHONE_67,
|
||||
new AppPreviewSet(requestContext, 'PSET_1', {
|
||||
previewType: PreviewType.IPHONE_67,
|
||||
appPreviews: previews,
|
||||
} as any)
|
||||
);
|
||||
|
||||
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: new Map([['en-US', previewTypeMap]]),
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(writer.setPreviews).toBeCalledWith('en-US', {
|
||||
[PreviewType.IPHONE_67]: [
|
||||
'store/apple/preview/en-US/IPHONE_67/intro.mp4',
|
||||
{
|
||||
path: 'store/apple/preview/en-US/IPHONE_67/features.mp4',
|
||||
previewFrameTimeCode: '00:01:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { AppStoreReviewDetail } from '@expo/apple-utils';
|
||||
import { AppStoreReviewAttachment, AppStoreReviewDetail } from '@expo/apple-utils';
|
||||
import chalk from 'chalk';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { logAsync } from '../../utils/log';
|
||||
@@ -10,6 +13,14 @@ export type AppReviewData = {
|
||||
reviewDetail: AppStoreReviewDetail;
|
||||
};
|
||||
|
||||
/** Default directory (relative to project root) for downloaded review attachments. */
|
||||
const REVIEW_ATTACHMENT_DIR = path.join('store', 'apple', 'review-attachment');
|
||||
|
||||
function md5OfFile(absolutePath: string): string {
|
||||
const bytes = fs.readFileSync(absolutePath);
|
||||
return crypto.createHash('md5').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
/** Handle all contact, demo account, or instruction info that are required for the App Store review team. */
|
||||
export class AppReviewDetailTask extends AppleTask {
|
||||
public name = (): string => 'app review detail';
|
||||
@@ -22,9 +33,41 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
if (context.reviewDetail) {
|
||||
config.setReviewDetails(context.reviewDetail.attributes);
|
||||
if (!context.reviewDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.setReviewDetails(context.reviewDetail.attributes);
|
||||
|
||||
// ASC limitation — see expo/third-party#146. AppStoreReviewAttachment has no
|
||||
// download URL (contrast with AppScreenshot.imageAsset.templateUrl). The API
|
||||
// only exposes fileName, fileSize, sourceFileChecksum, uploadOperations, and
|
||||
// assetDeliveryState. We record the expected path in the config so that the
|
||||
// pull -> push round-trip preserves the attachment entry, but we cannot
|
||||
// download the actual file bytes.
|
||||
const attachments = context.reviewDetail.attributes.appStoreReviewAttachments ?? [];
|
||||
const attachment = attachments[0];
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = attachment.attributes.fileName || 'attachment';
|
||||
const relativePath = path.join(REVIEW_ATTACHMENT_DIR, fileName);
|
||||
const absolutePath = path.resolve(context.projectDir, relativePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.log(
|
||||
chalk`{yellow Review attachment "${chalk.bold(
|
||||
fileName
|
||||
)}" exists in App Store Connect but cannot be downloaded.}\n` +
|
||||
chalk` App Store Connect does not expose a download URL for review attachments\n` +
|
||||
chalk` (unlike screenshots which have {dim imageAsset.templateUrl}). Only {dim fileName},\n` +
|
||||
chalk` {dim fileSize}, and {dim sourceFileChecksum} are available from the API.\n` +
|
||||
chalk` To keep the attachment in sync, place the file at: {bold ${relativePath}}`
|
||||
);
|
||||
}
|
||||
|
||||
config.setReviewAttachment(relativePath);
|
||||
}
|
||||
|
||||
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
|
||||
@@ -54,10 +97,82 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
);
|
||||
}
|
||||
|
||||
// Capture existing attachments before the patch response overwrites them. The
|
||||
// PATCH response doesn't include the related `appStoreReviewAttachments`, so
|
||||
// we need to reuse the version we fetched in `prepareAsync` to make a
|
||||
// checksum-based skip decision below.
|
||||
const existingAttachments = context.reviewDetail.attributes.appStoreReviewAttachments ?? [];
|
||||
|
||||
context.reviewDetail = await logAsync(() => context.reviewDetail.updateAsync(reviewDetail), {
|
||||
pending: `Updating store review details for ${chalk.bold(versionString)}...`,
|
||||
success: `Updated store review details for ${chalk.bold(versionString)}`,
|
||||
failure: `Failed updating store review details for ${chalk.bold(versionString)}`,
|
||||
});
|
||||
|
||||
await this.syncAttachmentAsync({
|
||||
attachmentPath: config.getReviewAttachment(),
|
||||
reviewDetail: context.reviewDetail,
|
||||
existingAttachments,
|
||||
projectDir: context.projectDir,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the App Store review attachment.
|
||||
* - When no attachment is configured, do nothing (existing remote attachments are left alone).
|
||||
* - When the configured file matches an existing attachment by checksum, skip re-upload.
|
||||
* - Otherwise delete any existing attachment(s) and upload the new file.
|
||||
*/
|
||||
private async syncAttachmentAsync({
|
||||
attachmentPath,
|
||||
reviewDetail,
|
||||
existingAttachments,
|
||||
projectDir,
|
||||
}: {
|
||||
attachmentPath: string | null;
|
||||
reviewDetail: AppStoreReviewDetail;
|
||||
existingAttachments: AppStoreReviewAttachment[];
|
||||
projectDir: string;
|
||||
}): Promise<void> {
|
||||
if (!attachmentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(projectDir, attachmentPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
Log.warn(chalk`{yellow Review attachment not found: ${absolutePath}}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = path.basename(absolutePath);
|
||||
const localChecksum = md5OfFile(absolutePath);
|
||||
|
||||
const matching = existingAttachments.find(
|
||||
attachment =>
|
||||
attachment.attributes.uploaded &&
|
||||
attachment.attributes.sourceFileChecksum === localChecksum
|
||||
);
|
||||
|
||||
if (matching) {
|
||||
Log.log(chalk`{dim - Skipped review attachment ${fileName}, already up to date}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any stale attachments before uploading the new one. App Store
|
||||
// Connect generally allows a single review attachment per review detail,
|
||||
// so we replace whatever is currently there.
|
||||
for (const attachment of existingAttachments) {
|
||||
await logAsync(() => attachment.deleteAsync(), {
|
||||
pending: `Deleting review attachment ${chalk.bold(attachment.attributes.fileName)}...`,
|
||||
success: `Deleted review attachment ${chalk.bold(attachment.attributes.fileName)}`,
|
||||
failure: `Failed deleting review attachment ${chalk.bold(attachment.attributes.fileName)}`,
|
||||
});
|
||||
}
|
||||
|
||||
await logAsync(() => reviewDetail.uploadAttachmentAsync(absolutePath), {
|
||||
pending: `Uploading review attachment ${chalk.bold(fileName)}...`,
|
||||
success: `Uploaded review attachment ${chalk.bold(fileName)}`,
|
||||
failure: `Failed uploading review attachment ${chalk.bold(fileName)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,35 +22,19 @@ export type PreviewsData = {
|
||||
previewSets: PreviewSetsMap;
|
||||
};
|
||||
|
||||
/** Normalized preview entry used internally by the upload pipeline. */
|
||||
type NormalizedPreviewConfig = {
|
||||
/**
|
||||
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
|
||||
*/
|
||||
function normalizePreviewConfig(config: ApplePreviewConfig): {
|
||||
path: string;
|
||||
previewFrameTimeCode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a single preview config into an object form with path and optional previewFrameTimeCode.
|
||||
*/
|
||||
function normalizePreviewConfig(config: ApplePreviewConfig): NormalizedPreviewConfig {
|
||||
} {
|
||||
if (typeof config === 'string') {
|
||||
return { path: config };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the value stored in `ApplePreviews[previewType]` (which can be a single
|
||||
* config OR an array of configs) into an array of normalized configs.
|
||||
*/
|
||||
function normalizePreviewConfigs(
|
||||
value: ApplePreviewConfig | ApplePreviewConfig[]
|
||||
): NormalizedPreviewConfig[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizePreviewConfig);
|
||||
}
|
||||
return [normalizePreviewConfig(value)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task for managing App Store video previews.
|
||||
* Downloads existing previews and uploads new ones based on store configuration.
|
||||
@@ -102,44 +86,34 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download all previews in this set, preserving order. Mirrors how
|
||||
// screenshots are handled: when a preview is in a broken state
|
||||
// (AWAITING_UPLOAD with no rendered videoUrl) 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 entries: ApplePreviewConfig[] = [];
|
||||
for (let i = 0; i < previewModels.length; i++) {
|
||||
const preview = previewModels[i];
|
||||
const downloaded = await downloadPreviewAsync(
|
||||
context.projectDir,
|
||||
localeCode,
|
||||
previewType,
|
||||
preview,
|
||||
i
|
||||
);
|
||||
// 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
|
||||
);
|
||||
|
||||
const fileName = preview.attributes.fileName || `${String(i + 1).padStart(2, '0')}.mp4`;
|
||||
const relativePath =
|
||||
downloaded || path.join('store', 'apple', 'preview', localeCode, previewType, fileName);
|
||||
// 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) {
|
||||
entries.push({
|
||||
path: relativePath,
|
||||
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
|
||||
});
|
||||
} else {
|
||||
entries.push(relativePath);
|
||||
}
|
||||
if (preview.attributes.previewFrameTimeCode) {
|
||||
previews[previewType] = {
|
||||
path: relativePath,
|
||||
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
|
||||
};
|
||||
} else {
|
||||
previews[previewType] = relativePath;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For backwards compatibility with existing single-preview configs,
|
||||
// emit the legacy single-object form when there's exactly one entry.
|
||||
previews[previewType] = entries.length === 1 ? entries[0] : entries;
|
||||
}
|
||||
|
||||
if (Object.keys(previews).length > 0) {
|
||||
@@ -172,13 +146,8 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [previewType, previewValue] of Object.entries(previews)) {
|
||||
if (!previewValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizePreviewConfigs(previewValue);
|
||||
if (normalized.length === 0) {
|
||||
for (const [previewType, previewConfig] of Object.entries(previews)) {
|
||||
if (!previewConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -186,7 +155,7 @@ export class PreviewsTask extends AppleTask {
|
||||
context.projectDir,
|
||||
localization,
|
||||
previewType as PreviewType,
|
||||
normalized,
|
||||
normalizePreviewConfig(previewConfig),
|
||||
context.previewSets.get(localeCode)
|
||||
);
|
||||
}
|
||||
@@ -195,20 +164,23 @@ export class PreviewsTask extends AppleTask {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a preview set against the configured previews.
|
||||
*
|
||||
* Mirrors the screenshots task: keeps unchanged previews (matched by filename
|
||||
* + size + completion state), uploads any new/changed previews, deletes
|
||||
* removed entries, and finally reorders the set to match config order.
|
||||
* Sync a preview set - upload new preview, delete old one if changed.
|
||||
*/
|
||||
async function syncPreviewSetAsync(
|
||||
projectDir: string,
|
||||
localization: AppStoreVersionLocalization,
|
||||
previewType: PreviewType,
|
||||
previewConfigs: NormalizedPreviewConfig[],
|
||||
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);
|
||||
@@ -229,127 +201,62 @@ async function syncPreviewSetAsync(
|
||||
|
||||
const existingPreviews = previewSet.attributes.appPreviews || [];
|
||||
|
||||
// Build a map of existing previews by filename for comparison.
|
||||
const existingByFilename = new Map<string, AppPreview>();
|
||||
// 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) {
|
||||
existingByFilename.set(preview.attributes.fileName, preview);
|
||||
}
|
||||
|
||||
// Track which previews to keep (by id), and which configs need uploading.
|
||||
const keptPreviewIds = new Set<string>();
|
||||
type UploadEntry = {
|
||||
absolutePath: string;
|
||||
fileName: string;
|
||||
previewFrameTimeCode?: string;
|
||||
};
|
||||
const toUpload: UploadEntry[] = [];
|
||||
|
||||
for (const previewConfig of previewConfigs) {
|
||||
const absolutePath = path.resolve(projectDir, previewConfig.path);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
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)
|
||||
) {
|
||||
keptPreviewIds.add(existing.id);
|
||||
existingByFilename.delete(fileName);
|
||||
|
||||
// If only the previewFrameTimeCode changed, patch it in place.
|
||||
if (
|
||||
previewConfig.previewFrameTimeCode &&
|
||||
existing.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
|
||||
) {
|
||||
await logAsync(
|
||||
() =>
|
||||
existing.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})`,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
toUpload.push({
|
||||
absolutePath,
|
||||
fileName,
|
||||
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete previews that are no longer in config.
|
||||
for (const preview of existingByFilename.values()) {
|
||||
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 previews.
|
||||
for (const entry of toUpload) {
|
||||
if (!fs.existsSync(entry.absolutePath)) {
|
||||
Log.warn(chalk`{yellow Video preview not found: ${entry.absolutePath}}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPreview = await logAsync(
|
||||
() =>
|
||||
AppPreview.uploadAsync(localization.context, {
|
||||
id: previewSet!.id,
|
||||
filePath: entry.absolutePath,
|
||||
waitForProcessing: true,
|
||||
previewFrameTimeCode: entry.previewFrameTimeCode,
|
||||
}),
|
||||
{
|
||||
pending: `Uploading video preview ${chalk.bold(entry.fileName)} (${locale})...`,
|
||||
success: `Uploaded video preview ${chalk.bold(entry.fileName)} (${locale})`,
|
||||
failure: `Failed uploading video preview ${chalk.bold(entry.fileName)} (${locale})`,
|
||||
}
|
||||
);
|
||||
|
||||
keptPreviewIds.add(newPreview.id);
|
||||
}
|
||||
|
||||
// Reorder previews to match config order (mirrors screenshots).
|
||||
if (keptPreviewIds.size > 0) {
|
||||
const refreshedSet = await AppPreviewSet.infoAsync(localization.context, {
|
||||
id: previewSet.id,
|
||||
});
|
||||
const refreshedPreviews = refreshedSet.attributes.appPreviews || [];
|
||||
const previewsByFilename = new Map<string, AppPreview>();
|
||||
for (const p of refreshedPreviews) {
|
||||
previewsByFilename.set(p.attributes.fileName, p);
|
||||
}
|
||||
|
||||
const orderedIds: string[] = [];
|
||||
for (const previewConfig of previewConfigs) {
|
||||
const fileName = path.basename(previewConfig.path);
|
||||
const preview = previewsByFilename.get(fileName);
|
||||
if (preview) {
|
||||
orderedIds.push(preview.id);
|
||||
}
|
||||
}
|
||||
|
||||
const currentIds = refreshedPreviews.map(p => p.id);
|
||||
if (
|
||||
orderedIds.length > 0 &&
|
||||
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
|
||||
) {
|
||||
await previewSet.reorderPreviewsAsync({ appPreviews: orderedIds });
|
||||
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})`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,16 +36,11 @@ export type ApplePreviewConfig =
|
||||
|
||||
/**
|
||||
* Video previews organized by display type.
|
||||
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config or an array of preview configs.
|
||||
* Apple supports up to 3 previews per device type. A single object/string is still
|
||||
* accepted for backwards compatibility with existing store configs.
|
||||
* 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" } }
|
||||
* @example { "IPHONE_67": ["./previews/intro.mp4", "./previews/features.mp4"] }
|
||||
*/
|
||||
export type ApplePreviews = Partial<
|
||||
Record<ApplePreviewType, ApplePreviewConfig | ApplePreviewConfig[]>
|
||||
>;
|
||||
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
|
||||
|
||||
export interface AppleMetadata {
|
||||
version?: string;
|
||||
@@ -133,5 +128,10 @@ export interface AppleReview {
|
||||
demoPassword?: string;
|
||||
demoRequired?: boolean;
|
||||
notes?: string;
|
||||
// attachment?: string;
|
||||
/**
|
||||
* Relative path (from project root) to a file shared with App Review.
|
||||
* This is uploaded as an `appStoreReviewAttachment` and re-uploaded only when the
|
||||
* file's checksum differs from the existing attachment.
|
||||
*/
|
||||
attachment?: string;
|
||||
}
|
||||
Reference in new issue
Block a user