From 1b3f123f1e37cebf26407f996de56afbdfb9a87b Mon Sep 17 00:00:00 2001 From: zhangyiming Date: Sat, 15 Aug 2026 22:58:01 +0800 Subject: [PATCH 1/6] test(e2e): isolate iOS acceptance execution --- .github/workflows/e2e.yml | 134 ++++++++- docs/guides/dev-environment.md | 4 +- docs/guides/testing-strategy.md | 3 + scripts/e2e/acceptance-package.mjs | 371 ++++++++++++++++++++++++ scripts/e2e/acceptance-package.test.mjs | 182 ++++++++++++ scripts/e2e/build-contract.test.mjs | 32 +- scripts/e2e/build-snapshot.mjs | 31 +- scripts/e2e/build-snapshot.test.mjs | 1 + scripts/e2e/ios-native-abi.mjs | 42 ++- scripts/e2e/ios-native-abi.test.mjs | 37 ++- scripts/e2e/ios-observation.mjs | 6 +- scripts/e2e/ios-observation.test.mjs | 16 + scripts/e2e/ios.mjs | 44 ++- scripts/e2e/run.mjs | 149 +++++++++- scripts/e2e/runner-cli.test.mjs | 70 +++++ scripts/e2e/workflow-contract.test.mjs | 30 +- 16 files changed, 1094 insertions(+), 58 deletions(-) create mode 100644 scripts/e2e/acceptance-package.mjs create mode 100644 scripts/e2e/acceptance-package.test.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c5ac486..fcd39b4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -19,6 +19,17 @@ on: description: Optional flow basename, for example f06-session-persistence required: false type: string + ios_execution: + description: iOS execution topology + required: true + default: same-host + type: choice + options: + - same-host + - isolation-pair + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} @@ -27,7 +38,9 @@ concurrency: jobs: ios-maestro: name: iOS Maestro acceptance suite - if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'ios' + if: >- + github.event_name != 'workflow_dispatch' || + ((inputs.platform == 'all' || inputs.platform == 'ios') && inputs.ios_execution == 'same-host') runs-on: macos-26 timeout-minutes: 165 steps: @@ -71,6 +84,125 @@ jobs: path: ${{ runner.temp }}/plogkit-e2e retention-days: 7 + ios-isolation-control: + name: Same-host build and acceptance control + if: >- + github.event_name == 'workflow_dispatch' && + (inputs.platform == 'all' || inputs.platform == 'ios') && + inputs.ios_execution == 'isolation-pair' + runs-on: macos-26-intel + timeout-minutes: 120 + outputs: + package-ready: ${{ steps.package-upload.outcome == 'success' }} + steps: + - uses: actions/checkout@v7.0.1 + - uses: pnpm/action-setup@v6.0.10 + - uses: actions/setup-node@v7.0.0 + with: + node-version-file: .node-version + cache: pnpm + - uses: actions/setup-java@v5.7.0 + with: + distribution: temurin + java-version-file: .java-version + - name: Select pinned Xcode + run: sudo xcode-select --switch /Applications/Xcode_26.6.app/Contents/Developer + - run: pnpm install --frozen-lockfile + - name: Install Maestro CLI + run: | + MAESTRO_VERSION="$(< .maestro-version)" + MAESTRO_ARCHIVE="$RUNNER_TEMP/maestro-$MAESTRO_VERSION" + mkdir -p "$MAESTRO_ARCHIVE" + curl -fsSL -o "$MAESTRO_ARCHIVE/maestro.zip" "https://github.com/mobile-dev-inc/maestro/releases/download/cli-$MAESTRO_VERSION/maestro.zip" + curl -fsSL -o "$MAESTRO_ARCHIVE/checksums_sha256.txt" "https://github.com/mobile-dev-inc/maestro/releases/download/cli-$MAESTRO_VERSION/checksums_sha256.txt" + (cd "$MAESTRO_ARCHIVE" && shasum -a 256 -c checksums_sha256.txt) + unzip -q "$MAESTRO_ARCHIVE/maestro.zip" -d "$MAESTRO_ARCHIVE" + echo "$MAESTRO_ARCHIVE/maestro/bin" >> "$GITHUB_PATH" + - name: Build sealed iOS acceptance package + id: package-build + run: pnpm e2e:ios --build-package "$RUNNER_TEMP/plogkit-ios-package" + timeout-minutes: 60 + env: + E2E_ARTIFACTS_DIR: ${{ runner.temp }}/plogkit-e2e-build-private + - name: Run same-host iOS acceptance control + run: pnpm e2e:ios --accept-package "$RUNNER_TEMP/plogkit-ios-package" + timeout-minutes: 45 + env: + E2E_ARTIFACTS_DIR: ${{ runner.temp }}/plogkit-e2e-control-private + E2E_PUBLIC_ARTIFACTS_DIR: ${{ runner.temp }}/plogkit-e2e-control + E2E_FLOW: ${{ inputs.flow }} + E2E_IOS_RUNNER_LABEL: macos-26-intel + - name: Upload sealed iOS acceptance package + id: package-upload + if: ${{ always() && steps.package-build.outcome == 'success' }} + uses: actions/upload-artifact@v7.0.1 + with: + name: ios-sealed-acceptance-package + compression-level: 1 + if-no-files-found: error + path: ${{ runner.temp }}/plogkit-ios-package + retention-days: 1 + - name: Upload same-host observations and failure artifacts + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: ios-same-host-observations + if-no-files-found: warn + path: ${{ runner.temp }}/plogkit-e2e-control + retention-days: 7 + + ios-isolation-fresh-host: + name: Fresh-host packaged acceptance experiment + needs: ios-isolation-control + if: ${{ always() && needs.ios-isolation-control.outputs.package-ready == 'true' }} + runs-on: macos-26-intel + timeout-minutes: 75 + steps: + - uses: actions/checkout@v7.0.1 + - uses: pnpm/action-setup@v6.0.10 + - uses: actions/setup-node@v7.0.0 + with: + node-version-file: .node-version + cache: pnpm + - uses: actions/setup-java@v5.7.0 + with: + distribution: temurin + java-version-file: .java-version + - name: Select pinned Xcode + run: sudo xcode-select --switch /Applications/Xcode_26.6.app/Contents/Developer + - run: pnpm install --frozen-lockfile + - name: Download sealed iOS acceptance package + uses: actions/download-artifact@v8.0.1 + with: + name: ios-sealed-acceptance-package + path: ${{ runner.temp }}/plogkit-ios-package + - name: Install Maestro CLI + run: | + MAESTRO_VERSION="$(< .maestro-version)" + MAESTRO_ARCHIVE="$RUNNER_TEMP/maestro-$MAESTRO_VERSION" + mkdir -p "$MAESTRO_ARCHIVE" + curl -fsSL -o "$MAESTRO_ARCHIVE/maestro.zip" "https://github.com/mobile-dev-inc/maestro/releases/download/cli-$MAESTRO_VERSION/maestro.zip" + curl -fsSL -o "$MAESTRO_ARCHIVE/checksums_sha256.txt" "https://github.com/mobile-dev-inc/maestro/releases/download/cli-$MAESTRO_VERSION/checksums_sha256.txt" + (cd "$MAESTRO_ARCHIVE" && shasum -a 256 -c checksums_sha256.txt) + unzip -q "$MAESTRO_ARCHIVE/maestro.zip" -d "$MAESTRO_ARCHIVE" + echo "$MAESTRO_ARCHIVE/maestro/bin" >> "$GITHUB_PATH" + - name: Run fresh-host iOS packaged acceptance + run: pnpm e2e:ios --accept-package "$RUNNER_TEMP/plogkit-ios-package" + timeout-minutes: 45 + env: + E2E_ARTIFACTS_DIR: ${{ runner.temp }}/plogkit-e2e-fresh-private + E2E_PUBLIC_ARTIFACTS_DIR: ${{ runner.temp }}/plogkit-e2e-fresh + E2E_FLOW: ${{ inputs.flow }} + E2E_IOS_RUNNER_LABEL: macos-26-intel + - name: Upload fresh-host observations and failure artifacts + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: ios-fresh-host-observations + if-no-files-found: warn + path: ${{ runner.temp }}/plogkit-e2e-fresh + retention-days: 7 + android-maestro: name: Android Maestro acceptance suite if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'android' diff --git a/docs/guides/dev-environment.md b/docs/guides/dev-environment.md index 52955e0..f3889a8 100644 --- a/docs/guides/dev-environment.md +++ b/docs/guides/dev-environment.md @@ -34,7 +34,7 @@ pnpm install ### iOS -- macOS 和完整安装的 Xcode;日常开发可使用与 Expo SDK 57 兼容的版本。本地 L4 E2E 当前要求 Apple Silicon,精确使用 Xcode 26.6(build 17F113),并只构建宿主实际执行的 arm64 Simulator slice。 +- macOS 和完整安装的 Xcode;日常开发可使用与 Expo SDK 57 兼容的版本。本地 L4 E2E 精确使用 Xcode 26.6(build 17F113),并只构建宿主实际执行的单一 Simulator slice:Apple Silicon 为 `arm64`,Intel 为 `x86_64`。 - 普通开发至少需要一个兼容的 iOS Simulator runtime;L4 E2E 精确使用 iOS 26.5 runtime 与 iPhone 17 Pro device type。 - CocoaPods 1.17.0。 @@ -161,6 +161,8 @@ pnpm e2e:android 上述三个命令都会在单次 runner 调用中完成 clean prebuild、Release 构建、产物快照、临时设备、安装与验收,不复用 development build 或可变的旧产物。`pnpm e2e` 在一台 Mac 上按 iOS、Android 顺序执行;GitHub 使用两台独立 runner 并行运行相同的平台入口。 +GitHub 的 #101 手动实验在现有 `Mobile simulator E2E` workflow 中选择 `ios_execution=isolation-pair`。构建端使用显式 `--build-package` 生成一次 sealed acceptance package;同宿主控制和 fresh-host 实验分别使用 `--accept-package` 消费它。这两个参数是配对实验接口,不是复用本地旧 build 的捷径:package 必须属于同一 workflow、同一 commit、同一仓库输入和同一 x86_64/Xcode/runtime 契约,任一身份或内容 hash 不符都会在创建设备前失败。普通 `pnpm e2e:ios` 仍保持单进程完整事务。 + 定位已知失败时可只运行一条 flow;它仍走完整 Release 构建与设备生命周期: ```bash diff --git a/docs/guides/testing-strategy.md b/docs/guides/testing-strategy.md index 322ed11..e940149 100644 --- a/docs/guides/testing-strategy.md +++ b/docs/guides/testing-strategy.md @@ -55,6 +55,8 @@ Maestro 在 iOS Simulator 和 Android Emulator 上驱动 clean Release standalon 每个平台只构建并冻结一份 Release 产物,随后在一个临时设备和一个 Maestro workspace 中串行执行 `e2e/config.yaml` 配置的顶层 flows。每条 flow 按 Maestro 的平台语义清空 App 数据,但不重新构建或更换受测产物;同一 flow 内可以保留数据验证生命周期。系统相册等设备级状态跨 flow 保留,因此 fixture 只注入一次,会改变系统状态的 Export 固定最后执行。任一 flow 失败都立即终止;timeout 只负责有界结束失控进程,不承担失败恢复。 +Issue #101 的手动 `isolation-pair` 模式是尚未进入正式门禁的执行面实验。它在 x86_64 `macos-26-intel` 上只构建一次 host-native Release slice,把 App、dSYM、flows、subflows、fixtures 与输入 provenance 封装为同一 sealed acceptance package;同宿主控制组和全新宿主实验组都必须先校验 commit、仓库输入、执行契约、archive digest 与 payload digest,再消费该包。控制组失败不得阻止已经发布的同一包进入 fresh-host 组,两个首轮结果都计入,均不 retry。该模式在形成配对证据并由新 ADR 修订 ADR 0042 前,不替换定时或普通手动 L4。 + ## Scenario 可追踪性 已实现 Scenario 必须由 L2/L3 原生测试标题或 L4 Maestro Flow tags 声明至少一项自动化证据,具体格式见 [Spec 规范](../specs/README.md)。绑定是跨层多对多关系,不要求每个 Scenario 拥有独立 Maestro flow,也不维护独立映射清单。 @@ -76,6 +78,7 @@ L2/L3 层级由测试文件路径推导,L4 由顶层 Flow 路径确定。`pnpm | ready / 正式 PR 的新提交 | macOS + Ubuntu(并行) | iOS Simulator Debug 与 Android arm64 Debug 原生集成编译 | | 每周一 02:30(北京时间) | macOS + Ubuntu(并行) | 双端 Release standalone 的完整 Maestro 验收套件 | | 手动 | macOS / Ubuntu(按选择) | 完整双端或指定平台 / flow | +| 手动隔离实验 | 两台 x86_64 macOS | 同一 sealed iOS package 的同宿主控制与全新宿主验收 | Draft PR 的每次提交只运行 `pnpm verify`。转为 ready 时触发双端编译检查,此后正式 PR 的每次新提交重新运行全部三项检查。`main` ruleset 要求 PR 和这三项检查全部通过后才能合并,见 [ADR 0016](../adr/0016-git-workflow.md) 和 [ADR 0020](../adr/0020-ci-lifecycle-and-main-ruleset.md)。 diff --git a/scripts/e2e/acceptance-package.mjs b/scripts/e2e/acceptance-package.mjs new file mode 100644 index 0000000..91bac0a --- /dev/null +++ b/scripts/e2e/acceptance-package.mjs @@ -0,0 +1,371 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + closeSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { hashPath } from "./build-snapshot.mjs"; + +const schemaVersion = 1; +const manifestFilename = "acceptance-package.json"; +const archiveFilename = "acceptance-package.tar"; +const manifestMaxBytes = 64 * 1024; +const archiveListingMaxBytes = 4 * 1024 * 1024; +const archiveEntryLimit = 20000; + +export function captureRepositoryCommit(root) { + const result = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + timeout: 15000, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Unable to identify the acceptance package commit: ${result.stderr}`); + } + const commitSha = result.stdout.trim(); + assertHex(commitSha, 20, "Acceptance package commit SHA"); + return commitSha; +} + +function assertHex(value, bytes, label) { + if (typeof value !== "string" || !new RegExp(`^[a-f0-9]{${bytes * 2}}$`).test(value)) { + throw new Error(`${label} must be a lowercase ${bytes * 2}-character hexadecimal digest.`); + } +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256File(path) { + const hash = createHash("sha256"); + const descriptor = openSync(path, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let read; + while ((read = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) { + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } + return hash.digest("hex"); +} + +function assertPayloadContained(root) { + const resolvedRoot = realpathSync(root); + const prefix = `${resolvedRoot}${sep}`; + const visit = (path) => { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + let target; + try { + target = realpathSync(path); + } catch (error) { + throw new Error( + `Acceptance package payload symlink is unresolved: ${relative(root, path)}`, + { + cause: error, + }, + ); + } + if (target !== resolvedRoot && !target.startsWith(prefix)) { + throw new Error( + `Acceptance package payload symlink escapes its root: ${relative(root, path)}`, + ); + } + return; + } + if (stat.isDirectory()) { + for (const entry of readdirSync(path).sort()) visit(join(path, entry)); + return; + } + if (!stat.isFile()) { + throw new Error( + `Acceptance package payload contains an unsupported entry: ${relative(root, path)}`, + ); + } + }; + visit(root); +} + +function runTar(args) { + const result = spawnSync("tar", args, { + encoding: "utf8", + maxBuffer: archiveListingMaxBytes, + timeout: 5 * 60 * 1000, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `Unable to process the sealed acceptance package archive: ${[result.stderr, result.stdout] + .filter(Boolean) + .join("\n")}`, + ); + } + return result.stdout; +} + +function validateIdentity({ commitSha, contract, platform, repositorySha256 }) { + assertHex(commitSha, 20, "Acceptance package commit SHA"); + assertHex(repositorySha256, 32, "Acceptance package repository SHA-256"); + if (platform !== "ios") { + throw new Error(`Unsupported acceptance package platform: ${platform ?? "missing"}.`); + } + if (!contract || typeof contract !== "object" || Array.isArray(contract)) { + throw new Error("Acceptance package execution contract must be an object."); + } +} + +function readManifest(packageDirectory) { + const path = join(packageDirectory, manifestFilename); + if (!existsSync(path) || !statSync(path).isFile()) { + throw new Error(`Acceptance package manifest is missing: ${path}`); + } + if (statSync(path).size > manifestMaxBytes) { + throw new Error(`Acceptance package manifest exceeds ${manifestMaxBytes} bytes.`); + } + let manifest; + try { + manifest = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + throw new SyntaxError(`Acceptance package manifest is not valid JSON: ${error.message}`, { + cause: error, + }); + } + if (manifest?.schemaVersion !== schemaVersion) { + throw new Error( + `Unsupported acceptance package schema version: ${manifest?.schemaVersion ?? "missing"}.`, + ); + } + validateIdentity({ + commitSha: manifest?.source?.commitSha, + contract: manifest?.contract, + platform: manifest?.platform, + repositorySha256: manifest?.source?.repositorySha256, + }); + if ( + manifest?.archive?.file !== archiveFilename || + !Number.isSafeInteger(manifest?.archive?.bytes) || + manifest.archive.bytes <= 0 + ) { + throw new Error("Acceptance package archive metadata is invalid."); + } + assertHex(manifest.archive.sha256, 32, "Acceptance package archive SHA-256"); + if ( + typeof manifest?.payload?.directory !== "string" || + basename(manifest.payload.directory) !== manifest.payload.directory || + manifest.payload.directory.length === 0 + ) { + throw new Error("Acceptance package payload directory is invalid."); + } + assertHex(manifest.payload.sha256, 32, "Acceptance package payload SHA-256"); + return manifest; +} + +function assertExpectedIdentity(manifest, expected) { + if (manifest.platform !== expected.platform) { + throw new Error("Acceptance package platform does not match this acceptance run."); + } + if (manifest.source.commitSha !== expected.commitSha) { + throw new Error("Acceptance package commit SHA does not match this checkout."); + } + if (manifest.source.repositorySha256 !== expected.repositorySha256) { + throw new Error("Acceptance package repository fingerprint does not match this checkout."); + } + if (canonicalJson(manifest.contract) !== canonicalJson(expected.contract)) { + throw new Error("Acceptance package execution contract does not match this test host."); + } +} + +function assertArchiveEntries(archive, payloadDirectory) { + const entries = runTar(["-tf", archive]).split("\n").filter(Boolean); + if (entries.length === 0 || entries.length > archiveEntryLimit) { + throw new Error("Acceptance package archive has an invalid number of entries."); + } + for (const entry of entries) { + if (entry.includes("\0") || isAbsolute(entry)) { + throw new Error("Acceptance package archive contains an unsafe entry path."); + } + const segments = entry.split("/").filter(Boolean); + if (segments[0] !== payloadDirectory || segments.some((segment) => segment === "..")) { + throw new Error("Acceptance package archive escapes its declared payload directory."); + } + } +} + +function resolveWithin(root, path, label) { + if (typeof path !== "string" || path.length === 0 || isAbsolute(path)) { + throw new Error(`${label} path is invalid.`); + } + const destination = resolve(root, path); + const prefix = `${resolve(root)}${sep}`; + if (!destination.startsWith(prefix)) throw new Error(`${label} path escapes the payload.`); + return destination; +} + +function snapshotFromPayload(payloadDirectory, manifest) { + const provenancePath = join(payloadDirectory, "provenance.json"); + if (!existsSync(provenancePath) || !statSync(provenancePath).isFile()) { + throw new Error("Acceptance package payload is missing provenance.json."); + } + const provenance = JSON.parse(readFileSync(provenancePath, "utf8")); + if (provenance.schemaVersion !== 1) { + throw new Error( + `Unsupported acceptance payload provenance version: ${provenance.schemaVersion ?? "missing"}.`, + ); + } + if (provenance.repositorySha256 !== manifest.source.repositorySha256) { + throw new Error("Acceptance package payload provenance does not match its manifest."); + } + const build = provenance?.builds?.[manifest.platform]; + if (!build?.artifact?.path || !Array.isArray(build?.sidecars) || build.sidecars.length === 0) { + throw new Error("Acceptance package payload does not contain the declared platform build."); + } + const artifact = resolveWithin(payloadDirectory, build.artifact.path, "Build artifact"); + const e2eRoot = resolveWithin(payloadDirectory, "e2e", "E2E root"); + const fixtures = [ + join(e2eRoot, "fixtures", "portrait.jpg"), + join(e2eRoot, "fixtures", "landscape.jpg"), + ]; + if ( + !existsSync(artifact) || + !existsSync(e2eRoot) || + !existsSync(join(e2eRoot, "config.yaml")) || + fixtures.some((fixture) => !existsSync(fixture)) + ) { + throw new Error("Acceptance package payload is missing its app or E2E inputs."); + } + for (const sidecar of build.sidecars) { + const sidecarPath = resolveWithin(payloadDirectory, sidecar?.path, "Build sidecar"); + if (!existsSync(sidecarPath)) { + throw new Error("Acceptance package payload is missing a diagnostic sidecar."); + } + } + return { + artifacts: { [manifest.platform]: artifact }, + e2eRoot, + fixtures, + provenance: provenancePath, + }; +} + +export function createAcceptancePackage({ + commitSha, + contract, + packageDirectory, + platform, + repositorySha256, + snapshot, +}) { + validateIdentity({ commitSha, contract, platform, repositorySha256 }); + if (existsSync(packageDirectory)) { + throw new Error(`Acceptance package destination already exists: ${packageDirectory}`); + } + const payloadDirectory = dirname(snapshot?.provenance ?? ""); + if ( + !payloadDirectory || + !existsSync(payloadDirectory) || + !lstatSync(payloadDirectory).isDirectory() + ) { + throw new Error("Acceptance package requires a completed run snapshot."); + } + const payloadName = basename(payloadDirectory); + assertPayloadContained(payloadDirectory); + const payloadSha256 = hashPath(payloadDirectory); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const stagingDirectory = mkdtempSync(join(dirname(packageDirectory), ".acceptance-package-")); + try { + const archive = join(stagingDirectory, archiveFilename); + runTar(["-cf", archive, "-C", dirname(payloadDirectory), payloadName]); + const archiveBytes = statSync(archive).size; + const manifest = join(stagingDirectory, manifestFilename); + writeFileSync( + manifest, + `${JSON.stringify( + { + archive: { + bytes: archiveBytes, + file: archiveFilename, + sha256: sha256File(archive), + }, + contract, + payload: { directory: payloadName, sha256: payloadSha256 }, + platform, + schemaVersion, + source: { commitSha, repositorySha256 }, + }, + null, + 2, + )}\n`, + ); + renameSync(stagingDirectory, packageDirectory); + return { + archive: join(packageDirectory, archiveFilename), + manifest: join(packageDirectory, manifestFilename), + }; + } catch (error) { + rmSync(stagingDirectory, { force: true, recursive: true }); + throw error; + } +} + +export function loadAcceptancePackage({ + commitSha, + contract, + extractionRoot, + packageDirectory, + platform, + repositorySha256, +}) { + validateIdentity({ commitSha, contract, platform, repositorySha256 }); + const manifest = readManifest(packageDirectory); + assertExpectedIdentity(manifest, { commitSha, contract, platform, repositorySha256 }); + const archive = join(packageDirectory, archiveFilename); + if (!existsSync(archive) || !statSync(archive).isFile()) { + throw new Error(`Acceptance package archive is missing: ${archive}`); + } + if ( + statSync(archive).size !== manifest.archive.bytes || + sha256File(archive) !== manifest.archive.sha256 + ) { + throw new Error("Acceptance package archive hash does not match its manifest."); + } + assertArchiveEntries(archive, manifest.payload.directory); + mkdirSync(extractionRoot, { recursive: true }); + const stagingDirectory = mkdtempSync(join(extractionRoot, ".acceptance-payload-")); + try { + runTar(["-xf", archive, "-C", stagingDirectory]); + const payloadDirectory = join(stagingDirectory, manifest.payload.directory); + assertPayloadContained(payloadDirectory); + if (hashPath(payloadDirectory) !== manifest.payload.sha256) { + throw new Error("Acceptance package payload hash does not match its manifest."); + } + return snapshotFromPayload(payloadDirectory, manifest); + } catch (error) { + rmSync(stagingDirectory, { force: true, recursive: true }); + throw error; + } +} diff --git a/scripts/e2e/acceptance-package.test.mjs b/scripts/e2e/acceptance-package.test.mjs new file mode 100644 index 0000000..6f3c15c --- /dev/null +++ b/scripts/e2e/acceptance-package.test.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import { createTemporaryTestDirectory } from "../test-support/temp-directory.mjs"; +import { createAcceptancePackage, loadAcceptancePackage } from "./acceptance-package.mjs"; +import { captureBuildInputs, createRunSnapshot } from "./build-snapshot.mjs"; + +const commitSha = "0123456789abcdef0123456789abcdef01234567"; +const iosContract = Object.freeze({ + architecture: "x86_64", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + xcodeBuild: "17F113", + xcodeVersion: "26.6", +}); + +function createSnapshotFixture(t) { + const root = createTemporaryTestDirectory(t, "plogkit-acceptance-package-repository-"); + execFileSync("git", ["init", "--quiet"], { cwd: root }); + writeFileSync(join(root, ".gitignore"), "build/\n"); + writeFileSync(join(root, "app.ts"), "export const version = 1;\n"); + mkdirSync(join(root, "e2e", "fixtures"), { recursive: true }); + mkdirSync(join(root, "e2e", "flows"), { recursive: true }); + writeFileSync(join(root, "e2e", "config.yaml"), "flows:\n - flows/*\n"); + writeFileSync(join(root, "e2e", "fixtures", "portrait.jpg"), "portrait"); + writeFileSync(join(root, "e2e", "fixtures", "landscape.jpg"), "landscape"); + writeFileSync(join(root, "e2e", "flows", "f01-smoke.yaml"), "appId: fixture\n"); + execFileSync("git", ["add", ".gitignore", "app.ts", "e2e"], { cwd: root }); + + const app = join(root, "build", "PlogKit.app"); + const symbols = join(root, "build", "PlogKit.app.dSYM"); + mkdirSync(app, { recursive: true }); + mkdirSync(symbols, { recursive: true }); + writeFileSync(join(app, "PlogKit"), "simulator executable"); + chmodSync(join(app, "PlogKit"), 0o755); + writeFileSync(join(symbols, "symbols"), "symbols"); + + const artifactRoot = createTemporaryTestDirectory(t, "plogkit-acceptance-package-artifacts-"); + const repositorySha256 = captureBuildInputs(root); + const snapshot = createRunSnapshot({ + artifactRoot, + builds: [{ artifact: app, platform: "ios", sidecars: [symbols] }], + repositorySha256, + root, + }); + return { artifactRoot, repositorySha256, root, snapshot }; +} + +test("a sealed acceptance package round-trips the exact app, sidecars, flows, and fixtures", (t) => { + const fixture = createSnapshotFixture(t); + const packageDirectory = join( + createTemporaryTestDirectory(t, "plogkit-acceptance-package-output-"), + "ios-package", + ); + const created = createAcceptancePackage({ + commitSha, + contract: iosContract, + packageDirectory, + platform: "ios", + repositorySha256: fixture.repositorySha256, + snapshot: fixture.snapshot, + }); + + const manifest = JSON.parse(readFileSync(created.manifest, "utf8")); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.platform, "ios"); + assert.equal(manifest.source.commitSha, commitSha); + assert.equal(manifest.source.repositorySha256, fixture.repositorySha256); + assert.deepEqual(manifest.contract, iosContract); + assert.match(manifest.archive.sha256, /^[a-f0-9]{64}$/); + assert.ok(manifest.archive.bytes > 0); + assert.match(manifest.payload.sha256, /^[a-f0-9]{64}$/); + + const extractionRoot = createTemporaryTestDirectory(t, "plogkit-acceptance-package-extraction-"); + const loaded = loadAcceptancePackage({ + commitSha, + contract: iosContract, + extractionRoot, + packageDirectory, + platform: "ios", + repositorySha256: fixture.repositorySha256, + }); + + assert.equal(readFileSync(join(loaded.artifacts.ios, "PlogKit"), "utf8"), "simulator executable"); + assert.notEqual(statSync(join(loaded.artifacts.ios, "PlogKit")).mode & 0o111, 0); + assert.equal(readFileSync(loaded.fixtures[0], "utf8"), "portrait"); + assert.equal(readFileSync(loaded.fixtures[1], "utf8"), "landscape"); + assert.equal( + readFileSync(join(loaded.e2eRoot, "flows", "f01-smoke.yaml"), "utf8"), + "appId: fixture\n", + ); + assert.equal(readFileSync(join(loaded.e2eRoot, "config.yaml"), "utf8"), "flows:\n - flows/*\n"); +}); + +test("a sealed acceptance package rejects archive mutation before extraction", (t) => { + const fixture = createSnapshotFixture(t); + const packageDirectory = join( + createTemporaryTestDirectory(t, "plogkit-acceptance-package-output-"), + "ios-package", + ); + const created = createAcceptancePackage({ + commitSha, + contract: iosContract, + packageDirectory, + platform: "ios", + repositorySha256: fixture.repositorySha256, + snapshot: fixture.snapshot, + }); + writeFileSync(created.archive, "mutated archive"); + + assert.throws( + () => + loadAcceptancePackage({ + commitSha, + contract: iosContract, + extractionRoot: createTemporaryTestDirectory(t, "plogkit-acceptance-package-extraction-"), + packageDirectory, + platform: "ios", + repositorySha256: fixture.repositorySha256, + }), + /acceptance package archive hash does not match/i, + ); +}); + +test("a sealed acceptance package rejects a different commit or execution contract", (t) => { + const fixture = createSnapshotFixture(t); + const packageDirectory = join( + createTemporaryTestDirectory(t, "plogkit-acceptance-package-output-"), + "ios-package", + ); + createAcceptancePackage({ + commitSha, + contract: iosContract, + packageDirectory, + platform: "ios", + repositorySha256: fixture.repositorySha256, + snapshot: fixture.snapshot, + }); + + const load = (overrides) => + loadAcceptancePackage({ + commitSha, + contract: iosContract, + extractionRoot: createTemporaryTestDirectory(t, "plogkit-acceptance-package-extraction-"), + packageDirectory, + platform: "ios", + repositorySha256: fixture.repositorySha256, + ...overrides, + }); + assert.throws( + () => load({ commitSha: "fedcba9876543210fedcba9876543210fedcba98" }), + /commit SHA does not match/i, + ); + assert.throws( + () => load({ contract: { ...iosContract, architecture: "arm64" } }), + /execution contract does not match/i, + ); +}); + +test("a sealed acceptance package rejects a payload symlink that escapes its root", (t) => { + const fixture = createSnapshotFixture(t); + symlinkSync("../../../../outside", join(fixture.snapshot.e2eRoot, "flows", "escape.yaml")); + + assert.throws( + () => + createAcceptancePackage({ + commitSha, + contract: iosContract, + packageDirectory: join( + createTemporaryTestDirectory(t, "plogkit-acceptance-package-output-"), + "ios-package", + ), + platform: "ios", + repositorySha256: fixture.repositorySha256, + snapshot: fixture.snapshot, + }), + /payload symlink.*outside|payload symlink.*escape/i, + ); +}); diff --git a/scripts/e2e/build-contract.test.mjs b/scripts/e2e/build-contract.test.mjs index 98ae1d2..94cf9b4 100644 --- a/scripts/e2e/build-contract.test.mjs +++ b/scripts/e2e/build-contract.test.mjs @@ -20,6 +20,7 @@ function writeExecutable(path, contents) { function createIosReleaseBuildFixture( t, { + architecture = "arm64", coreExports = [compatibleExpoModulesCoreSymbol], mediaImports = [compatibleExpoModulesCoreSymbol], } = {}, @@ -52,12 +53,12 @@ printf '%s' symbols > "${directory}/ios/build/Build/Products/Release-iphonesimul `#!/bin/sh case "$*" in "lipo -archs ${appBinary}"|"lipo -archs ${coreBinary}"|"lipo -archs ${mediaBinary}") - printf '%s\\n' arm64 ;; - "nm -arch arm64 -gU ${coreBinary}") + printf '%s\\n' ${architecture} ;; + "nm -arch ${architecture} -gU ${coreBinary}") printf '%s\\n' ${coreExports.map((symbol) => `'0000000000001000 T ${symbol}'`).join(" ")} ;; - "nm -arch arm64 -u ${mediaBinary}") + "nm -arch ${architecture} -u ${mediaBinary}") printf '%s\\n' ${mediaImports.map((symbol) => `' U ${symbol}'`).join(" ")} ;; - "nm -arch arm64 -u ${appBinary}") + "nm -arch ${architecture} -u ${appBinary}") printf '%s\\n' ' U _$s10Foundation3URLVMa' ;; *) exit 65 ;; esac @@ -93,6 +94,29 @@ test("iOS E2E builds a standalone Release simulator app", async (t) => { ]); }); +test("iOS E2E builds the x86_64-only Release slice selected by an Intel test host", async (t) => { + const { binaries, commandLog, directory } = createIosReleaseBuildFixture(t, { + architecture: "x86_64", + }); + + const previousPath = process.env.PATH; + process.env.PATH = `${binaries}:${previousPath}`; + try { + await buildIos({ + architecture: "x86_64", + cleanup: { add() {} }, + root: directory, + workers: "2", + }); + } finally { + process.env.PATH = previousPath; + } + + const argumentsLine = readFileSync(commandLog, "utf8").trim().split("\n")[1]; + assert.match(argumentsLine, /ARCHS=x86_64/); + assert.match(argumentsLine, /ONLY_ACTIVE_ARCH=YES/); +}); + test("iOS Release build rejects an embedded ExpoModulesCore ABI mismatch", async (t) => { const missingSymbol = `${compatibleExpoModulesCoreSymbol}Missing`; const { binaries, directory } = createIosReleaseBuildFixture(t, { diff --git a/scripts/e2e/build-snapshot.mjs b/scripts/e2e/build-snapshot.mjs index 9b29a2c..d6ffbf0 100644 --- a/scripts/e2e/build-snapshot.mjs +++ b/scripts/e2e/build-snapshot.mjs @@ -1,12 +1,14 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { + closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, - readFileSync, + openSync, + readSync, readdirSync, readlinkSync, renameSync, @@ -21,7 +23,21 @@ function updateHash(hash, label, value) { hash.update(buffer); } -function hashPath(path) { +function updateHashFromFile(hash, label, path, bytes) { + hash.update(`${label.length}:${label}:${bytes}:`); + const descriptor = openSync(path, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let read; + while ((read = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) { + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } +} + +export function hashPath(path) { if (!existsSync(path)) throw new Error(`Build or E2E input is missing: ${path}`); const hash = createHash("sha256"); const visit = (entryPath, relativePath) => { @@ -31,6 +47,7 @@ function hashPath(path) { return; } if (stat.isDirectory()) { + updateHash(hash, `mode:${relativePath}`, stat.mode & 0o777); updateHash(hash, "directory", relativePath); for (const entry of readdirSync(entryPath).sort()) { visit(join(entryPath, entry), relativePath ? join(relativePath, entry) : entry); @@ -38,7 +55,8 @@ function hashPath(path) { return; } if (!stat.isFile()) throw new Error(`Unsupported build or E2E input: ${entryPath}`); - updateHash(hash, `file:${relativePath}`, readFileSync(entryPath)); + updateHash(hash, `mode:${relativePath}`, stat.mode & 0o777); + updateHashFromFile(hash, `file:${relativePath}`, entryPath, stat.size); }; visit(path, ""); return hash.digest("hex"); @@ -67,8 +85,10 @@ function repositoryFingerprint(root) { } const stat = lstatSync(absolutePath); if (stat.isSymbolicLink()) updateHash(hash, `link:${path}`, readlinkSync(absolutePath)); - else if (stat.isFile()) updateHash(hash, `file:${path}`, readFileSync(absolutePath)); - else throw new Error(`Unsupported repository build input: ${path}`); + else if (stat.isFile()) { + updateHash(hash, `mode:${path}`, stat.mode & 0o777); + updateHashFromFile(hash, `file:${path}`, absolutePath, stat.size); + } else throw new Error(`Unsupported repository build input: ${path}`); } return hash.digest("hex"); } @@ -166,6 +186,7 @@ export function createRunSnapshot({ artifactRoot, builds, repositorySha256, root builds: provenanceBuilds, e2eSha256: hashPath(e2eRoot), repositorySha256, + schemaVersion: 1, }, null, 2, diff --git a/scripts/e2e/build-snapshot.test.mjs b/scripts/e2e/build-snapshot.test.mjs index 5ecc516..33d4826 100644 --- a/scripts/e2e/build-snapshot.test.mjs +++ b/scripts/e2e/build-snapshot.test.mjs @@ -69,6 +69,7 @@ test("a run snapshot atomically owns the exact build and Maestro inputs under ar assert.equal(snapshot.fixtures[1], join(snapshot.e2eRoot, "fixtures", "landscape.jpg")); assert.equal(provenance.repositorySha256, repositorySha256); + assert.equal(provenance.schemaVersion, 1); assert.equal(provenance.builds.android.artifact.path, `android/build/${basename(artifact)}`); assert.match(provenance.builds.android.artifact.sha256, /^[a-f0-9]{64}$/); assert.deepEqual( diff --git a/scripts/e2e/ios-native-abi.mjs b/scripts/e2e/ios-native-abi.mjs index 8d6f407..99e2bc0 100644 --- a/scripts/e2e/ios-native-abi.mjs +++ b/scripts/e2e/ios-native-abi.mjs @@ -27,25 +27,31 @@ function binaryArchitectures(binary) { .filter(Boolean); } -function assertArm64Only(binaries) { +function assertArchitecture(architecture) { + if (!["arm64", "x86_64"].includes(architecture)) { + throw new Error(`Unsupported iOS Simulator architecture: ${architecture}.`); + } +} + +function assertArchitectureOnly(binaries, architecture) { for (const { architectures, name } of binaries) { - if (architectures.length !== 1 || architectures[0] !== "arm64") { + if (architectures.length !== 1 || architectures[0] !== architecture) { throw new Error( - `${name} must contain only arm64 for the iOS Release simulator ` + + `${name} must contain only ${architecture} for the iOS Release simulator ` + `contract, but contains: ${architectures.join(" ") || "none"}.`, ); } } } -function inspectConsumer(binary, name, coreExports) { +function inspectConsumer(binary, name, coreExports, architecture) { const architectures = binaryArchitectures(binary); - if (!architectures.includes("arm64")) { - throw new Error(`${name} does not contain the required arm64 simulator slice.`); + if (!architectures.includes(architecture)) { + throw new Error(`${name} does not contain the required ${architecture} simulator slice.`); } const requirements = [ ...symbolsFromNm( - capture("xcrun", ["nm", "-arch", "arm64", "-u", binary], { + capture("xcrun", ["nm", "-arch", architecture, "-u", binary], { timeoutMs: 15000, }), ), @@ -61,7 +67,8 @@ function inspectConsumer(binary, name, coreExports) { return { architectures, name, requirements }; } -export function assertIosExpoModulesCoreAbi(app) { +export function assertIosExpoModulesCoreAbi(app, architecture = "arm64") { + assertArchitecture(architecture); const appBinary = join(app, "PlogKit"); if (!existsSync(appBinary) || !statSync(appBinary).isFile()) { throw new Error(`iOS Release is missing its PlogKit executable: ${appBinary}`); @@ -73,17 +80,19 @@ export function assertIosExpoModulesCoreAbi(app) { } const coreArchitectures = binaryArchitectures(coreBinary); - if (!coreArchitectures.includes("arm64")) { - throw new Error("ExpoModulesCore does not contain the required arm64 simulator slice."); + if (!coreArchitectures.includes(architecture)) { + throw new Error( + `ExpoModulesCore does not contain the required ${architecture} simulator slice.`, + ); } const coreExports = symbolsFromNm( - capture("xcrun", ["nm", "-arch", "arm64", "-gU", coreBinary], { + capture("xcrun", ["nm", "-arch", architecture, "-gU", coreBinary], { timeoutMs: 15000, }), ); let consumers = 0; let requiredSymbols = 0; - const appConsumer = inspectConsumer(appBinary, "PlogKit", coreExports); + const appConsumer = inspectConsumer(appBinary, "PlogKit", coreExports, architecture); const inspectedBinaries = [ appConsumer, { architectures: coreArchitectures, name: "ExpoModulesCore" }, @@ -105,7 +114,12 @@ export function assertIosExpoModulesCoreAbi(app) { ); } if (framework.frameworkName === "ExpoModulesCore") continue; - const consumer = inspectConsumer(framework.binary, framework.frameworkName, coreExports); + const consumer = inspectConsumer( + framework.binary, + framework.frameworkName, + coreExports, + architecture, + ); inspectedBinaries.push(consumer); if (consumer.requirements.length === 0) continue; consumers += 1; @@ -116,6 +130,6 @@ export function assertIosExpoModulesCoreAbi(app) { "iOS Release did not expose any native imports to validate against ExpoModulesCore.", ); } - assertArm64Only(inspectedBinaries); + assertArchitectureOnly(inspectedBinaries, architecture); return { consumers, requiredSymbols }; } diff --git a/scripts/e2e/ios-native-abi.test.mjs b/scripts/e2e/ios-native-abi.test.mjs index 9754a56..05b9965 100644 --- a/scripts/e2e/ios-native-abi.test.mjs +++ b/scripts/e2e/ios-native-abi.test.mjs @@ -16,7 +16,13 @@ function writeExecutable(path, contents) { function createAppFixture( t, - { architectures = "arm64", exportedSymbols, appImports = [], supportImports = null }, + { + architecture = "arm64", + architectures = architecture, + exportedSymbols, + appImports = [], + supportImports = null, + }, ) { const root = createTemporaryTestDirectory(t, "plogkit-ios-abi-"); const app = join(root, "PlogKit.app"); @@ -44,7 +50,7 @@ function createAppFixture( : ` "lipo -archs ${supportBinary}") printf '%s\\n' '${architectures}' ;; - "nm -arch arm64 -u ${supportBinary}") + "nm -arch ${architecture} -u ${supportBinary}") printf '%s\\n' ${supportImports.map((symbol) => `' U ${symbol}'`).join(" ")} ;; `; @@ -53,21 +59,21 @@ function createAppFixture( `#!/bin/sh case "$*" in "lipo -archs ${appBinary}") - printf '%s\\n' arm64 + printf '%s\\n' '${architecture}' ;; "lipo -archs ${coreBinary}"|"lipo -archs ${mediaBinary}"|"lipo -archs ${otherBinary}") printf '%s\\n' '${architectures}' ;; - "nm -arch arm64 -gU ${coreBinary}") + "nm -arch ${architecture} -gU ${coreBinary}") printf '%s\\n' ${exportedSymbols.map((symbol) => `'0000000000001000 T ${symbol}'`).join(" ")} ;; - "nm -arch arm64 -u ${mediaBinary}") + "nm -arch ${architecture} -u ${mediaBinary}") printf '%s\\n' ' U ${compatibleSymbol}' ' U _$s10Foundation3URLVMa' ;; - "nm -arch arm64 -u ${otherBinary}") + "nm -arch ${architecture} -u ${otherBinary}") printf '%s\\n' ' U _$s10Foundation4DataVMa' ;; - "nm -arch arm64 -u ${appBinary}") + "nm -arch ${architecture} -u ${appBinary}") printf '%s\\n' ${appImports.map((symbol) => `' U ${symbol}'`).join(" ")} ;; ${supportCases} @@ -159,6 +165,23 @@ test("iOS Release accepts a PlogKit executable resolved by the embedded ExpoModu } }); +test("iOS Release accepts an x86_64-only simulator package on an Intel host", (t) => { + const fixture = createAppFixture(t, { + architecture: "x86_64", + exportedSymbols: [compatibleSymbol], + }); + const previousPath = process.env.PATH; + process.env.PATH = `${fixture.bin}:${previousPath}`; + try { + assert.deepEqual(assertIosExpoModulesCoreAbi(fixture.app, "x86_64"), { + consumers: 1, + requiredSymbols: 1, + }); + } finally { + process.env.PATH = previousPath; + } +}); + test("iOS Release rejects a framework outside the arm64-only simulator contract", (t) => { const fixture = createAppFixture(t, { architectures: "x86_64 arm64", diff --git a/scripts/e2e/ios-observation.mjs b/scripts/e2e/ios-observation.mjs index 3e877de..0d48763 100644 --- a/scripts/e2e/ios-observation.mjs +++ b/scripts/e2e/ios-observation.mjs @@ -65,6 +65,8 @@ const allowedErrorCodes = new Set([ "E2E_PROCESS_TREE_TERMINATION_FAILED", "ENOSPC", ]); +const allowedJobs = new Set(["ios-isolation-control", "ios-isolation-fresh-host", "ios-maestro"]); +const allowedRunners = new Set(["macos-26", "macos-26-intel"]); const processFamilies = new Map([ ["CoreSimulatorBridge", "core-simulator"], ["CoreSimulatorService", "core-simulator"], @@ -130,9 +132,9 @@ function normalizedRun(environment) { typeof imageVersion === "string" && /^\d{8}\.\d+\.\d+$/.test(imageVersion) ? imageVersion : null, - job: environment.GITHUB_JOB === "ios-maestro" ? "ios-maestro" : null, + job: allowedJobs.has(environment.GITHUB_JOB) ? environment.GITHUB_JOB : null, mode: environment.E2E_FLOW ? "targeted" : "full", - runner: runner === "macos-26" ? runner : null, + runner: allowedRunners.has(runner) ? runner : null, sha: typeof sha === "string" && /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : null, }; } diff --git a/scripts/e2e/ios-observation.test.mjs b/scripts/e2e/ios-observation.test.mjs index 03eac95..90a8b38 100644 --- a/scripts/e2e/ios-observation.test.mjs +++ b/scripts/e2e/ios-observation.test.mjs @@ -114,6 +114,22 @@ test("iOS observations reject runner labels unavailable to this workflow", (t) = assert.equal(readSnapshot(directory).run.runner, null); }); +test("iOS observations distinguish the Intel same-host and fresh-host experiment jobs", (t) => { + for (const job of ["ios-isolation-control", "ios-isolation-fresh-host"]) { + const directory = createTemporaryTestDirectory(t, `plogkit-ios-observation-${job}-`); + createIosObservationRecorder({ + directory, + environment: { + E2E_IOS_RUNNER_LABEL: "macos-26-intel", + GITHUB_JOB: job, + }, + }); + + assert.equal(readSnapshot(directory).run.runner, "macos-26-intel"); + assert.equal(readSnapshot(directory).run.job, job); + } +}); + test("iOS observations keep only bounded numeric host evidence", async (t) => { const directory = createTemporaryTestDirectory(t, "plogkit-ios-host-observation-"); const recorder = createIosObservationRecorder({ diff --git a/scripts/e2e/ios.mjs b/scripts/e2e/ios.mjs index de91b77..c1c3c19 100644 --- a/scripts/e2e/ios.mjs +++ b/scripts/e2e/ios.mjs @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { homedir, platform } from "node:os"; +import { arch as hostArch, homedir, platform } from "node:os"; import { join, resolve } from "node:path"; import { performance } from "node:perf_hooks"; @@ -37,6 +37,23 @@ const iosGuestHealthTimeoutMs = 60000; const iosGuestHealthMaxBytes = 1024 * 1024; const iosCleanupStageErrorMaxBytes = 64 * 1024; +export function iosSimulatorArchitecture(architecture = hostArch()) { + if (architecture === "arm64") return "arm64"; + if (architecture === "x64") return "x86_64"; + if (architecture === "x86_64") return "x86_64"; + throw new Error(`Unsupported iOS E2E host architecture: ${architecture}.`); +} + +export function iosAcceptanceContract(architecture = iosSimulatorArchitecture()) { + return Object.freeze({ + architecture: iosSimulatorArchitecture(architecture), + deviceTypeIdentifier, + runtimeIdentifier, + xcodeBuild: requiredXcodeBuild, + xcodeVersion: requiredXcodeVersion, + }); +} + function boundedEvidence(value, maxBytes) { const source = Buffer.isBuffer(value) ? value : Buffer.from(value); if (source.length <= maxBytes) return source; @@ -57,6 +74,7 @@ export function validateIosHost() { if (platform() !== "darwin") { throw new Error("iOS E2E requires macOS. Run the Android-only command on this host."); } + iosSimulatorArchitecture(); } export function validateIosToolchain() { @@ -345,7 +363,14 @@ function configureIosEnglishLocale(deviceId) { log("ios", "Simulator locale: en-US."); } -export async function buildIos({ cleanup, observation, root, workers }) { +export async function buildIos({ + architecture = iosSimulatorArchitecture(), + cleanup, + observation, + root, + workers, +}) { + const selectedArchitecture = iosSimulatorArchitecture(architecture); log("ios", "Building the standalone Release app without booting a simulator."); const args = [ "-workspace", @@ -362,7 +387,13 @@ export async function buildIos({ cleanup, observation, root, workers }) { "ios/build", ]; if (workers) args.push("-jobs", workers); - args.push("-quiet", "ARCHS=arm64", "ONLY_ACTIVE_ARCH=YES", "CODE_SIGNING_ALLOWED=NO", "build"); + args.push( + "-quiet", + `ARCHS=${selectedArchitecture}`, + "ONLY_ACTIVE_ARCH=YES", + "CODE_SIGNING_ALLOWED=NO", + "build", + ); await observeIosStage(observation, "ios-release-build", async () => { await run("xcodebuild", args, { cleanup, @@ -370,11 +401,12 @@ export async function buildIos({ cleanup, observation, root, workers }) { env: createStandaloneBuildEnvironment(), timeoutMs: buildTimeoutMs, }); - assertIosStandaloneArtifact(root); + assertIosStandaloneArtifact(root, selectedArchitecture); }); } -export function assertIosStandaloneArtifact(root) { +export function assertIosStandaloneArtifact(root, architecture = iosSimulatorArchitecture()) { + const selectedArchitecture = iosSimulatorArchitecture(architecture); const artifact = iosBuildArtifact(root); const bundle = join(artifact, "main.jsbundle"); if (!existsSync(artifact) || !existsSync(bundle) || !statSync(bundle).isFile()) { @@ -394,7 +426,7 @@ export function assertIosStandaloneArtifact(root) { if (statSync(dwarf).size === 0) { throw new Error(`iOS Release dSYM DWARF binary is empty: ${dwarf}`); } - assertIosExpoModulesCoreAbi(artifact); + assertIosExpoModulesCoreAbi(artifact, selectedArchitecture); } export async function prepareIosDevice({ diff --git a/scripts/e2e/run.mjs b/scripts/e2e/run.mjs index ce25f54..b0cb739 100644 --- a/scripts/e2e/run.mjs +++ b/scripts/e2e/run.mjs @@ -1,5 +1,5 @@ import { existsSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -17,6 +17,7 @@ import { buildIos, captureIosPhotoResources, installAndSeedIos, + iosAcceptanceContract, iosBuildArtifact, iosBuildSidecars, prepareIosDevice, @@ -24,6 +25,11 @@ import { validateIosSimulatorEnvironment, validateIosToolchain, } from "./ios.mjs"; +import { + captureRepositoryCommit, + createAcceptancePackage, + loadAcceptancePackage, +} from "./acceptance-package.mjs"; import { createStandaloneBuildEnvironment, validateHostEnvironment } from "./environment.mjs"; import { captureBuildInputs, createRunSnapshot } from "./build-snapshot.mjs"; import { assessPhotoResourceDelta, startExportAssertionBridge } from "./export-assertion.mjs"; @@ -63,7 +69,13 @@ function recorderErrorIdentity(error) { } function createIosObservation(options, cleanup) { - if (!options.platforms.includes("ios") || !process.env.E2E_PUBLIC_ARTIFACTS_DIR) return null; + if ( + options.mode === "build-package" || + !options.platforms.includes("ios") || + !process.env.E2E_PUBLIC_ARTIFACTS_DIR + ) { + return null; + } try { return createIosRunObservationRecorder({ artifactRoot: options.artifactRoot, @@ -76,9 +88,11 @@ function createIosObservation(options, cleanup) { } } -function parseArguments(argv) { +export function parseArguments(argv) { const target = argv[0] ?? "all"; let flow = process.env.E2E_FLOW || null; + let mode = "complete"; + let packageDirectory = null; for (let index = 1; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--flow") { @@ -92,6 +106,16 @@ function parseArguments(argv) { if (!flow) { throw new Error("--flow requires a flow basename such as f06-session-persistence."); } + } else if (argument === "--build-package" || argument === "--accept-package") { + if (packageDirectory !== null) { + throw new Error("Select exactly one sealed-package mode per E2E invocation."); + } + packageDirectory = argv[index + 1]; + if (!packageDirectory || packageDirectory.startsWith("--")) { + throw new Error(`${argument} requires an acceptance package directory.`); + } + mode = argument === "--build-package" ? "build-package" : "accept-package"; + index += 1; } else { throw new Error(`Unknown argument: ${argument}`); } @@ -103,18 +127,28 @@ function parseArguments(argv) { if (flow && !/^[a-z0-9-]+(?:\.yaml)?$/.test(flow)) { throw new Error("--flow must be a flow basename such as f06-session-persistence."); } + if (mode !== "complete" && target !== "ios") { + throw new Error("Sealed acceptance packages are only supported for iOS."); + } + if (mode === "build-package" && flow) { + throw new Error("A sealed-package build cannot select a business flow."); + } return { flow: flow ? flow.replace(/\.yaml$/, "") : null, + mode, + packageDirectory, platforms: target === "all" ? ["ios", "android"] : [target], target, }; } -function validateBeforePlatformLock({ flow, platforms }) { - for (const fixture of sourceFixtures) { - if (!existsSync(fixture)) throw new Error(`Missing E2E fixture: ${fixture}`); +function validateBeforePlatformLock({ flow, mode, platforms }) { + if (mode !== "accept-package") { + for (const fixture of sourceFixtures) { + if (!existsSync(fixture)) throw new Error(`Missing E2E fixture: ${fixture}`); + } } - if (flow && !existsSync(resolve(root, `e2e/flows/${flow}.yaml`))) { + if (mode !== "accept-package" && flow && !existsSync(resolve(root, `e2e/flows/${flow}.yaml`))) { throw new Error(`Unknown E2E flow: ${flow}`); } const hostEnvironment = validateHostEnvironment(); @@ -126,8 +160,8 @@ function validateBeforePlatformLock({ flow, platforms }) { return hostEnvironment; } -async function validateLockedPlatformEnvironment(platforms, { artifactRoot, cleanup }) { - if (platforms.includes("ios")) { +async function validateLockedPlatformEnvironment(platforms, { artifactRoot, cleanup, mode }) { + if (platforms.includes("ios") && mode !== "build-package") { await validateIosSimulatorEnvironment({ artifactRoot, cleanup }); } } @@ -325,7 +359,7 @@ async function runAcceptance(platforms, { artifactRoot, cleanup, flow, observati log("result", `All ${platforms.join(" + ")} E2E suites passed.`); } -async function runCompleteE2e(options, cleanup, artifactRoot, hostEnvironment, observation) { +async function buildRunSnapshot(options, cleanup, artifactRoot, hostEnvironment, observation) { const repositorySha256 = captureBuildInputs(root); await build(options.platforms, cleanup, hostEnvironment, observation); const snapshot = await observeIosStage(observation, "ios-input-snapshot", () => @@ -342,6 +376,17 @@ async function runCompleteE2e(options, cleanup, artifactRoot, hostEnvironment, o ? "Captured immutable Release run inputs." : `Captured immutable Release run inputs: ${snapshot.provenance}`, ); + return { repositorySha256, snapshot }; +} + +async function runCompleteE2e(options, cleanup, artifactRoot, hostEnvironment, observation) { + const { snapshot } = await buildRunSnapshot( + options, + cleanup, + artifactRoot, + hostEnvironment, + observation, + ); await runAcceptance(options.platforms, { artifactRoot, cleanup, @@ -351,6 +396,65 @@ async function runCompleteE2e(options, cleanup, artifactRoot, hostEnvironment, o }); } +async function buildSealedIosPackage(options, cleanup, artifactRoot, hostEnvironment) { + const { repositorySha256, snapshot } = await buildRunSnapshot( + options, + cleanup, + artifactRoot, + hostEnvironment, + null, + ); + const packageDirectory = resolve(options.packageDirectory); + const relativePackagePath = relative(artifactRoot, packageDirectory); + if ( + relativePackagePath === "" || + (!relativePackagePath.startsWith("..") && !isAbsolute(relativePackagePath)) + ) { + throw new Error( + "The sealed acceptance package destination must be outside temporary artifacts.", + ); + } + const packaged = createAcceptancePackage({ + commitSha: captureRepositoryCommit(root), + contract: iosAcceptanceContract(), + packageDirectory, + platform: "ios", + repositorySha256, + snapshot, + }); + log( + "ios", + process.env.CI + ? "Created sealed iOS acceptance package." + : `Created sealed iOS acceptance package: ${packaged.manifest}`, + ); +} + +async function runSealedIosAcceptance(options, cleanup, artifactRoot, observation) { + const repositorySha256 = captureBuildInputs(root); + const snapshot = await observeIosStage(observation, "ios-input-snapshot", () => + loadAcceptancePackage({ + commitSha: captureRepositoryCommit(root), + contract: iosAcceptanceContract(), + extractionRoot: artifactRoot, + packageDirectory: resolve(options.packageDirectory), + platform: "ios", + repositorySha256, + }), + ); + if (options.flow && !existsSync(join(snapshot.e2eRoot, "flows", `${options.flow}.yaml`))) { + throw new Error(`Unknown E2E flow in sealed acceptance package: ${options.flow}`); + } + log("setup", "Verified sealed iOS acceptance package identity and contents."); + await runAcceptance(["ios"], { + artifactRoot, + cleanup, + flow: options.flow, + observation, + snapshot, + }); +} + async function main() { const options = parseArguments(process.argv.slice(2)); const cleanup = createCleanupManager(); @@ -361,6 +465,7 @@ async function main() { const publishFailureArtifacts = async () => { if ( artifactRoot === null || + options.mode === "build-package" || !options.platforms.includes("ios") || !process.env.E2E_PUBLIC_ARTIFACTS_DIR ) { @@ -382,7 +487,7 @@ async function main() { }); let operationError = null; try { - validateMaestroVersion(); + if (options.mode !== "build-package") validateMaestroVersion(); const hostEnvironment = validateBeforePlatformLock(options); artifactRoot = createArtifactRoot(); try { @@ -399,15 +504,29 @@ async function main() { } observation = createIosObservation({ ...options, artifactRoot }, cleanup); await observeIosStage(observation, "ios-simulator-environment", () => - validateAfterAcquiringPlatformLocks(options.platforms, { artifactRoot, cleanup }), + validateAfterAcquiringPlatformLocks(options.platforms, { + artifactRoot, + cleanup, + mode: options.mode, + }), ); log( "setup", process.env.CI - ? `Running ${options.target} Release E2E.` - : `Running ${options.target} Release E2E; artifacts: ${artifactRoot}`, + ? options.mode === "build-package" + ? "Building a sealed iOS Release acceptance package." + : options.mode === "accept-package" + ? "Running iOS Release E2E from a sealed acceptance package." + : `Running ${options.target} Release E2E.` + : `Running ${options.target} ${options.mode}; artifacts: ${artifactRoot}`, ); - await runCompleteE2e(options, cleanup, artifactRoot, hostEnvironment, observation); + if (options.mode === "build-package") { + await buildSealedIosPackage(options, cleanup, artifactRoot, hostEnvironment); + } else if (options.mode === "accept-package") { + await runSealedIosAcceptance(options, cleanup, artifactRoot, observation); + } else { + await runCompleteE2e(options, cleanup, artifactRoot, hostEnvironment, observation); + } } catch (error) { operationError = error; } diff --git a/scripts/e2e/runner-cli.test.mjs b/scripts/e2e/runner-cli.test.mjs index dff3d29..8a5da72 100644 --- a/scripts/e2e/runner-cli.test.mjs +++ b/scripts/e2e/runner-cli.test.mjs @@ -160,6 +160,76 @@ test("the runner rejects the removed cross-process phase interface", () => { assert.match(result.stderr, /Unknown argument: --phase/); }); +test("the runner exposes explicit sealed-package build and acceptance modes", async () => { + const { parseArguments } = await import("./run.mjs"); + assert.deepEqual(parseArguments(["ios", "--build-package", "/tmp/package"]), { + flow: null, + mode: "build-package", + packageDirectory: "/tmp/package", + platforms: ["ios"], + target: "ios", + }); + assert.deepEqual( + parseArguments(["ios", "--accept-package", "/tmp/package", "--flow", "f01-add-text"]), + { + flow: "f01-add-text", + mode: "accept-package", + packageDirectory: "/tmp/package", + platforms: ["ios"], + target: "ios", + }, + ); + assert.throws( + () => + parseArguments([ + "ios", + "--build-package", + "/tmp/package", + "--accept-package", + "/tmp/package", + ]), + /exactly one sealed-package mode/i, + ); + assert.throws( + () => parseArguments(["android", "--build-package", "/tmp/package"]), + /only supported for iOS/i, + ); +}); + +test( + "sealed-package build does not require Maestro before validating the iOS host", + { skip: process.platform === "darwin" }, + (t) => { + const directory = createTemporaryTestDirectory(t, "plogkit-runner-ios-package-build-"); + const binaries = join(directory, "bin"); + mkdirSync(binaries); + writeExecutable(join(binaries, "pnpm"), "#!/bin/sh\nprintf '%s\\n' '11.21.0'\n"); + writeExecutable( + join(binaries, "java"), + "#!/bin/sh\nprintf '%s\\n' ' java.home = /tmp/temurin' ' java.runtime.version = 17.0.20+8' ' java.vendor = Eclipse Adoptium' >&2\n", + ); + + const result = runCli( + ["scripts/e2e/run.mjs", "ios", "--build-package", join(directory, "package")], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${binaries}:${process.env.PATH}`, + TEMP: directory, + TMP: directory, + TMPDIR: directory, + }, + }, + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /iOS E2E requires macOS/); + assert.doesNotMatch(result.stderr, /Maestro .* is required/); + }, +); + test("the runner rejects an incomplete flow selector before validation", () => { const result = runCli(["scripts/e2e/run.mjs", "android", "--flow"], { cwd: root, diff --git a/scripts/e2e/workflow-contract.test.mjs b/scripts/e2e/workflow-contract.test.mjs index 660c93d..ee56e39 100644 --- a/scripts/e2e/workflow-contract.test.mjs +++ b/scripts/e2e/workflow-contract.test.mjs @@ -43,8 +43,8 @@ test("CI and local E2E share exact repository-owned host tool versions", () => { assert.match(workflow, /actions\/setup-java@v5\.7\.0/g); assert.match(workflow, /java-version-file: \.java-version/g); assert.match(workflow, /MAESTRO_VERSION="\$\(< \.maestro-version\)"/g); - assert.equal((workflow.match(/checksums_sha256\.txt/g) ?? []).length, 6); - assert.equal((workflow.match(/shasum -a 256 -c checksums_sha256\.txt/g) ?? []).length, 2); + assert.equal((workflow.match(/checksums_sha256\.txt/g) ?? []).length, 12); + assert.equal((workflow.match(/shasum -a 256 -c checksums_sha256\.txt/g) ?? []).length, 4); assert.doesNotMatch(workflow, /get\.maestro\.mobile\.dev/); assert.match( workflow, @@ -73,7 +73,7 @@ test("each mobile job has one complete E2E step and diagnostic-upload headroom", const workflow = readFileSync(workflowPath, "utf8"); assert.equal((workflow.match(/^\s+timeout-minutes: 165$/gm) ?? []).length, 2); assert.equal((workflow.match(/^\s+timeout-minutes: 135$/gm) ?? []).length, 2); - assert.equal((workflow.match(/run: pnpm e2e:ios/g) ?? []).length, 1); + assert.equal((workflow.match(/run: pnpm e2e:ios$/gm) ?? []).length, 1); assert.equal((workflow.match(/run: pnpm e2e:android/g) ?? []).length, 1); assert.equal((workflow.match(/path: \$\{\{ runner\.temp \}\}\/plogkit-e2e$/gm) ?? []).length, 2); assert.match( @@ -97,3 +97,27 @@ test("manual dispatch selects only a platform and optional business flow", () => assert.doesNotMatch(workflow, /ios_runner|macos-26-xlarge/); assert.doesNotMatch(workflow, /soak|iterations|--phase/); }); + +test("the iOS isolation experiment reuses one sealed package on same and fresh Intel hosts", () => { + const workflow = readFileSync(workflowPath, "utf8"); + assert.match( + workflow, + /ios_execution:[\s\S]*default: same-host[\s\S]*- same-host[\s\S]*- isolation-pair/, + ); + assert.equal((workflow.match(/runs-on: macos-26-intel/g) ?? []).length, 2); + assert.equal((workflow.match(/--build-package/g) ?? []).length, 1); + assert.equal((workflow.match(/--accept-package/g) ?? []).length, 2); + assert.match( + workflow, + /Build sealed iOS acceptance package[\s\S]*Run same-host iOS acceptance control[\s\S]*Upload sealed iOS acceptance package/, + ); + assert.match( + workflow, + /needs: ios-isolation-control[\s\S]*always\(\).*package-ready == 'true'[\s\S]*actions\/download-artifact@v8\.0\.1[\s\S]*Run fresh-host iOS packaged acceptance/, + ); + assert.equal((workflow.match(/name: ios-sealed-acceptance-package/g) ?? []).length, 2); + assert.equal((workflow.match(/actions\/upload-artifact@v7\.0\.1/g) ?? []).length, 5); + assert.match(workflow, /retention-days: 1/); + assert.equal((workflow.match(/E2E_IOS_RUNNER_LABEL: macos-26-intel/g) ?? []).length, 2); + assert.doesNotMatch(workflow, /retry|sleep|macos-15|macos-26-xlarge|continue-on-error/); +}); From 0ebdb4f131132055a64fa83d5207fc478cb7d724 Mon Sep 17 00:00:00 2001 From: zhangyiming Date: Sat, 15 Aug 2026 23:03:38 +0800 Subject: [PATCH 2/6] fix(e2e): preserve isolation evidence contracts --- .github/workflows/e2e.yml | 4 ++-- docs/guides/dev-environment.md | 2 +- scripts/e2e/acceptance-package.mjs | 20 +------------------- scripts/e2e/acceptance-package.test.mjs | 3 +++ scripts/e2e/build-contract.test.mjs | 15 ++++++++++++++- scripts/e2e/build-snapshot.mjs | 16 +++------------- scripts/e2e/file-hash.mjs | 21 +++++++++++++++++++++ scripts/e2e/ios.mjs | 14 ++++++++++---- scripts/e2e/workflow-contract.test.mjs | 4 ++++ 9 files changed, 59 insertions(+), 40 deletions(-) create mode 100644 scripts/e2e/file-hash.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fcd39b4..1cec89c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -91,7 +91,7 @@ jobs: (inputs.platform == 'all' || inputs.platform == 'ios') && inputs.ios_execution == 'isolation-pair' runs-on: macos-26-intel - timeout-minutes: 120 + timeout-minutes: 150 outputs: package-ready: ${{ steps.package-upload.outcome == 'success' }} steps: @@ -156,7 +156,7 @@ jobs: needs: ios-isolation-control if: ${{ always() && needs.ios-isolation-control.outputs.package-ready == 'true' }} runs-on: macos-26-intel - timeout-minutes: 75 + timeout-minutes: 90 steps: - uses: actions/checkout@v7.0.1 - uses: pnpm/action-setup@v6.0.10 diff --git a/docs/guides/dev-environment.md b/docs/guides/dev-environment.md index f3889a8..a0c0cc1 100644 --- a/docs/guides/dev-environment.md +++ b/docs/guides/dev-environment.md @@ -161,7 +161,7 @@ pnpm e2e:android 上述三个命令都会在单次 runner 调用中完成 clean prebuild、Release 构建、产物快照、临时设备、安装与验收,不复用 development build 或可变的旧产物。`pnpm e2e` 在一台 Mac 上按 iOS、Android 顺序执行;GitHub 使用两台独立 runner 并行运行相同的平台入口。 -GitHub 的 #101 手动实验在现有 `Mobile simulator E2E` workflow 中选择 `ios_execution=isolation-pair`。构建端使用显式 `--build-package` 生成一次 sealed acceptance package;同宿主控制和 fresh-host 实验分别使用 `--accept-package` 消费它。这两个参数是配对实验接口,不是复用本地旧 build 的捷径:package 必须属于同一 workflow、同一 commit、同一仓库输入和同一 x86_64/Xcode/runtime 契约,任一身份或内容 hash 不符都会在创建设备前失败。普通 `pnpm e2e:ios` 仍保持单进程完整事务。 +GitHub 的 #101 手动实验在现有 `Mobile simulator E2E` workflow 中选择 `ios_execution=isolation-pair`。构建端使用显式 `--build-package` 生成一次 sealed acceptance package;同宿主控制和 fresh-host 实验分别使用 `--accept-package` 消费它。这两个参数是配对实验接口,不是复用本地旧 build 的捷径:package 必须属于同一 workflow、同一 commit、同一仓库输入和同一 x86_64/Xcode/runtime/Release 构建契约,任一身份或内容 hash 不符都会在创建设备前失败。普通 `pnpm e2e:ios` 仍保持单进程完整事务。 定位已知失败时可只运行一条 flow;它仍走完整 Release 构建与设备生命周期: diff --git a/scripts/e2e/acceptance-package.mjs b/scripts/e2e/acceptance-package.mjs index 91bac0a..581f32a 100644 --- a/scripts/e2e/acceptance-package.mjs +++ b/scripts/e2e/acceptance-package.mjs @@ -1,14 +1,10 @@ -import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { - closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, - openSync, readFileSync, - readSync, readdirSync, realpathSync, renameSync, @@ -19,6 +15,7 @@ import { import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { hashPath } from "./build-snapshot.mjs"; +import { sha256File } from "./file-hash.mjs"; const schemaVersion = 1; const manifestFilename = "acceptance-package.json"; @@ -59,21 +56,6 @@ function canonicalJson(value) { return JSON.stringify(value); } -function sha256File(path) { - const hash = createHash("sha256"); - const descriptor = openSync(path, "r"); - const buffer = Buffer.allocUnsafe(1024 * 1024); - try { - let read; - while ((read = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) { - hash.update(buffer.subarray(0, read)); - } - } finally { - closeSync(descriptor); - } - return hash.digest("hex"); -} - function assertPayloadContained(root) { const resolvedRoot = realpathSync(root); const prefix = `${resolvedRoot}${sep}`; diff --git a/scripts/e2e/acceptance-package.test.mjs b/scripts/e2e/acceptance-package.test.mjs index 6f3c15c..8131896 100644 --- a/scripts/e2e/acceptance-package.test.mjs +++ b/scripts/e2e/acceptance-package.test.mjs @@ -11,8 +11,11 @@ import { captureBuildInputs, createRunSnapshot } from "./build-snapshot.mjs"; const commitSha = "0123456789abcdef0123456789abcdef01234567"; const iosContract = Object.freeze({ architecture: "x86_64", + configuration: "Release", deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro", runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + scheme: "PlogKit", + sdk: "iphonesimulator", xcodeBuild: "17F113", xcodeVersion: "26.6", }); diff --git a/scripts/e2e/build-contract.test.mjs b/scripts/e2e/build-contract.test.mjs index 94cf9b4..491144c 100644 --- a/scripts/e2e/build-contract.test.mjs +++ b/scripts/e2e/build-contract.test.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { createTemporaryTestDirectory } from "../test-support/temp-directory.mjs"; import { androidBuildArtifact, androidBuildSidecars, buildAndroid } from "./android.mjs"; -import { buildIos, iosBuildArtifact, iosBuildSidecars } from "./ios.mjs"; +import { buildIos, iosAcceptanceContract, iosBuildArtifact, iosBuildSidecars } from "./ios.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const compatibleExpoModulesCoreSymbol = @@ -117,6 +117,19 @@ test("iOS E2E builds the x86_64-only Release slice selected by an Intel test hos assert.match(argumentsLine, /ONLY_ACTIVE_ARCH=YES/); }); +test("the iOS acceptance contract explicitly binds the native Release build", () => { + assert.deepEqual(iosAcceptanceContract("x86_64"), { + architecture: "x86_64", + configuration: "Release", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + scheme: "PlogKit", + sdk: "iphonesimulator", + xcodeBuild: "17F113", + xcodeVersion: "26.6", + }); +}); + test("iOS Release build rejects an embedded ExpoModulesCore ABI mismatch", async (t) => { const missingSymbol = `${compatibleExpoModulesCoreSymbol}Missing`; const { binaries, directory } = createIosReleaseBuildFixture(t, { diff --git a/scripts/e2e/build-snapshot.mjs b/scripts/e2e/build-snapshot.mjs index d6ffbf0..c5efa39 100644 --- a/scripts/e2e/build-snapshot.mjs +++ b/scripts/e2e/build-snapshot.mjs @@ -1,14 +1,11 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { - closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, - openSync, - readSync, readdirSync, readlinkSync, renameSync, @@ -17,6 +14,8 @@ import { } from "node:fs"; import { basename, join, relative } from "node:path"; +import { updateHashWithFileContents } from "./file-hash.mjs"; + function updateHash(hash, label, value) { const buffer = Buffer.isBuffer(value) ? value : Buffer.from(String(value)); hash.update(`${label.length}:${label}:${buffer.length}:`); @@ -25,16 +24,7 @@ function updateHash(hash, label, value) { function updateHashFromFile(hash, label, path, bytes) { hash.update(`${label.length}:${label}:${bytes}:`); - const descriptor = openSync(path, "r"); - const buffer = Buffer.allocUnsafe(1024 * 1024); - try { - let read; - while ((read = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) { - hash.update(buffer.subarray(0, read)); - } - } finally { - closeSync(descriptor); - } + updateHashWithFileContents(hash, path); } export function hashPath(path) { diff --git a/scripts/e2e/file-hash.mjs b/scripts/e2e/file-hash.mjs new file mode 100644 index 0000000..308107e --- /dev/null +++ b/scripts/e2e/file-hash.mjs @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import { closeSync, openSync, readSync } from "node:fs"; + +export function updateHashWithFileContents(hash, path) { + const descriptor = openSync(path, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let read; + while ((read = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) { + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } +} + +export function sha256File(path) { + const hash = createHash("sha256"); + updateHashWithFileContents(hash, path); + return hash.digest("hex"); +} diff --git a/scripts/e2e/ios.mjs b/scripts/e2e/ios.mjs index c1c3c19..312e568 100644 --- a/scripts/e2e/ios.mjs +++ b/scripts/e2e/ios.mjs @@ -26,7 +26,10 @@ const requiredCocoaPodsVersion = "1.17.0"; const runtimeIdentifier = "com.apple.CoreSimulator.SimRuntime.iOS-26-5"; const deviceTypeName = "iPhone 17 Pro"; const deviceTypeIdentifier = "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro"; -const appPath = "ios/build/Build/Products/Release-iphonesimulator/PlogKit.app"; +const buildConfiguration = "Release"; +const buildScheme = "PlogKit"; +const buildSdk = "iphonesimulator"; +const appPath = `ios/build/Build/Products/${buildConfiguration}-${buildSdk}/PlogKit.app`; const buildTimeoutMs = 45 * 60 * 1000; const deviceLifecycleTimeoutMs = 3 * 60 * 1000; const hostLifecycleProbeTimeoutMs = 2 * 60 * 1000; @@ -47,8 +50,11 @@ export function iosSimulatorArchitecture(architecture = hostArch()) { export function iosAcceptanceContract(architecture = iosSimulatorArchitecture()) { return Object.freeze({ architecture: iosSimulatorArchitecture(architecture), + configuration: buildConfiguration, deviceTypeIdentifier, runtimeIdentifier, + scheme: buildScheme, + sdk: buildSdk, xcodeBuild: requiredXcodeBuild, xcodeVersion: requiredXcodeVersion, }); @@ -376,11 +382,11 @@ export async function buildIos({ "-workspace", "ios/PlogKit.xcworkspace", "-scheme", - "PlogKit", + buildScheme, "-configuration", - "Release", + buildConfiguration, "-sdk", - "iphonesimulator", + buildSdk, "-destination", "generic/platform=iOS Simulator", "-derivedDataPath", diff --git a/scripts/e2e/workflow-contract.test.mjs b/scripts/e2e/workflow-contract.test.mjs index ee56e39..a631975 100644 --- a/scripts/e2e/workflow-contract.test.mjs +++ b/scripts/e2e/workflow-contract.test.mjs @@ -119,5 +119,9 @@ test("the iOS isolation experiment reuses one sealed package on same and fresh I assert.equal((workflow.match(/actions\/upload-artifact@v7\.0\.1/g) ?? []).length, 5); assert.match(workflow, /retention-days: 1/); assert.equal((workflow.match(/E2E_IOS_RUNNER_LABEL: macos-26-intel/g) ?? []).length, 2); + assert.match( + workflow, + /ios-isolation-control:[\s\S]*?timeout-minutes: 150[\s\S]*ios-isolation-fresh-host:[\s\S]*?timeout-minutes: 90/, + ); assert.doesNotMatch(workflow, /retry|sleep|macos-15|macos-26-xlarge|continue-on-error/); }); From f9a0b58ecc4b9c05d517194b8c0d34a7ad286657 Mon Sep 17 00:00:00 2001 From: zhangyiming Date: Sat, 15 Aug 2026 23:06:26 +0800 Subject: [PATCH 3/6] test(e2e): decouple redaction from process latency --- scripts/e2e/runtime.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/runtime.test.mjs b/scripts/e2e/runtime.test.mjs index d5ebe6b..f6ffc93 100644 --- a/scripts/e2e/runtime.test.mjs +++ b/scripts/e2e/runtime.test.mjs @@ -150,7 +150,8 @@ test("bounded commands can omit sensitive output from their primary error", asyn captureBoundedCommand(command, [], { includeOutputInError: false, maxBytes: 1024, - timeoutMs: 1000, + // This asserts redaction, not process-start latency; shared CI needs scheduling headroom. + timeoutMs: 5000, }), (error) => { assert.doesNotMatch(error.message, /Users\/runner|private-catalog/); From fab6ee5a45bfeb13e8b5c4b24564751ed6ebf681 Mon Sep 17 00:00:00 2001 From: zhangyiming Date: Sat, 15 Aug 2026 23:34:37 +0800 Subject: [PATCH 4/6] test(e2e): pin fixture simulator architectures --- scripts/e2e/build-contract.test.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/build-contract.test.mjs b/scripts/e2e/build-contract.test.mjs index 491144c..53325d1 100644 --- a/scripts/e2e/build-contract.test.mjs +++ b/scripts/e2e/build-contract.test.mjs @@ -73,7 +73,12 @@ test("iOS E2E builds a standalone Release simulator app", async (t) => { const previousPath = process.env.PATH; process.env.PATH = `${binaries}:${previousPath}`; try { - await buildIos({ cleanup: { add() {} }, root: directory, workers: "2" }); + await buildIos({ + architecture: "arm64", + cleanup: { add() {} }, + root: directory, + workers: "2", + }); } finally { process.env.PATH = previousPath; } @@ -139,7 +144,12 @@ test("iOS Release build rejects an embedded ExpoModulesCore ABI mismatch", async process.env.PATH = `${binaries}:${previousPath}`; try { await assert.rejects( - buildIos({ cleanup: { add() {} }, root: directory, workers: "2" }), + buildIos({ + architecture: "arm64", + cleanup: { add() {} }, + root: directory, + workers: "2", + }), /ExpoMediaLibrary.*symbol.*missing.*Missing/s, ); } finally { From 5e606d54052266bc8aebede9eae0ca1fde19252b Mon Sep 17 00:00:00 2001 From: zhangyiming Date: Sat, 15 Aug 2026 23:46:08 +0800 Subject: [PATCH 5/6] fix(e2e): bound Maestro cold-start validation --- scripts/e2e/runtime.mjs | 40 +++++++++++++++++++++--------------- scripts/e2e/runtime.test.mjs | 22 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/scripts/e2e/runtime.mjs b/scripts/e2e/runtime.mjs index bf5cee7..d846ee1 100644 --- a/scripts/e2e/runtime.mjs +++ b/scripts/e2e/runtime.mjs @@ -298,15 +298,7 @@ function boundedCommandError(command, args, result, { includeOutputInError, maxB export async function captureBoundedCommand( command, args, - { - cleanup, - cwd, - env = process.env, - includeOutputInError = true, - maxBytes, - terminate, - timeoutMs, - }, + { cleanup, cwd, env = process.env, includeOutputInError = true, maxBytes, terminate, timeoutMs }, ) { const result = await captureDiagnostic(command, args, { captureStdout: true, @@ -761,16 +753,32 @@ const MAESTRO_VERSION = readFileSync(join(runtimeRoot, ".maestro-version"), "utf const MAESTRO_FLOW_TIMEOUT_MS = 10 * 60 * 1000; const MAESTRO_SUITE_TIMEOUT_MS = 60 * 60 * 1000; const IOS_MAESTRO_DRIVER_STARTUP_TIMEOUT_MS = 2 * 60 * 1000; +const MAESTRO_VERSION_PROBE_TIMEOUT_MS = 60 * 1000; -export function validateMaestroVersion() { +export function validateMaestroVersion({ + captureVersion = (timeoutMs) => + capture("maestro", ["--version"], { + env: createMaestroEnvironment(), + timeoutMs, + }), + timeoutMs = MAESTRO_VERSION_PROBE_TIMEOUT_MS, +} = {}) { let output; try { - output = capture("maestro", ["--version"], { - env: createMaestroEnvironment(), - timeoutMs: 15000, - }); - } catch { - throw new Error(`Maestro ${MAESTRO_VERSION} is required but was not found on PATH.`); + output = captureVersion(timeoutMs); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`Maestro ${MAESTRO_VERSION} is required but was not found on PATH.`, { + cause: error, + }); + } + if (error?.code === "ETIMEDOUT") { + throw Object.assign( + new Error(`Maestro version probe timed out after ${timeoutMs}ms.`, { cause: error }), + { code: "E2E_COMMAND_TIMEOUT" }, + ); + } + throw new Error("Unable to execute the installed Maestro version probe.", { cause: error }); } const installedVersion = output.match(/\d+\.\d+\.\d+/)?.[0]; if (!installedVersion) { diff --git a/scripts/e2e/runtime.test.mjs b/scripts/e2e/runtime.test.mjs index f6ffc93..f99d392 100644 --- a/scripts/e2e/runtime.test.mjs +++ b/scripts/e2e/runtime.test.mjs @@ -571,6 +571,28 @@ esac ); }); +test("Maestro version validation distinguishes a bounded cold start from a missing executable", () => { + const timeoutError = Object.assign(new Error("spawnSync maestro ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + + assert.throws( + () => + validateMaestroVersion({ + captureVersion(timeoutMs) { + assert.equal(timeoutMs, 60_000); + throw timeoutError; + }, + }), + (error) => { + assert.equal(error.code, "E2E_COMMAND_TIMEOUT"); + assert.match(error.message, /version probe timed out after 60000ms/i); + assert.doesNotMatch(error.message, /not found on PATH/i); + return true; + }, + ); +}); + test("Maestro validation and execution use the same PATH executable", async (t) => { const directory = createTemporaryTestDirectory(t, "plogkit-e2e-maestro-path-"); const binaries = join(directory, "bin"); From 2819604dd9d44b90e56ef4dfad71bf0793dc1d2d Mon Sep 17 00:00:00 2001 From: zhangyiming Date: Sun, 16 Aug 2026 10:37:26 +0800 Subject: [PATCH 6/6] fix(e2e): bound CocoaPods cold-start validation --- scripts/e2e/ios.mjs | 36 +++++++++++++++++++++++++++++------- scripts/e2e/ios.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/scripts/e2e/ios.mjs b/scripts/e2e/ios.mjs index 312e568..04a53eb 100644 --- a/scripts/e2e/ios.mjs +++ b/scripts/e2e/ios.mjs @@ -39,6 +39,7 @@ const iosPrepareEvidenceProbeTimeoutMs = 5000; const iosGuestHealthTimeoutMs = 60000; const iosGuestHealthMaxBytes = 1024 * 1024; const iosCleanupStageErrorMaxBytes = 64 * 1024; +const cocoaPodsVersionProbeTimeoutMs = 60 * 1000; export function iosSimulatorArchitecture(architecture = hostArch()) { if (architecture === "arm64") return "arm64"; @@ -83,7 +84,13 @@ export function validateIosHost() { iosSimulatorArchitecture(); } -export function validateIosToolchain() { +export function validateIosToolchain({ + captureCocoaPodsVersion = (timeoutMs) => + capture("pod", ["--version"], { + timeoutMs, + }), + cocoaPodsProbeTimeoutMs = cocoaPodsVersionProbeTimeoutMs, +} = {}) { const xcodePath = capture("xcode-select", ["-p"], { allowFailure: true, timeoutMs: 15000, @@ -96,11 +103,26 @@ export function validateIosToolchain() { if (xcodeVersion) { for (const line of xcodeVersion.split("\n")) log("ios", ` ${line}`); } - const cocoaPodsVersion = capture("pod", ["--version"], { - allowFailure: true, - timeoutMs: 15000, - }); - log("ios", `CocoaPods: ${cocoaPodsVersion ?? "unknown"}`); + let cocoaPodsVersion; + try { + cocoaPodsVersion = captureCocoaPodsVersion(cocoaPodsProbeTimeoutMs); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`CocoaPods ${requiredCocoaPodsVersion} is required but was not found on PATH.`, { + cause: error, + }); + } + if (error?.code === "ETIMEDOUT") { + throw Object.assign( + new Error(`CocoaPods version probe timed out after ${cocoaPodsProbeTimeoutMs}ms.`, { + cause: error, + }), + { code: "E2E_COMMAND_TIMEOUT" }, + ); + } + throw new Error("Unable to execute the installed CocoaPods version probe.", { cause: error }); + } + log("ios", `CocoaPods: ${cocoaPodsVersion}`); const selectedXcodeVersion = xcodeVersion?.match(/^Xcode\s+(.+)$/m)?.[1] ?? "unknown"; const selectedXcodeBuild = xcodeVersion?.match(/^Build version\s+(.+)$/m)?.[1] ?? "unknown"; if (selectedXcodeVersion !== requiredXcodeVersion || selectedXcodeBuild !== requiredXcodeBuild) { @@ -111,7 +133,7 @@ export function validateIosToolchain() { } if (cocoaPodsVersion !== requiredCocoaPodsVersion) { throw new Error( - `CocoaPods ${requiredCocoaPodsVersion} is required, but ${cocoaPodsVersion ?? "unknown"} is installed.`, + `CocoaPods ${requiredCocoaPodsVersion} is required, but ${cocoaPodsVersion} is installed.`, ); } log("ios", "iOS toolchain validation passed."); diff --git a/scripts/e2e/ios.test.mjs b/scripts/e2e/ios.test.mjs index 1d4802e..1e7c62e 100644 --- a/scripts/e2e/ios.test.mjs +++ b/scripts/e2e/ios.test.mjs @@ -604,6 +604,40 @@ test("iOS rejects a host outside the pinned Xcode toolchain", async (t) => { }, /Xcode 26\.6 \(17F113\) is required, but Xcode 27\.0 \(27A5218g\) is selected/); }); +test("iOS preserves a CocoaPods cold-start timeout instead of reporting an unknown version", async (t) => { + const directory = createTemporaryTestDirectory(t, "plogkit-ios-cocoapods-timeout-"); + writeExecutable( + join(directory, "xcode-select"), + "#!/bin/sh\nprintf '%s\\n' '/Applications/Xcode_26.6.app/Contents/Developer'\n", + ); + writeExecutable( + join(directory, "xcodebuild"), + "#!/bin/sh\nprintf '%s\\n' 'Xcode 26.6' 'Build version 17F113'\n", + ); + writeExecutable(join(directory, "pod"), "#!/bin/sh\nprintf '%s\\n' '1.17.0'\n"); + const timeoutError = Object.assign(new Error("spawnSync pod ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + + await withEnvironment({ PATH: `${directory}:${process.env.PATH}` }, async () => { + assert.throws( + () => + validateIosToolchain({ + captureCocoaPodsVersion(timeoutMs) { + assert.equal(timeoutMs, 60_000); + throw timeoutError; + }, + }), + (error) => { + assert.equal(error.code, "E2E_COMMAND_TIMEOUT"); + assert.match(error.message, /CocoaPods version probe timed out after 60000ms/); + assert.doesNotMatch(error.message, /unknown is installed/); + return true; + }, + ); + }); +}); + function writeIosSimulatorHostBinary(binaries) { writeExecutable( join(binaries, "xcrun"),