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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/validation/discover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { spawn } from "node:child_process";
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { scrubbedEnv } from "../env.js";

export type IosArtifact = { appPath: string; bundleId: string };
export type AndroidArtifact = { apkPath: string; packageName: string };

// Matches Layer 2 build mode's IOS_DESTINATION so showBuildSettings sees the
// same SDK/configuration that `xcodebuild build` produced.
const IOS_DESTINATION = "platform=iOS Simulator,name=iPhone 17,OS=26.2";

// After Layer 2 build mode has built the iOS .app, ask xcodebuild where it
// landed and read the bundle identifier from the built Info.plist (so any
// $(VAR) interpolations are already resolved). Returns null if the build
// hasn't happened, the project layout doesn't match expectations, or the
// build settings query fails.
export async function discoverIosArtifact(iosDir: string): Promise<IosArtifact | null> {
const entries = await readdir(iosDir).catch(() => [] as string[]);
const xcodeproj = entries.find((e) => e.endsWith(".xcodeproj"));
if (!xcodeproj) return null;
const scheme = xcodeproj.replace(/\.xcodeproj$/, "");

const settings = await runShowBuildSettings(iosDir, xcodeproj, scheme);
if (!settings) return null;
const builtProductsDir = settings["BUILT_PRODUCTS_DIR"];
const wrapperName = settings["WRAPPER_NAME"];
if (!builtProductsDir || !wrapperName) return null;

const appPath = join(builtProductsDir, wrapperName);
const appExists = await stat(appPath).then(() => true).catch(() => false);
if (!appExists) return null;

const bundleId = await readBundleId(join(appPath, "Info.plist"));
if (!bundleId) return null;

return { appPath, bundleId };
}

// Android `applicationId` lives in `app/build.gradle.kts`; the APK path is
// predictable. Returns null if the build hasn't happened or the gradle file
// doesn't have an `applicationId = "..."` line.
export async function discoverAndroidArtifact(androidDir: string): Promise<AndroidArtifact | null> {
const apkPath = join(androidDir, "app", "build", "outputs", "apk", "debug", "app-debug.apk");
const apkExists = await stat(apkPath).then(() => true).catch(() => false);
if (!apkExists) return null;

const gradlePath = join(androidDir, "app", "build.gradle.kts");
let gradle: string;
try {
gradle = await readFile(gradlePath, "utf8");
} catch {
return null;
}
const match = gradle.match(/applicationId\s*=\s*"([^"]+)"/);
if (!match || !match[1]) return null;

return { apkPath, packageName: match[1] };
}

async function runShowBuildSettings(
cwd: string,
xcodeproj: string,
scheme: string,
): Promise<Record<string, string> | null> {
return new Promise((resolvePromise) => {
let child;
try {
child = spawn(
"xcodebuild",
[
"-project", xcodeproj,
"-scheme", scheme,
"-destination", IOS_DESTINATION,
"-showBuildSettings",
"-json",
],
{ cwd, env: scrubbedEnv(), stdio: ["ignore", "pipe", "pipe"] },
);
} catch {
resolvePromise(null);
return;
}
const stdoutChunks: Buffer[] = [];
child.stdout.on("data", (c: Buffer) => stdoutChunks.push(c));
child.on("close", (code) => {
if (code !== 0) { resolvePromise(null); return; }
try {
const arr = JSON.parse(Buffer.concat(stdoutChunks).toString("utf8")) as Array<{ buildSettings: Record<string, string> }>;
resolvePromise(arr[0]?.buildSettings ?? null);
} catch {
resolvePromise(null);
}
});
child.on("error", () => resolvePromise(null));
});
}

async function readBundleId(infoPlistPath: string): Promise<string | null> {
return new Promise((resolvePromise) => {
let child;
try {
child = spawn(
"plutil",
["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
{ env: scrubbedEnv(), stdio: ["ignore", "pipe", "pipe"] },
);
} catch {
resolvePromise(null);
return;
}
const stdoutChunks: Buffer[] = [];
child.stdout.on("data", (c: Buffer) => stdoutChunks.push(c));
child.on("close", (code) => {
if (code !== 0) { resolvePromise(null); return; }
const out = Buffer.concat(stdoutChunks).toString("utf8").trim();
resolvePromise(out || null);
});
child.on("error", () => resolvePromise(null));
});
}
3 changes: 3 additions & 0 deletions src/validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ export type { LaunchInput, IosLaunchInput, AndroidLaunchInput, LaunchResult } fr

export { runVisualJudge, DEFAULT_STAGE1_RUBRIC } from "./visual-judge.js";
export type { VisualJudgeInput, VisualJudgeResult } from "./visual-judge.js";

export { discoverIosArtifact, discoverAndroidArtifact } from "./discover.js";
export type { IosArtifact, AndroidArtifact } from "./discover.js";
14 changes: 13 additions & 1 deletion tests/smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { runLayer1, runLayer2, runLayer3, captureScreenshot, installAndLaunch, runVisualJudge, DEFAULT_STAGE1_RUBRIC } from "../src/validation/index.js";
import { runLayer1, runLayer2, runLayer3, captureScreenshot, installAndLaunch, runVisualJudge, DEFAULT_STAGE1_RUBRIC, discoverIosArtifact, discoverAndroidArtifact } from "../src/validation/index.js";
import { dispatch } from "../src/dispatch.js";

test("validation layers are exported as functions", () => {
Expand All @@ -10,6 +10,18 @@ test("validation layers are exported as functions", () => {
assert.equal(typeof captureScreenshot, "function");
assert.equal(typeof installAndLaunch, "function");
assert.equal(typeof runVisualJudge, "function");
assert.equal(typeof discoverIosArtifact, "function");
assert.equal(typeof discoverAndroidArtifact, "function");
});

test("discoverAndroidArtifact returns null for missing dir", async () => {
const result = await discoverAndroidArtifact("/nonexistent/path/to/android");
assert.equal(result, null);
});

test("discoverIosArtifact returns null for missing dir", async () => {
const result = await discoverIosArtifact("/nonexistent/path/to/ios");
assert.equal(result, null);
});

test("DEFAULT_STAGE1_RUBRIC has the expected criteria ids", () => {
Expand Down
Loading