improve(ci): shard iOS screenshot capture (#128460)

* perf(ci): shard iOS screenshot capture

* fix(ci): bind iOS screenshot shard evidence

* fix(ci): preserve screenshot capture attempts

* fix(ci): align screenshot shards with Xcode 26.6

* fix(ci): provision screenshot shard Fastlane

* fix(ci): normalize Fastlane provenance version

* fix(ci): select installed Fastlane version
This commit is contained in:
Vincent Koc
2026-08-24 16:59:06 -07:00
committed by GitHub
parent 4c8c22867e
commit e27433c22e
10 changed files with 1803 additions and 67 deletions
+13 -3
View File
@@ -3126,7 +3126,7 @@ NODE
expect(codeqlSelect.run).toContain('if [[ "$xcode_version" != 26.6* ]]; then');
for (const [workflowPath, selectorCount] of [
[".github/workflows/ci.yml", 1],
[".github/workflows/ci.yml", 2],
[".github/workflows/ios-periphery.yml", 1],
[".github/workflows/macos-periphery.yml", 1],
[".github/workflows/shared-openclawkit-periphery.yml", 2],
@@ -3320,6 +3320,7 @@ NODE
"control-ui-i18n": "ubuntu-24.04",
"docker-seed-e2e": "ubuntu-24.04",
"ios-build": "macos-26",
"ios-screenshot-shard": "macos-26",
"macos-node": "macos-15",
"macos-swift": "macos-26",
"native-i18n": "ubuntu-24.04",
@@ -3346,6 +3347,7 @@ NODE
"macos-node": "blacksmith-6vcpu-macos-15",
"macos-swift": "blacksmith-12vcpu-macos-26",
"ios-build": "blacksmith-12vcpu-macos-26",
"ios-screenshot-shard": "blacksmith-12vcpu-macos-26",
"check-test-types-hosted-core-shard": "blacksmith-8vcpu-ubuntu-2404",
"checks-ui": "blacksmith-8vcpu-ubuntu-2404",
"checks-windows": "blacksmith-8vcpu-windows-2025",
@@ -5805,10 +5807,16 @@ server.listen(0, "127.0.0.1", () => writeFileSync(readyPath, String(server.addre
const workflow = readCiWorkflow();
expect(source.match(/&platform_checkout_step/gu) ?? []).toHaveLength(1);
expect(source.match(/\*platform_checkout_step/gu) ?? []).toHaveLength(3);
expect(source.match(/\*platform_checkout_step/gu) ?? []).toHaveLength(4);
expect(source.match(/fetch_checkout_ref_once\(\)/gu) ?? []).toHaveLength(1);
for (const jobName of ["checks-windows", "macos-node", "macos-swift", "ios-build"]) {
for (const jobName of [
"checks-windows",
"macos-node",
"macos-swift",
"ios-build",
"ios-screenshot-shard",
]) {
const checkoutStep = workflow.jobs[jobName].steps.find(
(step: WorkflowStep) => step.name === "Checkout",
);
@@ -7786,6 +7794,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
"macos-node",
"macos-swift",
"ios-build",
"ios-screenshot-shard",
"ios-screenshot-evidence",
"android",
"docker-seed-e2e",
];
+104 -24
View File
@@ -127,11 +127,13 @@ function swiftFunctionBody(source: string, name: string): string {
}
describe("iOS Fastlane release upload gates", () => {
it("pins the CI Ruby and Fastlane toolchain", () => {
it("pins the CI Ruby and Fastlane toolchain on the Fastlane-owning screenshot shards", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const iosJobStart = workflow.indexOf("\n ios-build:\n");
const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart);
const iosJobEnd = workflow.indexOf("\n ios-screenshot-shard:\n", iosJobStart);
const iosJob = workflow.slice(iosJobStart, iosJobEnd);
const shardJobEnd = workflow.indexOf("\n ios-screenshot-evidence:\n", iosJobEnd);
const shardJob = workflow.slice(iosJobEnd, shardJobEnd);
const gemfile = readFileSync(gemfilePath, "utf8");
const lockfile = readFileSync(gemfileLockPath, "utf8");
@@ -144,16 +146,22 @@ describe("iOS Fastlane release upload gates", () => {
expect(lockfile).toContain("CHECKSUMS");
expect(lockfile).toContain("RUBY VERSION\n ruby 3.4.10");
expect(lockfile).toContain("BUNDLED WITH\n 2.6.9");
expect(iosJob).toContain('BUNDLE_DEPLOYMENT: "true"');
expect(iosJob).toContain("BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile");
expect(iosJob).toContain("ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b");
expect(iosJob).toContain('ruby-version: "3.4.10"');
expect(iosJob).toContain('bundler: "2.6.9"');
expect(iosJob).toContain("bundler-cache: false");
expect(iosJob).toContain("working-directory: apps/ios");
expect(iosJob).toContain("bundle _2.6.9_ install --jobs 4 --retry 3");
expect(iosJob).toContain("bundle _2.6.9_ check");
expect(iosJob).toContain("bundle _2.6.9_ exec fastlane --version");
expect(iosJob).not.toContain("BUNDLE_DEPLOYMENT");
expect(iosJob).not.toContain("BUNDLE_GEMFILE");
expect(iosJob).not.toContain("ruby/setup-ruby@");
expect(iosJob).not.toContain("Install locked Fastlane bundle");
expect(shardJob).toContain('BUNDLE_DEPLOYMENT: "true"');
expect(shardJob).toContain("BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile");
expect(shardJob).toContain("ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b");
expect(shardJob).toContain('ruby-version: "3.4.10"');
expect(shardJob).toContain('bundler: "2.6.9"');
expect(shardJob).toContain("bundler-cache: false");
expect(shardJob).toContain("working-directory: apps/ios");
expect(shardJob).toContain("bundle _2.6.9_ install --jobs 4 --retry 3");
expect(shardJob).toContain("bundle _2.6.9_ check");
expect(shardJob).toContain("bundle _2.6.9_ exec fastlane --version");
expect(workflow.match(/ruby\/setup-ruby@/gu)).toHaveLength(1);
expect(workflow.match(/name: Install locked Fastlane bundle/gu)).toHaveLength(1);
});
it("documents every iOS Fastlane command through the pinned bundle", () => {
@@ -446,6 +454,8 @@ describe("iOS Fastlane release upload gates", () => {
const screenshots = laneBody(fastfile, "screenshots");
const capture = functionBody(fastfile, "capture_release_ios_screenshot!");
const archive = functionBody(fastfile, "archive_snapshot_test_result!");
const attemptRecorder = functionBody(fastfile, "record_release_ios_screenshot_attempt!");
const attemptWriter = functionBody(fastfile, "write_release_ios_screenshot_attempts!");
const verifier = functionBody(fastfile, "verify_snapshot_test_result!");
expect(screenshots).toContain("devices = snapshot_devices");
@@ -465,11 +475,22 @@ describe("iOS Fastlane release upload gates", () => {
expect(capture).toContain("stop_after_first_error: true");
expect(capture).toContain("retrying once in a fresh simulator session");
expect(capture).toContain("verify_snapshot_test_result!");
expect(capture).toContain('capture_outcome: "failed"');
expect(capture).toContain('capture_outcome: "succeeded"');
expect(capture.indexOf('capture_outcome: "failed"')).toBeLessThan(
capture.indexOf("raise if attempt == 2"),
);
expect(attemptRecorder).toContain('"captureOutcome" => capture_outcome');
expect(attemptRecorder).toContain("write_release_ios_screenshot_attempts!(");
expect(attemptWriter).toContain('"schemaVersion" => 1');
expect(archive).toContain('"#{device}-#{screenshot_name}-attempt-#{attempt}.xcresult"');
expect(screenshots).toContain("verify_release_ios_screenshot_manifest!(");
expect(screenshots).toContain(
'result_bundle_archive_directory = File.join(ios_root, "build", "SnapshotTestResults")',
);
expect(screenshots).toContain(
'capture_attempts_path = File.join(result_bundle_archive_directory, "capture-attempts.json")',
);
expect(screenshots.indexOf("capture_release_ios_screenshot!")).toBeLessThan(
screenshots.indexOf('FileUtils.rm_rf(File.join(output_directory, "test_output"))'),
);
@@ -515,9 +536,17 @@ describe("iOS Fastlane release upload gates", () => {
it("requires the exact nonempty PNG manifest before Watch capture", () => {
const fastfile = readFastfile();
const screenshots = laneBody(fastfile, "screenshots");
const snapshotDevices = functionBody(fastfile, "snapshot_devices");
const defaultSnapshotDevices = functionBody(fastfile, "default_snapshot_devices");
const verifier = functionBody(fastfile, "verify_release_ios_screenshot_manifest!");
expect(fastfile).toContain("REQUIRED_IOS_SCREENSHOT_NAMES");
expect(snapshotDevices).toContain('ENV["OPENCLAW_SNAPSHOT_DEVICES"]');
expect(snapshotDevices).toContain("return default_snapshot_devices if raw.empty?");
expect(defaultSnapshotDevices).toContain("available_simulator_devices");
expect(defaultSnapshotDevices).toContain('ENV["OPENCLAW_SNAPSHOT_DEVICE_FAMILY"]');
expect(defaultSnapshotDevices).toContain("families = DEFAULT_SNAPSHOT_DEVICE_FAMILIES");
expect(defaultSnapshotDevices).toContain("families = [family]");
expect(verifier).toContain("expected_names - actual_names");
expect(verifier).toContain("actual_names - expected_names");
expect(verifier).toContain("File.size?(path)");
@@ -528,25 +557,76 @@ describe("iOS Fastlane release upload gates", () => {
expect(screenshots.indexOf("verify_release_ios_screenshot_manifest!")).toBeLessThan(
screenshots.indexOf("watch_screenshot("),
);
expect(screenshots).toContain('ENV["OPENCLAW_SNAPSHOT_SKIP_WATCH"] == "1"');
});
it("runs the exact screenshot lane during native Apple, manual, and full release CI", () => {
it("shards exact screenshot evidence without changing runner authorization", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const iosJobStart = workflow.indexOf("\n ios-build:\n");
const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart);
const iosJobEnd = workflow.indexOf("\n ios-screenshot-shard:\n", iosJobStart);
const iosJob = workflow.slice(iosJobStart, iosJobEnd);
const shardJobStart = iosJobEnd;
const shardJobEnd = workflow.indexOf("\n ios-screenshot-evidence:\n", shardJobStart);
const shardJob = workflow.slice(shardJobStart, shardJobEnd);
const reducerJobStart = shardJobEnd;
const reducerJobEnd = workflow.indexOf("\n android:\n", reducerJobStart);
const reducerJob = workflow.slice(reducerJobStart, reducerJobEnd);
expect(workflow).toContain('IOS_SCREENSHOT_NODE_VERSION: "24.16.0"');
expect(workflow).toContain('IOS_SCREENSHOT_XCODE_VERSION: "Xcode 26.6 Build version 17F113"');
expect(iosJob).toContain("timeout-minutes: 150");
expect(iosJob).toContain("Capture iOS release screenshots");
expect(iosJob).toContain("github.event_name == 'workflow_dispatch'");
expect(iosJob).toContain("github.event_name == 'pull_request'");
expect(iosJob).toContain("inputs.release_gate");
expect(iosJob).toContain("needs.preflight.outputs.run_ios_screenshots == 'true'");
expect(iosJob).not.toContain("needs.preflight.outputs.run_macos == 'true'");
expect(iosJob).toContain("run: pnpm ios:screenshots");
expect(iosJob).toContain("Upload iOS release screenshot evidence");
expect(iosJob).toContain("apps/ios/build/SnapshotTestResults/*.xcresult");
expect(iosJob).toContain("if-no-files-found: error");
expect(iosJob).not.toContain("Capture iOS release screenshots");
expect(shardJob).toContain("needs: [preflight, ios-build]");
expect(shardJob).toContain("max-parallel: 2");
expect(shardJob).toContain("device_family: [iphone, ipad-13]");
expect(shardJob).toContain('OPENCLAW_SNAPSHOT_SKIP_WATCH: "1"');
expect(shardJob).toContain("if: matrix.device_family == 'iphone'");
expect(shardJob).toContain("run_ios_fastlane ios watch_screenshot");
expect(shardJob).toContain("run: pnpm ios:screenshots");
expect(shardJob).toContain("id: package_screenshot_evidence");
expect(shardJob).toContain("steps.package_screenshot_evidence.outcome == 'failure'");
expect(shardJob).toContain("apps/ios/build/SnapshotTestResults/capture-attempts.json");
expect(shardJob).not.toContain("IOS_SCREENSHOT_FASTLANE_VERSION");
expect(shardJob).toContain("IOS_SCREENSHOT_NODE_VERSION");
expect(shardJob).toContain("IOS_SCREENSHOT_XCODE_VERSION");
expect(shardJob).not.toContain('test "$fastlane_version" = "$IOS_SCREENSHOT_FASTLANE_VERSION"');
expect(shardJob).toContain("node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}");
expect(shardJob).not.toContain("SnapshotDerivedData");
expect(shardJob.match(/contents: read/g)).toHaveLength(1);
expect(reducerJob).toContain("needs: [preflight, ios-screenshot-shard]");
expect(reducerJob).toContain("merge-multiple: false");
expect(reducerJob).toContain("Setup screenshot evidence Node");
expect(reducerJob).toContain("node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}");
expect(reducerJob).toContain("id: reduce_screenshot_evidence");
expect(reducerJob).toContain("scripts/ios-screenshot-evidence.mjs reduce");
expect(reducerJob).toContain('--workflow-sha "$WORKFLOW_SHA"');
expect(reducerJob).toContain('--run-id "$RUN_ID"');
expect(reducerJob).toContain('--run-attempt "$RUN_ATTEMPT"');
expect(reducerJob).toContain('--xcode-version "$IOS_SCREENSHOT_XCODE_VERSION"');
expect(reducerJob).toContain('--fastlane-version "$IOS_SCREENSHOT_FASTLANE_VERSION"');
expect(reducerJob).toContain('--node-version "$(node --version)"');
expect(reducerJob).toContain("steps.reduce_screenshot_evidence.outcome == 'failure'");
expect(reducerJob).toContain("apps/ios/build/ScreenshotEvidenceInputs/**/xcresults/*.xcresult");
expect(reducerJob).toContain(
"name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}",
);
expect(reducerJob).toContain("apps/ios/build/ScreenshotEvidence/manifest.json");
expect(reducerJob).not.toContain("SnapshotDerivedData");
});
it("records the installed Fastlane semver before the update notice", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const parser = workflow.match(/run_ios_fastlane --version 2>&1 \| awk '([^']+)'/u)?.[1];
expect(parser).toBeDefined();
const result = spawnSync("awk", [parser!], {
encoding: "utf8",
input:
"fastlane installation at path:\nfastlane 2.236.1\n# fastlane 2.238.0 is available. You are on 2.236.1.\n",
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("2.236.1\n");
});
it("preserves caller-pinned Swift tools in archive build PATH", () => {
@@ -0,0 +1,563 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectIosScreenshotEvidence,
reduceIosScreenshotEvidence,
} from "../../scripts/ios-screenshot-evidence.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const TARGET_SHA = "a".repeat(40);
const WORKFLOW_SHA = "b".repeat(40);
const PNG = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from("fixture"),
]);
const SCREENSHOTS = [
"01-control-connected",
"02-chat-connected",
"03-agent-connected",
"04-settings-connected",
];
const ATTEMPT_MODEL = {
owner: "openclaw",
unit: "capture_ios_screenshots invocation",
maxAttempts: 2,
fastlaneInternalRetries: "workflow-log",
};
type Family = "iphone" | "ipad-13" | "watch";
function provenance(targetSha = TARGET_SHA) {
return {
targetSha,
workflowSha: WORKFLOW_SHA,
runId: "12345",
runAttempt: 2,
tooling: {
xcode: "Xcode 26.6 Build version 17F113",
fastlane: "2.236.1",
node: "v24.16.0",
},
};
}
function writeFamilySource(
root: string,
family: Family,
options: {
retry?: string;
retryTestResult?: "fail" | "pass";
retryWithoutXcresult?: boolean;
} = {},
) {
const screenshots = path.join(root, family, "screenshots");
const xcresults = path.join(root, family, "xcresults");
fs.mkdirSync(screenshots, { recursive: true });
fs.mkdirSync(xcresults, { recursive: true });
const device =
family === "iphone"
? "iPhone 17 Pro Max"
: family === "ipad-13"
? "iPad Pro 13-inch (M5)"
: "Apple Watch Ultra 3 (49mm)";
const names = family === "watch" ? ["01-now-face"] : SCREENSHOTS;
const captureAttempts = [];
for (const name of names) {
fs.writeFileSync(path.join(screenshots, `${device}-${name}.png`), PNG);
if (family !== "watch") {
const retried = options.retry === name;
captureAttempts.push({
deviceName: device,
screenshotName: name,
attempt: 1,
captureOutcome: retried ? "failed" : "succeeded",
});
if (!(retried && options.retryWithoutXcresult)) {
const attemptOne = path.join(xcresults, `${device}-${name}-attempt-1.xcresult`);
fs.mkdirSync(attemptOne, { recursive: true });
fs.writeFileSync(
path.join(attemptOne, "summary.txt"),
retried ? (options.retryTestResult ?? "pass") : "pass",
);
}
if (options.retry === name) {
captureAttempts.push({
deviceName: device,
screenshotName: name,
attempt: 2,
captureOutcome: "succeeded",
});
const attemptTwo = path.join(xcresults, `${device}-${name}-attempt-2.xcresult`);
fs.mkdirSync(attemptTwo, { recursive: true });
fs.writeFileSync(path.join(attemptTwo, "summary.txt"), "pass");
}
}
}
if (family !== "watch") {
fs.writeFileSync(
path.join(xcresults, "capture-attempts.json"),
JSON.stringify({ schemaVersion: 1, attempts: captureAttempts }),
);
}
return { device, screenshots, xcresults };
}
function containerName(family: Family, targetSha = TARGET_SHA) {
const shard = family === "watch" ? "iphone" : family;
return `ios-release-screenshot-shard-${shard}-${targetSha}`;
}
function familyDirectory(input: string, family: Family, targetSha = TARGET_SHA) {
return path.join(input, containerName(family, targetSha), family);
}
function manifestPath(input: string, family: Family, targetSha = TARGET_SHA) {
return path.join(familyDirectory(input, family, targetSha), "manifest.json");
}
function collectAll(
root: string,
targetSha = TARGET_SHA,
options: { retryWithoutXcresult?: boolean } = {},
) {
const output = path.join(root, "collected");
for (const family of ["iphone", "ipad-13", "watch"] as const) {
const source = writeFamilySource(root, family, {
retry: family === "iphone" ? "02-chat-connected" : undefined,
retryWithoutXcresult: family === "iphone" && options.retryWithoutXcresult,
});
collectIosScreenshotEvidence({
family,
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(output, containerName(family, targetSha)),
provenance: provenance(targetSha),
readXcresultSummary: (resultPath) => {
const result = fs.readFileSync(path.join(resultPath, "summary.txt"), "utf8");
return result === "pass"
? { testResult: "Passed", failedTests: 0 }
: { testResult: "Failed", failedTests: 1 };
},
});
}
return output;
}
function reduceAll(input: string, outputRoot: string, expected = provenance()) {
return reduceIosScreenshotEvidence({
inputDirectory: input,
outputRoot,
expectedProvenance: expected,
});
}
function updateManifest(
input: string,
family: Family,
mutate: (manifest: Record<string, any>) => void,
) {
const filePath = manifestPath(input, family);
const manifest = JSON.parse(fs.readFileSync(filePath, "utf8"));
mutate(manifest);
fs.writeFileSync(filePath, JSON.stringify(manifest));
}
function updateAllManifests(input: string, mutate: (manifest: Record<string, any>) => void) {
for (const family of ["iphone", "ipad-13", "watch"] as const) {
updateManifest(input, family, mutate);
}
}
describe("iOS screenshot evidence", () => {
it("reduces the exact device union and models passed retry xcresults by capture outcome", () => {
const root = tempDirs.make("ios-screenshot-evidence-");
const input = collectAll(root);
const output = path.join(root, "reduced");
const manifest = reduceAll(input, output);
const iphoneManifest = JSON.parse(fs.readFileSync(manifestPath(input, "iphone"), "utf8"));
const retryAttempts = iphoneManifest.captureAttempts.filter(
(entry: { screenshotName: string }) => entry.screenshotName === "02-chat-connected",
);
expect(manifest.targetSha).toBe(TARGET_SHA);
expect(manifest.attemptModel).toEqual(ATTEMPT_MODEL);
expect(retryAttempts.map((entry: { captureOutcome: string }) => entry.captureOutcome)).toEqual([
"failed",
"succeeded",
]);
expect(retryAttempts.map((entry: { testResult: string }) => entry.testResult)).toEqual([
"Passed",
"Passed",
]);
expect(fs.readdirSync(path.join(output, "apps/ios/fastlane/screenshots/en-US"))).toHaveLength(
9,
);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-1.xcresult",
),
),
).toBe(true);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-2.xcresult",
),
),
).toBe(true);
});
it("accepts a failed first invocation without an xcresult before a passing retry", () => {
const root = tempDirs.make("ios-screenshot-missing-retry-xcresult-");
const input = collectAll(root, TARGET_SHA, { retryWithoutXcresult: true });
const output = path.join(root, "reduced");
reduceAll(input, output);
const iphoneManifest = JSON.parse(fs.readFileSync(manifestPath(input, "iphone"), "utf8"));
const retryAttempts = iphoneManifest.captureAttempts.filter(
(entry: { screenshotName: string }) => entry.screenshotName === "02-chat-connected",
);
expect(
retryAttempts.map(
(entry: { attempt: number; captureOutcome: string; artifactPath: string | null }) => ({
attempt: entry.attempt,
captureOutcome: entry.captureOutcome,
artifactPath: entry.artifactPath,
}),
),
).toEqual([
{ attempt: 1, captureOutcome: "failed", artifactPath: null },
{
attempt: 2,
captureOutcome: "succeeded",
artifactPath: "xcresults/iPhone 17 Pro Max-02-chat-connected-attempt-2.xcresult",
},
]);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-1.xcresult",
),
),
).toBe(false);
expect(
fs.existsSync(
path.join(
output,
"apps/ios/build/SnapshotTestResults",
"iPhone 17 Pro Max-02-chat-connected-attempt-2.xcresult",
),
),
).toBe(true);
});
it("requires the successful final invocation to have a passing xcresult", () => {
const root = tempDirs.make("ios-screenshot-missing-final-xcresult-");
const source = writeFamilySource(root, "iphone", { retry: "02-chat-connected" });
fs.rmSync(
path.join(source.xcresults, `${source.device}-02-chat-connected-attempt-2.xcresult`),
{ recursive: true },
);
expect(() =>
collectIosScreenshotEvidence({
family: "iphone",
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(root, "collected"),
provenance: provenance(),
readXcresultSummary: () => ({ testResult: "Passed", failedTests: 0 }),
}),
).toThrow("is missing for the successful final capture attempt");
});
it.each([
{
label: "extra",
mutate: (input: string) =>
fs.cpSync(
path.join(input, containerName("ipad-13")),
path.join(input, `ios-release-screenshot-shard-extra-${TARGET_SHA}`),
{ recursive: true },
),
},
{
label: "renamed",
mutate: (input: string) =>
fs.renameSync(
path.join(input, containerName("iphone")),
path.join(input, `ios-release-screenshot-shard-renamed-${TARGET_SHA}`),
),
},
{
label: "missing",
mutate: (input: string) =>
fs.rmSync(path.join(input, containerName("ipad-13")), { recursive: true }),
},
{
label: "swapped",
mutate: (input: string) => {
const iphone = path.join(input, containerName("iphone"));
const ipad = path.join(input, containerName("ipad-13"));
const temporary = path.join(input, "temporary-container");
fs.renameSync(iphone, temporary);
fs.renameSync(ipad, iphone);
fs.renameSync(temporary, ipad);
},
},
])("rejects $label artifact container topology", ({ mutate }) => {
const root = tempDirs.make("ios-screenshot-topology-");
const input = collectAll(root);
mutate(input);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(/topology mismatch/u);
});
it("rejects cross-SHA shard evidence", () => {
const root = tempDirs.make("ios-screenshot-cross-sha-");
const input = collectAll(root);
updateManifest(input, "ipad-13", (manifest) => {
manifest.targetSha = "c".repeat(40);
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"cross-SHA screenshot evidence",
);
});
it.each([
{
label: "workflow SHA",
mutate: (manifest: Record<string, any>) => {
manifest.workflowSha = "c".repeat(40);
},
error: "workflow SHA",
},
{
label: "run id",
mutate: (manifest: Record<string, any>) => {
manifest.runId = "99999";
},
error: "workflow run id",
},
{
label: "run attempt",
mutate: (manifest: Record<string, any>) => {
manifest.runAttempt = 3;
},
error: "workflow run attempt",
},
{
label: "Xcode version",
mutate: (manifest: Record<string, any>) => {
manifest.tooling.xcode = "Xcode 26.6 Build version forged";
},
error: "xcode version",
},
{
label: "Fastlane version",
mutate: (manifest: Record<string, any>) => {
manifest.tooling.fastlane = "2.236.0";
},
error: "fastlane version",
},
{
label: "Node version",
mutate: (manifest: Record<string, any>) => {
manifest.tooling.node = "v24.0.0";
},
error: "node version",
},
])("rejects self-consistent forged $label", ({ mutate, error }) => {
const root = tempDirs.make("ios-screenshot-forged-provenance-");
const input = collectAll(root);
updateAllManifests(input, mutate);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(error);
});
it.each([
{
label: "missing",
mutate: (manifest: Record<string, any>) => {
delete manifest.attemptModel;
},
},
{
label: "owner",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.owner = "fastlane";
},
},
{
label: "unit",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.unit = "launch retry";
},
},
{
label: "maximum",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.maxAttempts = 3;
},
},
{
label: "Fastlane retry ownership",
mutate: (manifest: Record<string, any>) => {
manifest.attemptModel.fastlaneInternalRetries = "xcresult";
},
},
])("rejects $label attempt model changes", ({ mutate }) => {
const root = tempDirs.make("ios-screenshot-attempt-model-");
const input = collectAll(root);
updateManifest(input, "iphone", mutate);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow("unexpected attempt model");
});
it("rejects changed PNG bytes after collection", () => {
const root = tempDirs.make("ios-screenshot-digest-");
const input = collectAll(root);
const screenshot = path.join(
familyDirectory(input, "watch"),
"screenshots",
"Apple Watch Ultra 3 (49mm)-01-now-face.png",
);
fs.appendFileSync(screenshot, "changed");
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"screenshot digest mismatch",
);
});
it("rejects changed xcresult bytes after collection", () => {
const root = tempDirs.make("ios-screenshot-xcresult-digest-");
const input = collectAll(root);
fs.appendFileSync(
path.join(
familyDirectory(input, "iphone"),
"xcresults",
"iPhone 17 Pro Max-01-control-connected-attempt-1.xcresult",
"summary.txt",
),
"changed",
);
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow("xcresult digest mismatch");
});
it("rejects a non-passing final capture attempt", () => {
const root = tempDirs.make("ios-screenshot-final-failure-");
const input = collectAll(root);
updateManifest(input, "ipad-13", (manifest) => {
const final = manifest.captureAttempts.find(
(entry: { screenshotName: string }) => entry.screenshotName === "01-control-connected",
);
final.testResult = "Failed";
final.failedTests = 1;
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"final xcresult is not passing",
);
});
it("rejects a successful capture predecessor before attempt two", () => {
const root = tempDirs.make("ios-screenshot-predecessor-");
const input = collectAll(root);
updateManifest(input, "iphone", (manifest) => {
const predecessor = manifest.captureAttempts.find(
(entry: { attempt: number; screenshotName: string }) =>
entry.screenshotName === "02-chat-connected" && entry.attempt === 1,
);
predecessor.captureOutcome = "succeeded";
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"unexpected capture outcome sequence",
);
});
it("rejects an invalid PNG signature before collection", () => {
const root = tempDirs.make("ios-screenshot-signature-");
const source = writeFamilySource(root, "watch");
fs.writeFileSync(
path.join(source.screenshots, `${source.device}-01-now-face.png`),
"not a png",
);
expect(() =>
collectIosScreenshotEvidence({
family: "watch",
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(root, "collected"),
provenance: provenance(),
}),
).toThrow("invalid PNG signature");
});
it("rejects unexpected screenshots in a device shard", () => {
const root = tempDirs.make("ios-screenshot-unexpected-");
const source = writeFamilySource(root, "iphone");
fs.writeFileSync(path.join(source.screenshots, `${source.device}-99-unexpected.png`), PNG);
expect(() =>
collectIosScreenshotEvidence({
family: "iphone",
screenshotDirectory: source.screenshots,
xcresultDirectory: source.xcresults,
outputDirectory: path.join(root, "collected"),
provenance: provenance(),
readXcresultSummary: () => ({ testResult: "Passed", failedTests: 0 }),
}),
).toThrow("PNG union mismatch");
});
it("rejects unexpected capture attempt entries in a shard manifest", () => {
const root = tempDirs.make("ios-screenshot-unexpected-xcresult-");
const input = collectAll(root);
updateManifest(input, "iphone", (manifest) => {
manifest.captureAttempts.push({
...manifest.captureAttempts[0],
screenshotName: "99-unexpected",
});
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"capture attempt union contains an unexpected screenshot",
);
});
it("rejects unexpected files in a collected shard", () => {
const root = tempDirs.make("ios-screenshot-unexpected-file-");
const input = collectAll(root);
fs.writeFileSync(path.join(familyDirectory(input, "watch"), "unexpected.txt"), "unexpected");
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"shard contains unexpected evidence",
);
});
it("rejects artifact paths outside the declared family directory", () => {
const root = tempDirs.make("ios-screenshot-artifact-path-");
const input = collectAll(root);
updateManifest(input, "watch", (manifest) => {
manifest.screenshots[0].artifactPath = "../iphone/manifest.json";
});
expect(() => reduceAll(input, path.join(root, "reduced"))).toThrow(
"unexpected screenshot artifact path",
);
});
});