Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/e2e-manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ jobs:
restore-keys: |
${{ runner.os }}-pods-${{ inputs.app }}-

- name: Install Maestro CLI
- name: Install maestro-runner
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
curl -fsSL https://open.devicelab.dev/install/maestro-runner | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"

- name: Select iOS simulator
Expand Down Expand Up @@ -125,9 +125,9 @@ jobs:
run: npm i
working-directory: Examples/${{ inputs.app }}

- name: Install Maestro CLI
- name: Install maestro-runner
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
curl -fsSL https://open.devicelab.dev/install/maestro-runner | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"

- name: Run Android E2E
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,9 @@ bin/*

# E2E mock server data
e2e/mock-server/data/

# maestro-runner report directory
e2e/reports/

# maestro-runner iOS driver artifacts
drivers
15 changes: 8 additions & 7 deletions e2e/README.ko.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# E2E 테스트 실행 가이드

[Maestro](https://maestro.mobile.dev/)를 사용한 `react-native-code-push` E2E 테스트입니다.
[maestro-runner](https://github.com/devicelab-dev/maestro-runner)를 사용한 `react-native-code-push` E2E 테스트입니다.

## 사전 요구사항

- **Node.js** (v18 이상)
- **Maestro CLI** — [설치 가이드](https://maestro.mobile.dev/getting-started/installing-maestro)
- **maestro-runner** — `curl -fsSL https://open.devicelab.dev/install/maestro-runner | bash`
- **iOS**: Xcode 및 부팅된 iOS 시뮬레이터
- **Android**: Android SDK 및 실행 중인 에뮬레이터
- `Examples/` 디렉토리에 설정된 예제 앱 (예: `RN0840`)
Expand All @@ -16,7 +16,7 @@
# 전체 실행 (빌드 + 테스트)
npm run e2e -- --app RN0840 --platform ios

# 빌드 생략, Maestro 플로우만 실행
# 빌드 생략, 테스트 플로우만 실행
npm run e2e -- --app RN0840 --platform ios --maestro-only
```

Expand All @@ -26,7 +26,7 @@ npm run e2e -- --app RN0840 --platform ios --maestro-only
# Expo 예제 앱 전체 실행
npm run e2e -- --app Expo55 --framework expo --platform ios

# Expo 예제 앱 Maestro만 실행
# Expo 예제 앱 플로우만 실행
npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only
```

Expand All @@ -38,7 +38,8 @@ npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only
| `--platform <type>` | 예 | `ios` 또는 `android` |
| `--framework <type>` | 아니오 | Expo 예제 앱인 경우 `expo` 지정 |
| `--simulator <name>` | 아니오 | iOS 시뮬레이터 이름 (부팅된 시뮬레이터 자동 감지, 기본값 "iPhone 16") |
| `--maestro-only` | 아니오 | 빌드 단계 생략, Maestro 플로우만 실행 |
| `--maestro-only` | 아니오 | 빌드 단계 생략, 테스트 플로우만 실행 |
| `--team-id <id>` | 아니오 | iOS WDA 서명용 Apple Team ID (`maestro-runner`). iOS에서 생략하면 env/keychain/profile에서 자동 탐지 |

## 실행 과정

Expand All @@ -50,7 +51,7 @@ npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only
2. **앱 빌드** — 예제 앱을 Release 모드로 빌드하여 시뮬레이터/에뮬레이터에 설치합니다.
3. **번들 준비** — `npx code-push release`로 릴리스 히스토리를 생성하고 v1.0.1을 번들링합니다.
4. **Mock 서버 시작** — 번들과 릴리스 히스토리 JSON을 서빙하는 로컬 HTTP 서버(포트 18081)를 시작합니다.
5. **Maestro 플로우 실행**:
5. **테스트 플로우 실행 (maestro-runner 사용)**:
- `01-app-launch` — 앱 실행 및 UI 요소 존재 확인
- `02-restart-no-crash` — 재시작 탭 후 크래시 없음 확인
- `03-update-flow` — 이전 업데이트 초기화, sync 트리거, 업데이트 설치 확인("UPDATED!" 표시) 및 메타데이터 `METADATA_V1.0.1` 확인
Expand Down Expand Up @@ -102,6 +103,6 @@ e2e/
## 문제 해결

- **iOS 빌드 시 서명 오류**: setup 스크립트가 `SUPPORTED_PLATFORMS = iphonesimulator`를 설정하고 코드 서명을 비활성화합니다. `scripts/setupExampleApp`으로 예제 앱이 설정되었는지 확인하세요.
- **Maestro가 앱을 찾지 못함**: 실행 전에 시뮬레이터/에뮬레이터가 부팅되어 있는지 확인하세요. iOS의 경우 스크립트가 부팅된 시뮬레이터를 자동 감지합니다.
- **maestro-runner가 앱을 찾지 못함**: 실행 전에 시뮬레이터/에뮬레이터가 부팅되어 있는지 확인하세요. iOS의 경우 스크립트가 부팅된 시뮬레이터를 자동 감지합니다.
- **Android 네트워크 오류**: Android 에뮬레이터는 호스트 머신의 localhost에 접근하기 위해 `10.0.2.2`를 사용합니다. 설정에서 자동으로 처리됩니다.
- **업데이트가 적용되지 않음**: Mock 서버가 실행 중인지(포트 18081), `mock-server/data/`에 예상되는 번들과 히스토리 파일이 있는지 확인하세요.
15 changes: 8 additions & 7 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# E2E Testing Guide

End-to-end tests for `react-native-code-push` using [Maestro](https://maestro.mobile.dev/).
End-to-end tests for `react-native-code-push` using [maestro-runner](https://github.com/devicelab-dev/maestro-runner).

## Prerequisites

- **Node.js** (v18+)
- **Maestro CLI** — [Install guide](https://maestro.mobile.dev/getting-started/installing-maestro)
- **maestro-runner** — `curl -fsSL https://open.devicelab.dev/install/maestro-runner | bash`
- **iOS**: Xcode with a booted iOS Simulator
- **Android**: Android SDK with a running emulator
- An example app set up under `Examples/` (e.g. `RN0840`)
Expand All @@ -16,7 +16,7 @@ End-to-end tests for `react-native-code-push` using [Maestro](https://maestro.mo
# Full run (build + test)
npm run e2e -- --app RN0840 --platform ios

# Skip build, run Maestro flows only
# Skip build, run test flows only
npm run e2e -- --app RN0840 --platform ios --maestro-only
```

Expand All @@ -26,7 +26,7 @@ npm run e2e -- --app RN0840 --platform ios --maestro-only
# Full run for Expo example app
npm run e2e -- --app Expo55 --framework expo --platform ios

# Maestro-only run for Expo example app
# Flow-only run for Expo example app
npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only
```

Expand All @@ -38,7 +38,8 @@ npm run e2e -- --app Expo55Beta --framework expo --platform ios --maestro-only
| `--platform <type>` | Yes | `ios` or `android` |
| `--framework <type>` | No | Use `expo` for Expo example apps |
| `--simulator <name>` | No | iOS simulator name (auto-detects booted simulator, defaults to "iPhone 16") |
| `--maestro-only` | No | Skip build step, only run Maestro flows |
| `--maestro-only` | No | Skip build step, only run test flows |
| `--team-id <id>` | No | Apple Team ID for iOS WDA signing (`maestro-runner`). If omitted on iOS, the runner auto-detects from env/keychain/profiles |

## What It Does

Expand All @@ -50,7 +51,7 @@ The test runner (`e2e/run.ts`) executes these phases in order:
2. **Build app** — Builds the example app in Release mode and installs it on the simulator/emulator.
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.
5. **Run Maestro flows** — Executes:
5. **Run test flows (via maestro-runner)** — Executes:
- `01-app-launch` — Verifies the app launches and UI elements are present.
- `02-restart-no-crash` — Taps Restart, confirms app doesn't crash.
- `03-update-flow` — Clears any previous update, triggers sync, verifies update installs (shows "UPDATED!") and metadata shows `METADATA_V1.0.1`.
Expand Down Expand Up @@ -102,6 +103,6 @@ When creating multiple releases with identical source code (e.g. v1.0.1 and v1.0
## 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 can't find the app**: Ensure the simulator/emulator is booted before running. For iOS, the script auto-detects the booted simulator.
- **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.
- **Update not applying**: Check that the mock server is running (port 18081) and that `mock-server/data/` contains the expected bundle and history files.
89 changes: 89 additions & 0 deletions e2e/helpers/resolve-ios-team-id.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const { resolveIosTeamIdForMaestro } = require("./resolve-ios-team-id");

function createProvider({ certificateOutput = "", profileOutput = "" } = {}) {
return {
readCertificateSubjectOutput: () => certificateOutput,
readProfileTeamIdOutput: () => profileOutput,
};
}

describe("resolveIosTeamIdForMaestro", () => {
test("returns undefined for android", () => {
const result = resolveIosTeamIdForMaestro({
platform: "android",
provider: createProvider({
certificateOutput: "subject= /OU=AAAAAAAAAA/CN=Apple Development: Test",
}),
});

expect(result).toBeUndefined();
});

test("uses CLI team id when provided", () => {
const result = resolveIosTeamIdForMaestro({
platform: "ios",
cliTeamId: "ABCDEFGHIJ",
provider: createProvider({
certificateOutput: "subject= /OU=ZZZZZZZZZZ/CN=Apple Development: Test",
}),
});

expect(result).toBe("ABCDEFGHIJ");
});

test("uses environment team id when CLI team id is missing", () => {
const result = resolveIosTeamIdForMaestro({
platform: "ios",
env: { APPLE_TEAM_ID: "BCDEFGHIJK" },
provider: createProvider(),
});

expect(result).toBe("BCDEFGHIJK");
});

test("uses detected team id from certificates when one value exists", () => {
const result = resolveIosTeamIdForMaestro({
platform: "ios",
provider: createProvider({
certificateOutput: [
"subject= /UID=ABCDEF/OU=KLMNOPQRST/CN=Apple Development: Tester",
"subject= /UID=ABCDEF/OU=KLMNOPQRST/CN=Apple Development: Tester 2",
].join("\n"),
}),
});

expect(result).toBe("KLMNOPQRST");
});

test("uses detected team id from profiles when certificate output is empty", () => {
const result = resolveIosTeamIdForMaestro({
platform: "ios",
provider: createProvider({
profileOutput: ["ZYXWVUTSRQ", "ZYXWVUTSRQ"].join("\n"),
}),
});

expect(result).toBe("ZYXWVUTSRQ");
});

test("throws when multiple team ids are detected", () => {
expect(() =>
resolveIosTeamIdForMaestro({
platform: "ios",
provider: createProvider({
certificateOutput: "subject= /OU=AAAAAAAAAA/CN=Apple Development: Test",
profileOutput: "BBBBBBBBBB",
}),
}),
).toThrow("Multiple iOS Team IDs detected");
});

test("throws when no team id is available", () => {
expect(() =>
resolveIosTeamIdForMaestro({
platform: "ios",
provider: createProvider(),
}),
).toThrow("Could not resolve iOS Team ID");
});
});
135 changes: 135 additions & 0 deletions e2e/helpers/resolve-ios-team-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { execSync } from "child_process";

type Platform = "ios" | "android";

interface TeamIdProvider {
readCertificateSubjectOutput: () => string;
readProfileTeamIdOutput: () => string;
}

interface ResolveIosTeamIdOptions {
platform: Platform;
cliTeamId?: string;
env?: NodeJS.ProcessEnv;
provider?: TeamIdProvider;
}

const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/;
const TEAM_ID_ENV_KEYS = [
"MAESTRO_IOS_TEAM_ID",
"APPLE_TEAM_ID",
"IOS_TEAM_ID",
"TEAM_ID",
] as const;

export function resolveIosTeamIdForMaestro(
options: ResolveIosTeamIdOptions,
): string | undefined {
if (options.platform !== "ios") {
return undefined;
}

const cliTeamId = normalizeTeamId(options.cliTeamId);
if (cliTeamId !== undefined) {
assertValidTeamId(cliTeamId, "--team-id");
return cliTeamId;
}

const env = options.env ?? process.env;
for (const key of TEAM_ID_ENV_KEYS) {
const value = normalizeTeamId(env[key]);
if (value === undefined) {
continue;
}
assertValidTeamId(value, key);
return value;
}

const provider = options.provider ?? createDefaultProvider();
const detectedTeamIds = uniqueTeamIds([
...extractTeamIds(provider.readCertificateSubjectOutput()),
...extractTeamIds(provider.readProfileTeamIdOutput()),
]);

if (detectedTeamIds.length === 1) {
return detectedTeamIds[0];
}

if (detectedTeamIds.length > 1) {
throw new Error(
`Multiple iOS Team IDs detected: ${detectedTeamIds.join(", ")}. `
+ "Pass --team-id <APPLE_TEAM_ID> or set MAESTRO_IOS_TEAM_ID.",
);
}

throw new Error(
"Could not resolve iOS Team ID for maestro-runner. "
+ "Pass --team-id <APPLE_TEAM_ID> or set MAESTRO_IOS_TEAM_ID.",
);
}

function createDefaultProvider(): TeamIdProvider {
return {
readCertificateSubjectOutput: () =>
runCommand("security find-identity -v -p codesigning 2>/dev/null"),
readProfileTeamIdOutput: () =>
runCommand(
"for p in \"$HOME\"/Library/MobileDevice/Provisioning\\ Profiles/*.mobileprovision; do "
+ "security cms -D -i \"$p\" 2>/dev/null | "
+ "plutil -extract TeamIdentifier.0 raw -o - - 2>/dev/null; "
+ "done",
),
};
}

function runCommand(command: string): string {
try {
return execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
} catch {
return "";
}
}

function normalizeTeamId(rawValue: string | undefined): string | undefined {
if (typeof rawValue !== "string") {
return undefined;
}

const normalized = rawValue.trim().toUpperCase();
return normalized.length > 0 ? normalized : undefined;
}

function assertValidTeamId(teamId: string, source: string): void {
if (!TEAM_ID_PATTERN.test(teamId)) {
throw new Error(
`Invalid iOS Team ID from ${source}: "${teamId}". `
+ "Expected a 10-character uppercase alphanumeric value.",
);
}
}

function extractTeamIds(output: string): string[] {
const teamIds = new Set<string>();

addMatches(teamIds, output, /OU=([A-Z0-9]{10})/g);
addMatches(teamIds, output, /\(([A-Z0-9]{10})\)/g);

for (const rawLine of output.split(/\r?\n/)) {
const line = normalizeTeamId(rawLine);
if (line !== undefined && TEAM_ID_PATTERN.test(line)) {
teamIds.add(line);
}
}

return Array.from(teamIds);
}

function addMatches(target: Set<string>, output: string, pattern: RegExp): void {
for (const match of output.matchAll(pattern)) {
target.add(match[1]);
}
}

function uniqueTeamIds(teamIds: string[]): string[] {
return Array.from(new Set(teamIds)).sort();
}
Loading