diff --git a/.gitignore b/.gitignore index 76ce35c85..a593bc294 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,12 @@ drivers # Local tool and build outputs cpp/binarypatch/host/build/ .claude/ + +# E2E files written into the example apps during a run +Examples/*/App.ios.tsx +Examples/*/App.android.tsx +Examples/*/e2e-marker-assets-*/ +Examples/*/code-push.config.local.ts + +# Release history the CLI stages in the directory it is invoked from +codepush-release-history/ diff --git a/cli/commands/createHistoryCommand/createReleaseHistory.test.ts b/cli/commands/createHistoryCommand/createReleaseHistory.test.ts new file mode 100644 index 000000000..6f1ebe922 --- /dev/null +++ b/cli/commands/createHistoryCommand/createReleaseHistory.test.ts @@ -0,0 +1,133 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createReleaseHistory } from "./createReleaseHistory.js"; +import { updateReleaseHistory } from "../updateHistoryCommand/updateReleaseHistory.js"; + +/** + * Covers where the release history is staged before the config is handed it. + * + * The config receives a path rather than the history itself, so the command has to put the + * file somewhere first. Two releases of one app for different platforms are the same binary + * version, so a staging path derived from that version alone is the same path for both, and + * they overwrite each other's contents before either has read its own. + */ + +const BINARY_VERSION = "1.0.0"; + +let workingDir: string; +let previousCwd: string; + +beforeEach(() => { + previousCwd = process.cwd(); + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-history-staging-")); + process.chdir(workingDir); +}); + +afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(workingDir, { recursive: true, force: true }); + jest.restoreAllMocks(); +}); + +function stagedPath(platform: "ios" | "android"): string { + return path.join(workingDir, "codepush-release-history", platform, `${BINARY_VERSION}.json`); +} + +/** + * Turns the command's own exit into a throw, so a staging failure fails the test instead + * of taking the worker down with it. + */ +function failOnExit(): void { + jest.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`the command exited with code ${String(code)}`); + }); + jest.spyOn(console, "error").mockImplementation(() => undefined); + jest.spyOn(console, "log").mockImplementation(() => undefined); +} + +describe("staging the release history a config is handed", () => { + it("gives each platform writing at the same time a file of its own", async () => { + failOnExit(); + + const staged: Record = {}; + const setReleaseHistory = async ( + _binaryVersion: string, + jsonFilePath: string, + _releaseInfo: unknown, + platform: "ios" | "android", + ): Promise => { + // Yields once, which is all it takes for the other platform to reach its own + // write and its own clean-up while this one still holds only a path. + await Promise.resolve(); + staged[platform] = { jsonFilePath, contents: fs.readFileSync(jsonFilePath, "utf8") }; + }; + + await Promise.all([ + createReleaseHistory(BINARY_VERSION, setReleaseHistory, "ios", "RN0840"), + createReleaseHistory(BINARY_VERSION, setReleaseHistory, "android", "RN0840"), + ]); + + expect(staged.ios.jsonFilePath).not.toBe(staged.android.jsonFilePath); + }); + + it("hands each platform the history that platform released", async () => { + failOnExit(); + + const released: Record = { + ios: JSON.stringify({ [BINARY_VERSION]: { enabled: true, mandatory: false, downloadUrl: "", packageHash: "" }, "1.0.1": { enabled: true, mandatory: true, downloadUrl: "ios-url", packageHash: "ios-hash" } }), + android: JSON.stringify({ [BINARY_VERSION]: { enabled: true, mandatory: false, downloadUrl: "", packageHash: "" }, "1.0.1": { enabled: true, mandatory: true, downloadUrl: "android-url", packageHash: "android-hash" } }), + }; + + const staged: Record = {}; + const getReleaseHistory = async ( + _binaryVersion: string, + platform: "ios" | "android", + ) => JSON.parse(released[platform]); + const setReleaseHistory = async ( + _binaryVersion: string, + jsonFilePath: string, + _releaseInfo: unknown, + platform: "ios" | "android", + ): Promise => { + await Promise.resolve(); + staged[platform] = fs.readFileSync(jsonFilePath, "utf8"); + }; + + await Promise.all([ + updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "ios", "RN0840", undefined, false, undefined), + updateReleaseHistory("1.0.1", BINARY_VERSION, getReleaseHistory, setReleaseHistory, "android", "RN0840", undefined, false, undefined), + ]); + + expect(staged.ios).toContain("ios-url"); + expect(staged.ios).not.toContain("android-url"); + expect(staged.android).toContain("android-url"); + expect(staged.android).not.toContain("ios-url"); + }); + + it("takes the file away once the config has stored the history", async () => { + failOnExit(); + + const setReleaseHistory = async (): Promise => undefined; + await createReleaseHistory(BINARY_VERSION, setReleaseHistory, "ios", "RN0840"); + + expect(fs.existsSync(stagedPath("ios"))).toBe(false); + }); + + it("leaves the file behind when the config could not store the history", async () => { + failOnExit(); + + const setReleaseHistory = async (): Promise => { + throw new Error("the storage backend rejected the history"); + }; + + await expect(createReleaseHistory(BINARY_VERSION, setReleaseHistory, "ios", "RN0840")).rejects.toThrow( + "the command exited with code 1", + ); + + // What the release tried to store is worth reading back, so a failed release keeps + // its history at a path that is the same on every run. + expect(Object.keys(JSON.parse(fs.readFileSync(stagedPath("ios"), "utf8")))).toEqual([BINARY_VERSION]); + }); +}); diff --git a/cli/commands/createHistoryCommand/createReleaseHistory.ts b/cli/commands/createHistoryCommand/createReleaseHistory.ts index c4eb1f65c..5be1e839d 100644 --- a/cli/commands/createHistoryCommand/createReleaseHistory.ts +++ b/cli/commands/createHistoryCommand/createReleaseHistory.ts @@ -1,6 +1,5 @@ -import fs from "fs"; -import path from "path"; import type { CliConfigInterface, ReleaseHistoryInterface, ReleaseInfo } from "../../../typings/react-native-code-push.d.ts"; +import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js"; export async function createReleaseHistory( targetVersion: string, @@ -20,15 +19,8 @@ export async function createReleaseHistory( }; try { - const JSON_FILE_NAME = `${targetVersion}.json`; - const JSON_FILE_PATH = path.resolve(process.cwd(), JSON_FILE_NAME); - - console.log(`log: creating JSON file... ("${JSON_FILE_NAME}")\n`, JSON.stringify(INITIAL_HISTORY, null, 2)); - fs.writeFileSync(JSON_FILE_PATH, JSON.stringify(INITIAL_HISTORY)); - - await setReleaseHistory(targetVersion, JSON_FILE_PATH, INITIAL_HISTORY, platform, identifier) - - fs.unlinkSync(JSON_FILE_PATH); + await stageReleaseHistoryFile(targetVersion, INITIAL_HISTORY, platform, (jsonFilePath) => + setReleaseHistory(targetVersion, jsonFilePath, INITIAL_HISTORY, platform, identifier)); } catch (error) { console.error('Error occurred while creating new history:', error); process.exit(1) diff --git a/cli/commands/releaseCommand/addToReleaseHistory.ts b/cli/commands/releaseCommand/addToReleaseHistory.ts index 785e1be93..ebee51183 100644 --- a/cli/commands/releaseCommand/addToReleaseHistory.ts +++ b/cli/commands/releaseCommand/addToReleaseHistory.ts @@ -1,6 +1,5 @@ -import path from "path"; -import fs from "fs"; import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts"; +import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js"; export async function addToReleaseHistory( appVersion: string, @@ -51,15 +50,8 @@ export async function addToReleaseHistory( } try { - const JSON_FILE_NAME = `${binaryVersion}.json`; - const JSON_FILE_PATH = path.resolve(process.cwd(), JSON_FILE_NAME); - - console.log(`log: creating JSON file... ("${JSON_FILE_NAME}")\n`, JSON.stringify(newReleaseHistory, null, 2)); - fs.writeFileSync(JSON_FILE_PATH, JSON.stringify(newReleaseHistory)); - - await setReleaseHistory(binaryVersion, JSON_FILE_PATH, newReleaseHistory, platform, identifier) - - fs.unlinkSync(JSON_FILE_PATH); + await stageReleaseHistoryFile(binaryVersion, newReleaseHistory, platform, (jsonFilePath) => + setReleaseHistory(binaryVersion, jsonFilePath, newReleaseHistory, platform, identifier)); } catch (error) { console.error('Error occurred while updating history:', error); process.exit(1) diff --git a/cli/commands/releaseCommand/release.test.ts b/cli/commands/releaseCommand/release.test.ts index 1f3d5fc38..65324c68c 100644 --- a/cli/commands/releaseCommand/release.test.ts +++ b/cli/commands/releaseCommand/release.test.ts @@ -206,9 +206,9 @@ beforeEach(() => { afterEach(() => { jest.restoreAllMocks(); - // addToReleaseHistory writes its JSON next to the invocation, and only removes it - // when the history was stored successfully. - fs.rmSync(path.resolve(process.cwd(), `${BINARY_VERSION}.json`), { force: true }); + // addToReleaseHistory writes its JSON under the directory it was invoked in, and only + // removes it when the history was stored successfully. + fs.rmSync(path.resolve(process.cwd(), "codepush-release-history"), { recursive: true, force: true }); }); describe("release without --binary-bundle-path", () => { diff --git a/cli/commands/updateHistoryCommand/updateReleaseHistory.ts b/cli/commands/updateHistoryCommand/updateReleaseHistory.ts index 45942ac2d..6c1c98374 100644 --- a/cli/commands/updateHistoryCommand/updateReleaseHistory.ts +++ b/cli/commands/updateHistoryCommand/updateReleaseHistory.ts @@ -1,6 +1,5 @@ -import fs from "fs"; -import path from "path"; import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts"; +import { stageReleaseHistoryFile } from "../../functions/stageReleaseHistoryFile.js"; export async function updateReleaseHistory( appVersion: string, @@ -23,15 +22,8 @@ export async function updateReleaseHistory( if (typeof rollout === "number") updateInfo.rollout = rollout; try { - const JSON_FILE_NAME = `${binaryVersion}.json`; - const JSON_FILE_PATH = path.resolve(process.cwd(), JSON_FILE_NAME); - - console.log(`log: creating JSON file... ("${JSON_FILE_NAME}")\n`, JSON.stringify(releaseHistory, null, 2)); - fs.writeFileSync(JSON_FILE_PATH, JSON.stringify(releaseHistory)); - - await setReleaseHistory(binaryVersion, JSON_FILE_PATH, releaseHistory, platform, identifier) - - fs.unlinkSync(JSON_FILE_PATH); + await stageReleaseHistoryFile(binaryVersion, releaseHistory, platform, (jsonFilePath) => + setReleaseHistory(binaryVersion, jsonFilePath, releaseHistory, platform, identifier)); } catch (error) { console.error('Error occurred while updating history:', error); process.exit(1) diff --git a/cli/functions/stageReleaseHistoryFile.ts b/cli/functions/stageReleaseHistoryFile.ts new file mode 100644 index 000000000..778f4dd11 --- /dev/null +++ b/cli/functions/stageReleaseHistoryFile.ts @@ -0,0 +1,38 @@ +import fs from "fs"; +import path from "path"; +import type { ReleaseHistoryInterface } from "../../typings/react-native-code-push.d.ts"; + +const STAGING_DIR_NAME = 'codepush-release-history'; + +/** + * Writes the release history to a file for the config to read, and takes it away again + * once the config has stored it. + * + * Written under a directory of the platform's own rather than straight into the directory + * the command ran in. The name follows from the binary version alone, so two commands + * releasing the same version of one app for different platforms shared one path: whichever + * finished first took the file away while the other was still about to read it, and before + * that each had overwritten the other's contents. The name itself is kept as it was, + * because the name is part of what the config is handed. + * + * A file whose history was never stored is left where it was written, so a release that + * failed can be read back from a path that is the same on every run. + */ +export async function stageReleaseHistoryFile( + binaryVersion: string, + releaseHistory: ReleaseHistoryInterface, + platform: 'ios' | 'android', + store: (jsonFilePath: string) => Promise, +): Promise { + const jsonFileName = `${binaryVersion}.json`; + const stagingDir = path.resolve(process.cwd(), STAGING_DIR_NAME, platform); + const jsonFilePath = path.join(stagingDir, jsonFileName); + + fs.mkdirSync(stagingDir, { recursive: true }); + console.log(`log: creating JSON file... ("${jsonFileName}")\n`, JSON.stringify(releaseHistory, null, 2)); + fs.writeFileSync(jsonFilePath, JSON.stringify(releaseHistory)); + + const stored = await store(jsonFilePath); + fs.rmSync(jsonFilePath, { force: true }); + return stored; +} diff --git a/e2e/README.ko.md b/e2e/README.ko.md index eb2def965..8ae44b627 100644 --- a/e2e/README.ko.md +++ b/e2e/README.ko.md @@ -8,7 +8,7 @@ - **Maestro CLI (iOS)** — `curl -Ls "https://get.maestro.mobile.dev" | bash` - **maestro-runner (Android)** — `curl -fsSL https://open.devicelab.dev/install/maestro-runner | bash` - **iOS**: Xcode 및 부팅된 iOS 시뮬레이터 -- **Android**: Android SDK 및 실행 중인 에뮬레이터 +- **Android**: Android SDK 및 실행 중인 에뮬레이터 또는 연결된 기기 - `Examples/` 디렉토리에 설정된 예제 앱 (예: `RN0840`) ## 빠른 시작 @@ -19,6 +19,9 @@ npm run e2e -- --app RN0840 --platform ios # 빌드 생략, 테스트 플로우만 실행 npm run e2e -- --app RN0840 --platform ios --maestro-only + +# 부팅된 시뮬레이터와 연결된 안드로이드 기기에서 두 플랫폼을 동시에 실행 +npm run e2e -- --app RN0840 --platform both ``` ### Expo 예제 앱 @@ -31,12 +34,33 @@ npm run e2e -- --app Expo55 --framework expo --platform ios npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only ``` +### 두 플랫폼 동시 실행 + +`--platform both`는 하나의 체크아웃에서 두 플랫폼의 시나리오를 나란히 실행합니다. 각 플랫폼이 자기 +디바이스를 구동하므로 부팅된 iOS 시뮬레이터와 실행 중인 안드로이드 에뮬레이터 또는 연결된 기기가 +동시에 필요합니다. + +실행이 기록하는 자원은 모두 플랫폼별로 나뉘어 있습니다. 앱 엔트리(`App.ios.tsx`, `App.android.tsx`), +mock 서버 포트(18081, 18082)와 서빙 데이터, 아티팩트 기록, 앱의 `build/` 아래 CLI 출력 루트, 그리고 +CLI 호출마다 사용하는 임시 디렉터리가 각각 분리됩니다. 두 파이프라인 사이에 직렬화하는 구간은 없습니다. + +순차로 남겨 둔 작업은 두 가지입니다. 앱 디렉터리와 `node_modules`를 공유하는 네이티브 빌드, 그리고 +한 번만 수행하는 watchman 초기화와 라이브러리 동기화입니다. + +두 파이프라인이 같은 터미널에 출력합니다. 러너의 단계 배너와 mock 서버 로그에는 플랫폼이 붙지만, +그들이 띄우는 gradle, xcodebuild, maestro의 출력은 그대로 섞여 나옵니다. 한 플랫폼이 실패해도 다른 +플랫폼은 끝까지 실행하며, 두 결과를 모두 보고하고 어느 한쪽이라도 실패하면 0이 아닌 코드로 종료합니다. + +동시 실행은 벽시계 시간을 절반 가까이 줄이는 대신 한 머신의 CPU를 시뮬레이터 두 대와 번들러 두 개, +Maestro 드라이버 두 개가 나눠 씁니다. 이 부하에서 타이밍 민감 시나리오가 흔들리면 +`--exclude-timing-sensitive`와 `--retry-count`로 조절하세요. + ## CLI 옵션 | 옵션 | 필수 | 설명 | |---|---|---| | `--app ` | 예 | 예제 앱 디렉토리 이름 (예: `RN0840`) | -| `--platform ` | 예 | `ios` 또는 `android` | +| `--platform ` | 예 | `ios`, `android`, 또는 두 플랫폼을 나란히 실행하는 `both` | | `--framework ` | 아니오 | Expo 예제 앱인 경우 `expo` 지정 | | `--simulator ` | 아니오 | iOS 시뮬레이터 이름 (부팅된 시뮬레이터 자동 감지, 기본값 "iPhone 16") | | `--maestro-only` | 아니오 | 빌드 단계 생략, 테스트 플로우만 실행 | @@ -48,10 +72,10 @@ npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only ### Phase 1 — 기본 플로우 (`flows/`) -1. **설정 준비** — `App.tsx`를 로컬 mock 서버를 가리키도록 패치하고, `code-push.config.local.ts`를 앱 디렉토리에 복사합니다. +1. **설정 준비** — `App.tsx`를 읽어 해당 플랫폼의 로컬 mock 서버를 가리키는 `App..tsx`를 작성하고, `code-push.config.local.ts`를 앱 디렉토리에 복사합니다. Metro가 플랫폼 확장자를 일반 이름보다 먼저 해석하므로 `App.tsx` 자체는 수정하지 않으며, 두 플랫폼이 서로의 엔트리를 건드리지 않습니다. 2. **앱 빌드** — 예제 앱을 Release 모드로 빌드하여 시뮬레이터/에뮬레이터에 설치합니다. export 훅이 이 빌드 안에서 실행되므로, 훅이 내보낸 번들을 빌드된 앱 안의 번들과 비교하고 옆에 놓인 `binary-patch-base.json` 기록도 같은 해시와 바이너리 버전인지 확인합니다. 이 검사는 해당 플랫폼의 훅을 적용한 앱에서만 실행합니다(`android/app/build.gradle`의 `codepush-export.gradle`, Xcode 빌드 페이즈의 `export-embedded-bundle.sh`). 지금은 `RN0840`만 적용했고 나머지 앱은 로그를 남기고 건너뜁니다. 훅을 적용한 앱에서 export가 없거나 내용이 어긋나면 실행이 실패합니다. `--maestro-only` 실행은 빌드 산출물이 없으니 export를 찾지 못하면 마찬가지로 건너뜁니다. 3. **번들 준비** — `npx code-push release`로 릴리스 히스토리를 생성하고 v1.0.1을 번들링합니다. -4. **Mock 서버 시작** — 번들과 릴리스 히스토리 JSON을 서빙하는 로컬 HTTP 서버(포트 18081)를 시작합니다. +4. **Mock 서버 시작** — 번들과 릴리스 히스토리 JSON을 서빙하는 로컬 HTTP 서버를 시작합니다. iOS는 포트 18081, 안드로이드는 18082를 사용합니다. 5. **테스트 플로우 실행** — iOS는 Maestro, Android는 maestro-runner 사용: - `01-app-launch` — 앱 실행 및 UI 요소 존재 확인 - `02-restart-no-crash` — 재시작 탭 후 크래시 없음 확인 @@ -114,11 +138,11 @@ e2e/ ├── config.ts # 경로, 포트, 호스트 설정 ├── tsconfig.json ├── mock-server/ -│ └── server.ts # Express 정적 파일 서버 (포트 18081), 모든 요청 기록 +│ └── server.ts # 플랫폼별 Express 정적 파일 서버 (18081/18082), 모든 요청 기록 ├── templates/ │ └── code-push.config.local.ts # 파일시스템 기반 CodePush 설정 ├── helpers/ -│ ├── prepare-config.ts # App.tsx 패치(호스트, E2E 버튼, archive 결과 프로브), 설정 복사 +│ ├── prepare-config.ts # App..tsx 작성(호스트, E2E 버튼, archive 결과 프로브), 설정 복사 │ ├── prepare-bundle.ts # code-push CLI로 번들 생성 │ ├── build-app.ts # iOS/Android Release 빌드 │ ├── artifact-storage.ts # CLI가 번들과 릴리스 히스토리를 저장한 위치 검증 @@ -141,8 +165,10 @@ e2e/ ### Mock 서버 실제 CodePush 서버 대신, 로컬 Express 서버가 다음을 서빙합니다: -- **번들**: `mock-server/data/bundles/{platform}/{identifier}/full-bundle/{packageHash}`와 `mock-server/data/bundles/{platform}/{identifier}/{artifactType}/{targetBinaryVersion}/` -- **릴리스 히스토리**: `mock-server/data/histories/{platform}/{identifier}/{version}.json` +- **번들**: `mock-server/data/{platform}/bundles/{platform}/{identifier}/full-bundle/{packageHash}`와 `mock-server/data/{platform}/bundles/{platform}/{identifier}/{artifactType}/{targetBinaryVersion}/` +- **릴리스 히스토리**: `mock-server/data/{platform}/histories/{platform}/{identifier}/{version}.json` + +플랫폼마다 서빙 루트를 따로 두어서, 두 플랫폼을 함께 실행하더라도 한쪽이 시나리오 사이에 데이터를 비울 때 다른 쪽 데이터가 함께 지워지지 않습니다. `code-push.config.local.ts` 템플릿은 모든 CLI 작업(업로드, 히스토리 읽기/쓰기)을 로컬 파일시스템으로 라우팅하며, 앱의 `CODEPUSH_HOST`는 mock 서버를 가리키도록 패치됩니다. 업로더가 전달한 artifact metadata로 스토리지 키를 만들므로 archive 파일명에 의존하지 않습니다. @@ -152,11 +178,11 @@ e2e/ ### 릴리스 마커 -동일한 소스 코드로 여러 릴리스(예: v1.0.1과 v1.0.2)를 생성하면 번들 JavaScript의 해시가 동일해져 CodePush가 같은 업데이트로 인식합니다. 이를 방지하기 위해 러너는 각 릴리스 전에 `App.tsx`에 `console.log("E2E_MARKER_{version}")`를 주입합니다. 이 코드는 미니피케이션 후에도 유지되어 고유한 번들 해시를 생성합니다. +동일한 소스 코드로 여러 릴리스(예: v1.0.1과 v1.0.2)를 생성하면 번들 JavaScript의 해시가 동일해져 CodePush가 같은 업데이트로 인식합니다. 이를 방지하기 위해 러너는 각 릴리스 전에 `App..tsx`에 `console.log("E2E_MARKER_{version}")`를 주입합니다. 이 코드는 미니피케이션 후에도 유지되어 고유한 번들 해시를 생성합니다. ## 문제 해결 - **iOS 빌드 시 서명 오류**: setup 스크립트가 `SUPPORTED_PLATFORMS = iphonesimulator`를 설정하고 코드 서명을 비활성화합니다. `scripts/setupExampleApp`으로 예제 앱이 설정되었는지 확인하세요. - **Maestro/maestro-runner가 앱을 찾지 못함**: 실행 전에 시뮬레이터/에뮬레이터가 부팅되어 있는지 확인하세요. iOS의 경우 스크립트가 부팅된 시뮬레이터를 자동 감지합니다. - **Android 네트워크 오류**: Android 에뮬레이터는 호스트 머신의 localhost에 접근하기 위해 `10.0.2.2`를 사용합니다. 설정에서 자동으로 처리됩니다. adb로 연결한 실기기에는 이 별칭이 없으므로, 러너가 mock 서버 포트를 기기로 포워딩(`adb reverse`)하고 앱이 기기 자신의 localhost를 보도록 합니다. 두 기본값 모두 `E2E_ANDROID_MOCK_SERVER_HOST`로 덮어쓸 수 있습니다. -- **업데이트가 적용되지 않음**: Mock 서버가 실행 중인지(포트 18081), `mock-server/data/`에 예상되는 번들과 히스토리 파일이 있는지 확인하세요. +- **업데이트가 적용되지 않음**: Mock 서버가 실행 중인지(iOS는 18081, 안드로이드는 18082), `mock-server/data/{platform}/`에 예상되는 번들과 히스토리 파일이 있는지 확인하세요. diff --git a/e2e/README.md b/e2e/README.md index fe6f97768..9f1edb36b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -8,7 +8,7 @@ End-to-end tests for `react-native-code-push` using [Maestro](https://github.com - **Maestro CLI (iOS)** — `curl -Ls "https://get.maestro.mobile.dev" | bash` - **maestro-runner (Android)** — `curl -fsSL https://open.devicelab.dev/install/maestro-runner | bash` - **iOS**: Xcode with a booted iOS Simulator -- **Android**: Android SDK with a running emulator +- **Android**: Android SDK with a running emulator or a connected device - An example app set up under `Examples/` (e.g. `RN0840`) ## Quick Start @@ -19,6 +19,9 @@ npm run e2e -- --app RN0840 --platform ios # Skip build, run test flows only npm run e2e -- --app RN0840 --platform ios --maestro-only + +# Both platforms at once, on a booted simulator and a connected Android device +npm run e2e -- --app RN0840 --platform both ``` ### Expo Example App @@ -31,12 +34,35 @@ npm run e2e -- --app Expo55 --framework expo --platform ios npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only ``` +### Running Both Platforms + +`--platform both` runs the two platforms' scenarios side by side out of one checkout. It needs +a booted iOS Simulator and a running Android emulator or connected device at the same time, +because each platform drives its own. + +Everything a run writes is per platform: the app entry (`App.ios.tsx` / `App.android.tsx`), the +mock server port (18081 / 18082) and the data it serves, the artifact record, the CLI output +root under the app's `build/`, and the temp directory each CLI invocation runs in. Nothing is +serialized between the two pipelines. + +Two things stay sequential and up front: the native builds, which share the app directory and +its `node_modules`, and the one-time watchman reset and library sync. + +Both pipelines write to one terminal. The runner's own phase banners and the mock server lines +carry the platform they belong to; output from the processes they spawn — gradle, xcodebuild, +maestro — arrives as it is. A failing platform does not cut the other one short: both verdicts +are reported and the run exits non-zero if either failed. + +Running both halves the wall clock at the cost of sharing one machine's CPU between two +simulators, two bundlers and two Maestro drivers. If the timing-sensitive scenarios start +flaking under that load, `--exclude-timing-sensitive` and `--retry-count` are the levers. + ## CLI Options | Option | Required | Description | |---|---|---| | `--app ` | Yes | Example app directory name (e.g. `RN0840`) | -| `--platform ` | Yes | `ios` or `android` | +| `--platform ` | Yes | `ios`, `android`, or `both` to run the two platforms side by side | | `--framework ` | No | Use `expo` for Expo example apps | | `--simulator ` | No | iOS simulator name (auto-detects booted simulator, defaults to "iPhone 16") | | `--maestro-only` | No | Skip build step, only run test flows | @@ -48,10 +74,10 @@ The test runner (`e2e/run.ts`) executes these phases in order: ### Phase 1 — Basic Flows (`flows/`) -1. **Prepare config** — Patches `App.tsx` to point at a local mock server, copies `code-push.config.local.ts` to the app directory. +1. **Prepare config** — Writes `App..tsx` from `App.tsx`, pointed at that platform's local mock server, and copies `code-push.config.local.ts` to the app directory. Metro resolves the platform extension ahead of the plain name, so `App.tsx` itself is never modified and each platform has an entry the other cannot touch. 2. **Build app** — Builds the example app in Release mode and installs it on the simulator/emulator. The export hooks run inside that build, so the bundle they wrote out is then compared with the bundle inside the built app, and the `binary-patch-base.json` record beside it is checked against the same hash and binary version. The check runs only for an app that applies the hook for the platform being built (`codepush-export.gradle` in its `android/app/build.gradle`, `export-embedded-bundle.sh` as an Xcode build phase) — `RN0840` is the one that does today, and every other app is skipped with a log line. For an app that does apply it, a missing or mismatched export fails the run. A `--maestro-only` run builds nothing of its own, so it too is skipped when it finds no export. 3. **Prepare bundle** — Creates release history and bundles v1.0.1 using `npx code-push release`. -4. **Start mock server** — Starts a local HTTP server (port 18081) that serves bundles and release history JSON. +4. **Start mock server** — Starts a local HTTP server that serves bundles and release history JSON: port 18081 for iOS, 18082 for Android. 5. **Run test flows** — Uses Maestro on iOS and maestro-runner on Android: - `01-app-launch` — Verifies the app launches and UI elements are present. - `02-restart-no-crash` — Taps Restart, confirms app doesn't crash. @@ -114,11 +140,11 @@ e2e/ ├── config.ts # Paths, ports, host configuration ├── tsconfig.json ├── mock-server/ -│ └── server.ts # Express static file server (port 18081), records every request +│ └── server.ts # Express static file server per platform (18081/18082), records every request ├── templates/ │ └── code-push.config.local.ts # Filesystem-based CodePush config ├── helpers/ -│ ├── prepare-config.ts # Patches App.tsx (host, E2E buttons, archive result probe), copies config +│ ├── prepare-config.ts # Writes App..tsx (host, E2E buttons, archive result probe), copies config │ ├── prepare-bundle.ts # Runs code-push CLI to create bundles │ ├── build-app.ts # Builds iOS/Android in Release mode │ ├── artifact-storage.ts # Asserts where the CLI stored bundles and release histories @@ -141,8 +167,10 @@ e2e/ ### Mock Server Instead of a real CodePush server, tests use a local Express server that serves: -- **Bundles**: `mock-server/data/bundles/{platform}/{identifier}/full-bundle/{packageHash}` and `mock-server/data/bundles/{platform}/{identifier}/{artifactType}/{targetBinaryVersion}/` -- **Release history**: `mock-server/data/histories/{platform}/{identifier}/{version}.json` +- **Bundles**: `mock-server/data/{platform}/bundles/{platform}/{identifier}/full-bundle/{packageHash}` and `mock-server/data/{platform}/bundles/{platform}/{identifier}/{artifactType}/{targetBinaryVersion}/` +- **Release history**: `mock-server/data/{platform}/histories/{platform}/{identifier}/{version}.json` + +Each platform serves a root of its own so a run covering both can empty one platform's data between scenarios without touching the other's. The `code-push.config.local.ts` template routes all CLI operations (upload, history read/write) to this local filesystem, and the app's `CODEPUSH_HOST` is patched to point at the mock server. It uses the uploader's artifact metadata for the storage key, so its layout does not depend on archive filenames. @@ -152,11 +180,11 @@ When the config template is given `E2E_ARTIFACT_LOG_PATH`, it also records every ### Release Markers -When creating multiple releases with identical source code (e.g. v1.0.1 and v1.0.2), the bundled JavaScript would produce the same hash, causing CodePush to treat them as the same update. To avoid this, the runner injects `console.log("E2E_MARKER_{version}")` into `App.tsx` before each release, which survives minification and produces unique bundle hashes. +When creating multiple releases with identical source code (e.g. v1.0.1 and v1.0.2), the bundled JavaScript would produce the same hash, causing CodePush to treat them as the same update. To avoid this, the runner injects `console.log("E2E_MARKER_{version}")` into `App..tsx` before each release, which survives minification and produces unique bundle hashes. ## Troubleshooting - **Build fails with signing error (iOS)**: The setup script sets `SUPPORTED_PLATFORMS = iphonesimulator` and disables code signing. Make sure the example app was set up with `scripts/setupExampleApp`. - **Maestro/maestro-runner can't find the app**: Ensure the simulator/emulator is booted before running. For iOS, the script auto-detects the booted simulator. - **Android network error**: Android emulators use `10.0.2.2` to reach the host machine's localhost. This is handled automatically by the config. A phone connected over adb has no such alias, so the runner forwards the mock server port onto the device (`adb reverse`) and points the app at its own localhost instead. Set `E2E_ANDROID_MOCK_SERVER_HOST` to override either default. -- **Update not applying**: Check that the mock server is running (port 18081) and that `mock-server/data/` contains the expected bundle and history files. +- **Update not applying**: Check that the mock server is running (18081 for iOS, 18082 for Android) and that `mock-server/data/{platform}/` contains the expected bundle and history files. diff --git a/e2e/config.ts b/e2e/config.ts index f7fd9c7ff..acb0bbd89 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -1,8 +1,31 @@ +import os from "os"; import path from "path"; -export const MOCK_SERVER_PORT = 18081; export const EXAMPLES_DIR = path.resolve(__dirname, "../Examples"); -export const MOCK_DATA_DIR = path.resolve(__dirname, "mock-server/data"); + +/** + * Port the mock server of one platform listens on. + * + * Each platform serves its own artifacts, so a run that covers both needs a server each. + * The port follows from the platform rather than being picked when the server starts, + * because the app binary is built with the host it will ask. + */ +export function getMockServerPort(platform: "ios" | "android"): number { + return platform === "ios" ? 18081 : 18082; +} + +const MOCK_DATA_ROOT = path.resolve(__dirname, "mock-server/data"); + +/** + * The data one platform's mock server serves. + * + * Scenarios empty this between releases, and the artifacts of the two platforms are told + * apart by the same assertions either way, so each platform gets a root of its own rather + * than a shared one that a scenario would have to empty piece by piece. + */ +export function getMockDataDir(platform: "ios" | "android"): string { + return path.join(MOCK_DATA_ROOT, platform); +} /** * Scratch directory for files a run builds but never serves, such as the JS bundle @@ -15,15 +38,71 @@ export const WORK_DIR = path.resolve(__dirname, ".work"); * data directory so the record is not itself downloadable, and so wiping the mock data * between scenarios does not decide when the record is cleared. */ -export const ARTIFACT_LOG_PATH = path.join(WORK_DIR, "artifact-log.jsonl"); +export function getArtifactLogPath(platform: "ios" | "android"): string { + return path.join(WORK_DIR, `artifact-log-${platform}.jsonl`); +} + +/** + * Output root the CLI writes a platform's bundle and archives to, relative to the app. + * + * The bundle step empties this directory before it writes anything, so the two platforms + * need roots of their own. It stays under `build`, which the example apps already ignore. + */ +export function getCliOutputPath(platform: "ios" | "android"): string { + return path.join("build", platform); +} + +/** + * Temporary directory a platform's CLI invocations work in. + * + * The bundle step also clears `$TMPDIR/react-*`, which reaches every directory that + * pattern matches rather than only its own, so the two platforms are pointed at temporary + * directories of their own and that sweep stays inside the run that made it. Metro's cache + * follows the same variable, which leaves each platform bundling out of a cache of its own. + */ +export function getCliTempDir(platform: "ios" | "android"): string { + return path.join(os.tmpdir(), `codepush-e2e-${platform}`); +} export function getMockServerHost(platform: "ios" | "android"): string { const host = platform === "android" ? process.env.E2E_ANDROID_MOCK_SERVER_HOST ?? "10.0.2.2" : process.env.E2E_IOS_MOCK_SERVER_HOST ?? "localhost"; - return `http://${host}:${MOCK_SERVER_PORT}`; + return `http://${host}:${getMockServerPort(platform)}`; } export function getAppPath(appName: string): string { return path.join(EXAMPLES_DIR, appName); } + +/** The app entry as the example app ships it, which a run reads but never writes. */ +export function getAppSourceEntryPath(appPath: string): string { + return path.join(appPath, "App.tsx"); +} + +/** + * The app entry one platform's run rewrites. + * + * A run rewrites its entry once per release, so two platforms sharing `App.tsx` would + * overwrite each other's markers. Metro resolves a platform extension ahead of the plain + * name, so a file per platform gives each run an entry of its own, and leaves `App.tsx` + * itself as the source every entry is patched from. + */ +export function getAppEntryPath(appPath: string, platform: "ios" | "android"): string { + return path.join(appPath, `App.${platform}.tsx`); +} + +/** + * Directory holding the marker assets of one platform's releases. + * + * Both platforms write the same file for the same label, and a release clears its markers + * by emptying the directory, so the runs need one directory each for neither to delete an + * asset the other is about to bundle. + * + * The name avoids the `e2e-asset` prefix the marker files themselves carry. The asset + * assertions match archive entries on the characters both platforms keep, and a directory + * carrying that prefix would show up inside those entry names. + */ +export function getAssetMarkerDirName(platform: "ios" | "android"): string { + return `e2e-marker-assets-${platform}`; +} diff --git a/e2e/helpers/artifact-storage.ts b/e2e/helpers/artifact-storage.ts index cf0ce9302..8abd406a2 100644 --- a/e2e/helpers/artifact-storage.ts +++ b/e2e/helpers/artifact-storage.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { ARTIFACT_LOG_PATH, MOCK_DATA_DIR } from "../config"; +import { getArtifactLogPath, getMockDataDir } from "../config"; /** * What the CLI stored through the local config, read back from the record the config @@ -34,16 +34,16 @@ export interface StoredHistoryArtifact { export type StoredArtifact = StoredBundleArtifact | StoredHistoryArtifact; -export function clearArtifactLog(): void { - fs.rmSync(ARTIFACT_LOG_PATH, { force: true }); +export function clearArtifactLog(platform: "ios" | "android"): void { + fs.rmSync(getArtifactLogPath(platform), { force: true }); } -export function readArtifactLog(): StoredArtifact[] { - if (!fs.existsSync(ARTIFACT_LOG_PATH)) { +export function readArtifactLog(platform: "ios" | "android"): StoredArtifact[] { + if (!fs.existsSync(getArtifactLogPath(platform))) { return []; } - return fs.readFileSync(ARTIFACT_LOG_PATH, "utf8") + return fs.readFileSync(getArtifactLogPath(platform), "utf8") .split("\n") .filter((line) => line.trim().length > 0) .map((line) => JSON.parse(line) as StoredArtifact); @@ -56,8 +56,8 @@ export function readArtifactLog(): StoredArtifact[] { * @return the artifacts that were checked, so a caller can go on to assert something * about a specific one. */ -export function assertArtifactStorageLayout(scenario: string): StoredArtifact[] { - const artifacts = readArtifactLog(); +export function assertArtifactStorageLayout(scenario: string, platform: "ios" | "android"): StoredArtifact[] { + const artifacts = readArtifactLog(platform); if (artifacts.length === 0) { throw new Error(`${scenario}: no artifacts were stored`); } @@ -75,7 +75,7 @@ export function assertArtifactStorageLayout(scenario: string): StoredArtifact[] ); } - if (!fs.existsSync(path.join(MOCK_DATA_DIR, artifact.storedPath))) { + if (!fs.existsSync(path.join(getMockDataDir(platform), artifact.storedPath))) { throw new Error(`${scenario}: ${artifact.kind} is missing from the served data at "${artifact.storedPath}"`); } } diff --git a/e2e/helpers/asset-diff-phase.ts b/e2e/helpers/asset-diff-phase.ts index 2fde087f1..0ee25da2d 100644 --- a/e2e/helpers/asset-diff-phase.ts +++ b/e2e/helpers/asset-diff-phase.ts @@ -109,17 +109,17 @@ export async function runAssetDiffPhase(context: AssetDiffPhaseContext): Promise */ const runDiffScenario = (scenario: AssetDiffScenario) => context.withRetry(`run-maestro: asset diff (${scenario.name})`, async () => { - console.log(`\n=== [prepare-bundle: asset diff ${scenario.baseVersion} (${scenario.name})] ===`); + console.log(`\n=== [${platform}][prepare-bundle: asset diff ${scenario.baseVersion} (${scenario.name})] ===`); context.cleanMockData(); await releaseWithAssets(scenario.baseVersion, true); - startRecordingDownloads(); + startRecordingDownloads(platform); await context.runMaestro(installUpdateFlow, { RELEASE_LABEL: scenario.baseVersion }); - assertDownloadedArchives(`${scenario.name} — base install`, ["binary-patch"]); - await assertReportedArchiveResult(`${scenario.name} — base install`, "applied:binary-patch:binary-patch=applied"); + assertDownloadedArchives(`${scenario.name} — base install`, platform, ["binary-patch"]); + await assertReportedArchiveResult(`${scenario.name} — base install`, platform, "applied:binary-patch:binary-patch=applied"); - console.log(`\n=== [prepare-bundle: asset diff ${scenario.updateVersion} (${scenario.name})] ===`); + console.log(`\n=== [${platform}][prepare-bundle: asset diff ${scenario.updateVersion} (${scenario.name})] ===`); await releaseWithAssets(scenario.updateVersion, false); - assertArtifactStorageLayout(scenario.name); + assertArtifactStorageLayout(scenario.name, platform); assertReleaseOffersPatch(scenario.name, platform, releaseIdentifier, BINARY_VERSION, scenario.updateVersion); assertReleaseOffersDiff( @@ -138,13 +138,13 @@ export async function runAssetDiffPhase(context: AssetDiffPhaseContext): Promise scenario.breakDiff?.(); - startRecordingDownloads(); + startRecordingDownloads(platform); await context.runMaestro(updateFromInstalledFlow, { RELEASE_LABEL: scenario.updateVersion, BASE_RELEASE_LABEL: scenario.baseVersion, }); - assertDownloadedArchives(scenario.name, scenario.expectedDownloads); - await assertReportedArchiveResult(scenario.name, scenario.expectedArchiveResult); + assertDownloadedArchives(scenario.name, platform, scenario.expectedDownloads); + await assertReportedArchiveResult(scenario.name, platform, scenario.expectedArchiveResult); }); await runDiffScenario({ @@ -161,10 +161,10 @@ export async function runAssetDiffPhase(context: AssetDiffPhaseContext): Promise // must be passed over for the patch archive exactly as if it had never been published. const binaryClientScenario = "client on the binary passes over the diff and installs the patch"; await context.withRetry(`run-maestro: asset diff (${binaryClientScenario})`, async () => { - startRecordingDownloads(); + startRecordingDownloads(platform); await context.runMaestro(installUpdateFlow, { RELEASE_LABEL: "1.4.2" }); - assertDownloadedArchives(binaryClientScenario, ["binary-patch"]); - await assertReportedArchiveResult(binaryClientScenario, "applied:binary-patch:binary-patch=applied"); + assertDownloadedArchives(binaryClientScenario, platform, ["binary-patch"]); + await assertReportedArchiveResult(binaryClientScenario, platform, "applied:binary-patch:binary-patch=applied"); }); // The merge completes over the corrupted asset, and what catches it is the package diff --git a/e2e/helpers/binary-patch-fixtures.ts b/e2e/helpers/binary-patch-fixtures.ts index 205ccfdda..1b4f460e6 100644 --- a/e2e/helpers/binary-patch-fixtures.ts +++ b/e2e/helpers/binary-patch-fixtures.ts @@ -2,7 +2,7 @@ import { execFileSync } from "child_process"; import crypto from "crypto"; import fs from "fs"; import path from "path"; -import { MOCK_DATA_DIR, WORK_DIR } from "../config"; +import { getMockDataDir, WORK_DIR } from "../config"; /** * Fixtures for the binary patch scenarios: the base bundle a patch is computed against, @@ -116,7 +116,7 @@ function extractIosBinaryBundle(appId: string, bundleName: string, destPath: str } export function getHistoryFilePath(platform: Platform, identifier: string, binaryVersion: string): string { - return path.join(MOCK_DATA_DIR, "histories", platform, identifier, `${binaryVersion}.json`); + return path.join(getMockDataDir(platform), "histories", platform, identifier, `${binaryVersion}.json`); } export function readReleaseHistory( @@ -249,7 +249,7 @@ function findArchive( identifier: string, artifactType: "full-bundle" | "binary-patch" | "asset-diff", ): string { - const bundleDir = path.join(MOCK_DATA_DIR, "bundles", platform, identifier); + const bundleDir = path.join(getMockDataDir(platform), "bundles", platform, identifier); const archivePaths = findFiles(bundleDir) .filter((filePath) => path.relative(bundleDir, filePath).split(path.sep).includes(artifactType)); diff --git a/e2e/helpers/binary-patch-phase.ts b/e2e/helpers/binary-patch-phase.ts index a0b322ea1..c9f685757 100644 --- a/e2e/helpers/binary-patch-phase.ts +++ b/e2e/helpers/binary-patch-phase.ts @@ -104,9 +104,9 @@ export async function runBinaryPatchPhase(context: BinaryPatchPhaseContext): Pro releaseVersion: string, expectedDownloads: DownloadedArchive[], ) => context.withRetry(`run-maestro: binary patch (${scenarioName})`, async () => { - startRecordingDownloads(); + startRecordingDownloads(platform); await context.runMaestro(flowPath, { RELEASE_LABEL: releaseVersion }); - assertDownloadedArchives(scenarioName, expectedDownloads); + assertDownloadedArchives(scenarioName, platform, expectedDownloads); }); const scenarios: BinaryPatchScenario[] = [ @@ -198,14 +198,14 @@ export async function runBinaryPatchPhase(context: BinaryPatchPhaseContext): Pro for (const scenario of scenarios) { if (scenario.timingSensitive && context.excludeTimingSensitive) { - console.log(`\n=== [phase 6] skipping timing-sensitive scenario (${scenario.name}) ===`); + console.log(`\n=== [${platform}][phase 6] skipping timing-sensitive scenario (${scenario.name}) ===`); continue; } - console.log(`\n=== [prepare-bundle: binary patch ${scenario.releaseVersion} (${scenario.name})] ===`); + console.log(`\n=== [${platform}][prepare-bundle: binary patch ${scenario.releaseVersion} (${scenario.name})] ===`); context.cleanMockData(); await scenario.prepare(); - assertArtifactStorageLayout(scenario.name); + assertArtifactStorageLayout(scenario.name, platform); assertReleaseOffersPatch(scenario.name, platform, releaseIdentifier, BINARY_VERSION, scenario.releaseVersion); await installUpdate(scenario.name, scenario.flowPath, scenario.releaseVersion, scenario.expectedDownloads); @@ -240,12 +240,12 @@ async function runPublishedTwiceScenario( const releaseVersion = "1.3.7"; const fullOnlyIdentifier = `${releaseIdentifier}-full-only`; - console.log(`\n=== [prepare-bundle: binary patch ${releaseVersion} (${scenario})] ===`); + console.log(`\n=== [${platform}][prepare-bundle: binary patch ${releaseVersion} (${scenario})] ===`); context.cleanMockData(); - setReleasingBundle(appPath, true); + setReleasingBundle(appPath, platform, true); const { entryFile, frameworkArgs } = getCodePushReleaseArgs(appPath, framework); try { - setReleaseMarker(appPath, releaseVersion); + setReleaseMarker(appPath, platform, releaseVersion); await runCodePushCommand(appPath, platform, [ "bundle", ...frameworkArgs, @@ -286,11 +286,11 @@ async function runPublishedTwiceScenario( "--skip-bundle", "true", ]); } finally { - clearReleaseMarker(appPath); - setReleasingBundle(appPath, false); + clearReleaseMarker(appPath, platform); + setReleasingBundle(appPath, platform, false); } - assertArtifactStorageLayout(scenario); + assertArtifactStorageLayout(scenario, platform); assertReleaseOffersPatch(scenario, platform, releaseIdentifier, BINARY_VERSION, releaseVersion); assertReleaseOffersNoPatch(scenario, platform, fullOnlyIdentifier, BINARY_VERSION, releaseVersion); assertSameReleasedPackage(scenario, platform, releaseIdentifier, fullOnlyIdentifier, releaseVersion); diff --git a/e2e/helpers/download-order.ts b/e2e/helpers/download-order.ts index de7c7ae36..f88b064bd 100644 --- a/e2e/helpers/download-order.ts +++ b/e2e/helpers/download-order.ts @@ -17,13 +17,13 @@ import { clearRequestLog, getRequestLog } from "../mock-server/server"; */ export type DownloadedArchive = "binary-patch" | "asset-diff" | "full"; -export function startRecordingDownloads(): void { - clearRequestLog(); +export function startRecordingDownloads(platform: "ios" | "android"): void { + clearRequestLog(platform); } /** The update archives the app downloaded, in the order it asked for them. */ -export function getDownloadedArchives(): DownloadedArchive[] { - return getRequestLog() +export function getDownloadedArchives(platform: "ios" | "android"): DownloadedArchive[] { + return getRequestLog(platform) .filter((request) => request.method === "GET" && request.url.startsWith("/bundles/")) .map((request) => { if (request.url.includes("/binary-patch/")) return "binary-patch"; @@ -32,8 +32,12 @@ export function getDownloadedArchives(): DownloadedArchive[] { }); } -export function assertDownloadedArchives(scenario: string, expected: DownloadedArchive[]): void { - const actual = getDownloadedArchives(); +export function assertDownloadedArchives( + scenario: string, + platform: "ios" | "android", + expected: DownloadedArchive[], +): void { + const actual = getDownloadedArchives(platform); if (actual.length !== expected.length || actual.some((archive, index) => archive !== expected[index])) { throw new Error( @@ -65,8 +69,8 @@ interface ReportedUpdateArchiveResult { } /** The results the app reported since recording started, in the order it reported them. */ -function getReportedUpdateArchiveResults(): ReportedUpdateArchiveResult[] { - return getRequestLog() +function getReportedUpdateArchiveResults(platform: "ios" | "android"): ReportedUpdateArchiveResult[] { + return getRequestLog(platform) .filter((request) => request.method === "GET" && request.url.startsWith("/e2e/update-archive-result?")) .map((request) => { const data = new URLSearchParams(request.url.split("?")[1]).get("data"); @@ -90,11 +94,11 @@ const REPORT_POLL_INTERVAL_MS = 100; * other half, the bridge going down before the request is even dispatched, no wait can fix; * the timeout below is what says so out loud. */ -async function waitForOneReport(scenario: string): Promise { +async function waitForOneReport(scenario: string, platform: "ios" | "android"): Promise { const deadline = Date.now() + REPORT_ARRIVAL_TIMEOUT_MS; for (;;) { - const reports = getReportedUpdateArchiveResults(); + const reports = getReportedUpdateArchiveResults(platform); if (reports.length > 1) { throw new Error( `${scenario}: expected exactly one update archive result report, but the app sent ${reports.length}`, @@ -140,8 +144,12 @@ function attemptOutcome(attempt: { fallbackReason?: string }, isLast: boolean, s * The report is waited for rather than read once, because the app dispatches it and moves * straight on to installing: it can still be in flight when the runner first looks. */ -export async function assertReportedArchiveResult(scenario: string, expected: string): Promise { - const { result } = await waitForOneReport(scenario); +export async function assertReportedArchiveResult( + scenario: string, + platform: "ios" | "android", + expected: string, +): Promise { + const { result } = await waitForOneReport(scenario, platform); const attempts = result.attempts ?? []; const actual = [ result.status, @@ -163,8 +171,8 @@ export async function assertReportedArchiveResult(scenario: string, expected: st * Asserts that no binary patch and no asset diff archive was offered to the app, let alone * downloaded - it took the full archive and nothing else. */ -export function assertFullArchivesOnly(scenario: string): void { - const archives = getDownloadedArchives(); +export function assertFullArchivesOnly(scenario: string, platform: "ios" | "android"): void { + const archives = getDownloadedArchives(platform); if (archives.some((archive) => archive !== "full")) { throw new Error(`${scenario}: expected full archives only, but the app downloaded [${archives.join(", ")}]`); diff --git a/e2e/helpers/prepare-bundle.ts b/e2e/helpers/prepare-bundle.ts index ff3564452..edba018f3 100644 --- a/e2e/helpers/prepare-bundle.ts +++ b/e2e/helpers/prepare-bundle.ts @@ -2,7 +2,15 @@ import crypto from "crypto"; import fs from "fs"; import path from "path"; import { spawn } from "child_process"; -import { ARTIFACT_LOG_PATH, MOCK_DATA_DIR, getMockServerHost } from "../config"; +import { + getAppEntryPath, + getArtifactLogPath, + getAssetMarkerDirName, + getCliOutputPath, + getCliTempDir, + getMockDataDir, + getMockServerHost, +} from "../config"; /** An image asset the released bundle carries. Same label, byte-identical file. */ export interface AssetMarker { @@ -25,47 +33,47 @@ interface PrepareBundleOptions { createHistory?: boolean; } -export function setReleasingBundle(appPath: string, value: boolean): void { - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); +export function setReleasingBundle(appPath: string, platform: "ios" | "android", value: boolean): void { + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); content = content.replace( value ? /const IS_RELEASING_BUNDLE = false/ : /const IS_RELEASING_BUNDLE = true/, `const IS_RELEASING_BUNDLE = ${value}`, ); - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); } const RELEASE_MARKER_PATTERN = /^console\.log\("E2E_MARKER_.*"\);$/m; const CRASH_ON_START_MARKER_PATTERN = /^if \(IS_RELEASING_BUNDLE\) \{ throw new Error\("E2E_CRASH_ON_START_.*"\); \}$/m; /** - * Add a unique code statement to App.tsx to ensure different bundle hashes - * for releases with otherwise identical content. + * Add a unique code statement to the platform's app entry to ensure different bundle + * hashes for releases with otherwise identical content. */ -export function setReleaseMarker(appPath: string, version: string): void { - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); +export function setReleaseMarker(appPath: string, platform: "ios" | "android", version: string): void { + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); const marker = `console.log("E2E_MARKER_${version}");`; if (RELEASE_MARKER_PATTERN.test(content)) { content = content.replace(RELEASE_MARKER_PATTERN, marker); } else { content = `${marker}\n${content}`; } - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); } -export function clearReleaseMarker(appPath: string): void { - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); +export function clearReleaseMarker(appPath: string, platform: "ios" | "android"): void { + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); content = content.replace(RELEASE_MARKER_PATTERN, "").replace(/^\n+/, ""); - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); } -export function setCrashOnStartMarker(appPath: string, version: string): void { - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); +export function setCrashOnStartMarker(appPath: string, platform: "ios" | "android", version: string): void { + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); const marker = `if (IS_RELEASING_BUNDLE) { throw new Error("E2E_CRASH_ON_START_${version}"); }`; if (CRASH_ON_START_MARKER_PATTERN.test(content)) { @@ -73,19 +81,19 @@ export function setCrashOnStartMarker(appPath: string, version: string): void { } else { const declarationPattern = /const IS_RELEASING_BUNDLE = (true|false);/; if (!declarationPattern.test(content)) { - throw new Error(`Could not find IS_RELEASING_BUNDLE declaration in ${appTsxPath}`); + throw new Error(`Could not find IS_RELEASING_BUNDLE declaration in ${entryPath}`); } content = content.replace(declarationPattern, (declaration) => `${declaration}\n${marker}`); } - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); } -export function clearCrashOnStartMarker(appPath: string): void { - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); +export function clearCrashOnStartMarker(appPath: string, platform: "ios" | "android"): void { + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); content = content.replace(CRASH_ON_START_MARKER_PATTERN, "").replace(/\n{3,}/g, "\n\n"); - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); } const ASSET_MARKER_PATTERN = /^global\.__E2E_ASSETS__ = \[.*\];$/m; @@ -102,15 +110,21 @@ const ASSET_MARKER_PNG_BASE64 = * the update. Requiring the images is enough to put them in the update - Metro copies * every asset the module graph reaches, whether or not the app draws it. */ -export function setAssetMarker(appPath: string, markers: AssetMarker[]): void { +export function setAssetMarker(appPath: string, platform: "ios" | "android", markers: AssetMarker[]): void { + const markerDirName = getAssetMarkerDirName(platform); + fs.mkdirSync(path.join(appPath, markerDirName), { recursive: true }); + const requires = markers.map((marker) => { const assetFileName = `${ASSET_MARKER_FILE_PREFIX}${marker.label}.png`; - fs.writeFileSync(path.join(appPath, assetFileName), assetMarkerPng(marker.label, marker.byteSize)); - return `require("./${assetFileName}")`; + fs.writeFileSync( + path.join(appPath, markerDirName, assetFileName), + assetMarkerPng(marker.label, marker.byteSize), + ); + return `require("./${markerDirName}/${assetFileName}")`; }); - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); // Assigned to a global so that no minifier can decide the assets are unused. const marker = `global.__E2E_ASSETS__ = [${requires.join(", ")}];`; if (ASSET_MARKER_PATTERN.test(content)) { @@ -118,7 +132,7 @@ export function setAssetMarker(appPath: string, markers: AssetMarker[]): void { } else { content = `${marker}\n${content}`; } - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); } /** @@ -147,17 +161,13 @@ function assetMarkerPng(label: string, byteSize?: number): Buffer { return Buffer.concat(blocks).subarray(0, byteSize); } -export function clearAssetMarker(appPath: string): void { - const appTsxPath = path.join(appPath, "App.tsx"); - let content = fs.readFileSync(appTsxPath, "utf8"); +export function clearAssetMarker(appPath: string, platform: "ios" | "android"): void { + const entryPath = getAppEntryPath(appPath, platform); + let content = fs.readFileSync(entryPath, "utf8"); content = content.replace(ASSET_MARKER_PATTERN, "").replace(/^\n+/, ""); - fs.writeFileSync(appTsxPath, content, "utf8"); + fs.writeFileSync(entryPath, content, "utf8"); - for (const fileName of fs.readdirSync(appPath)) { - if (fileName.startsWith(ASSET_MARKER_FILE_PREFIX) && fileName.endsWith(".png")) { - fs.rmSync(path.join(appPath, fileName), { force: true }); - } - } + fs.rmSync(path.join(appPath, getAssetMarkerDirName(platform)), { recursive: true, force: true }); } export async function prepareBundle( @@ -173,17 +183,17 @@ export async function prepareBundle( const crashOnStartVersion = options.crashOnStartVersion; const assetMarkers = options.assetMarkers; - setReleasingBundle(appPath, true); + setReleasingBundle(appPath, platform, true); try { if (releaseMarkerVersion) { - setReleaseMarker(appPath, releaseMarkerVersion); + setReleaseMarker(appPath, platform, releaseMarkerVersion); } if (crashOnStartVersion) { - setCrashOnStartMarker(appPath, crashOnStartVersion); + setCrashOnStartMarker(appPath, platform, crashOnStartVersion); } if (assetMarkers?.length) { - setAssetMarker(appPath, assetMarkers); + setAssetMarker(appPath, platform, assetMarkers); } if (options.createHistory ?? true) { @@ -206,15 +216,15 @@ export async function prepareBundle( ); } finally { if (releaseMarkerVersion) { - clearReleaseMarker(appPath); + clearReleaseMarker(appPath, platform); } if (crashOnStartVersion) { - clearCrashOnStartMarker(appPath); + clearCrashOnStartMarker(appPath, platform); } if (assetMarkers?.length) { - clearAssetMarker(appPath); + clearAssetMarker(appPath, platform); } - setReleasingBundle(appPath, false); + setReleasingBundle(appPath, platform, false); } } @@ -275,14 +285,32 @@ function resolveReactNativeEntryFile(appPath: string): string { throw new Error(`Could not find React Native entry file in ${appPath} (expected index.js or index.ts)`); } +/** The commands that write a bundle, and so need to be told where to write it. */ +const OUTPUT_PATH_COMMANDS = new Set(["bundle", "release"]); + +/** + * Points a bundling command at the output root of its platform. + * + * The root is passed on every such command rather than only the first, because a release + * that skips bundling reads the bundle an earlier `bundle` command left there. + */ +function withOutputPath(args: string[], platform: "ios" | "android"): string[] { + if (!OUTPUT_PATH_COMMANDS.has(args[0])) { + return args; + } + return [...args, "-o", getCliOutputPath(platform)]; +} + export function runCodePushCommand( appPath: string, platform: "ios" | "android", args: string[], ): Promise { const command = "npx"; - const commandArgs = ["code-push", ...args]; + const commandArgs = ["code-push", ...withOutputPath(args, platform)]; const commandLabel = `npx ${commandArgs.join(" ")}`; + const tempDir = getCliTempDir(platform); + fs.mkdirSync(tempDir, { recursive: true }); console.log(`[command] ${commandLabel} (cwd: ${appPath})`); @@ -292,9 +320,10 @@ export function runCodePushCommand( stdio: "inherit", env: { ...process.env, - E2E_MOCK_DATA_DIR: MOCK_DATA_DIR, + TMPDIR: tempDir, + E2E_MOCK_DATA_DIR: getMockDataDir(platform), E2E_MOCK_SERVER_HOST: getMockServerHost(platform), - E2E_ARTIFACT_LOG_PATH: ARTIFACT_LOG_PATH, + E2E_ARTIFACT_LOG_PATH: getArtifactLogPath(platform), }, }); child.on("error", reject); diff --git a/e2e/helpers/prepare-config.ts b/e2e/helpers/prepare-config.ts index f612e6e30..32f16ca01 100644 --- a/e2e/helpers/prepare-config.ts +++ b/e2e/helpers/prepare-config.ts @@ -1,8 +1,7 @@ import fs from "fs"; import path from "path"; -import { getMockServerHost } from "../config"; +import { getAppEntryPath, getAppSourceEntryPath, getMockServerHost } from "../config"; -const BACKUP_SUFFIX = ".e2e-backup"; const RESUME_SYNC_BUTTON_TITLE = "Sync ON_NEXT_RESUME (20s)"; const SUSPEND_SYNC_BUTTON_TITLE = "Sync ON_NEXT_SUSPEND (20s)"; const ALERT_SYNC_BUTTON_TITLE = "Sync with updateDialog"; @@ -15,23 +14,28 @@ const DEFAULT_SYNC_BUTTON_PATTERN = /^(\s*)