From f6c5710702de2bb33c53d950160493497cdefc9b Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 3 May 2026 07:22:31 +0900 Subject: [PATCH] Layer 3 Phase 5c: artifact discovery for iOS .app and Android .apk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Layer 2 build mode produces the app bundle / APK, callers need the artifact path + identifier to feed into runVisualJudge (#40, #41). Hardcoding paths or guessing identifiers from the slug is fragile — the substrate's iOS bundle ID is `com..App.ios${TEAM}` where ${TEAM} is the developer's signing team, resolved at build time. The right source of truth is the build outputs. Adds two resolvers: discoverIosArtifact(iosDir): 1. Find *.xcodeproj, scheme = filename minus extension 2. xcodebuild -showBuildSettings -json → BUILT_PRODUCTS_DIR + WRAPPER_NAME 3. plutil -extract CFBundleIdentifier raw on the built Info.plist Returns {appPath, bundleId} | null discoverAndroidArtifact(androidDir): 1. apkPath = app/build/outputs/apk/debug/app-debug.apk (predictable) 2. Parse `applicationId = "..."` from app/build.gradle.kts Returns {apkPath, packageName} | null Both return null gracefully when: - Build hasn't happened (.app / .apk missing) - Project layout doesn't match (missing .xcodeproj, missing build.gradle.kts, etc.) - Tooling fails (xcodebuild / plutil exit non-zero, JSON parse fails) Why post-build for iOS (vs. parsing project.pbxproj at the source): the substrate's PRODUCT_BUNDLE_IDENTIFIER is `com..App.ios${SAMPLE_CODE_DISAMBIGUATOR}` where SAMPLE_CODE_DISAMBIGUATOR=${DEVELOPMENT_TEAM}. Pre-build it's a template; post-build the .app's Info.plist has the resolved value (e.g. ".iosNNYDL5U3V3" with the user's team ID). Reading the resolved form makes installAndLaunch's bundle ID match what's actually installed. Real-mode smoke: against existing out/vet-clinic-queue/, Android returned null (no apk built yet), iOS returned a fully-resolved {appPath, bundleId} from a prior xcodebuild run that lived in DerivedData. Both behaved as designed. Tests: 14/14 npm run ci green. Out of scope (Phase 5d): - Wire discovery into a higher-level runner that does build → discover → runVisualJudge in one call. - Plumb that runner into dispatch with a flag/env var to opt in to Stage 1 visual judging. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/validation/discover.ts | 121 +++++++++++++++++++++++++++++++++++++ src/validation/index.ts | 3 + tests/smoke.test.ts | 14 ++++- 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/validation/discover.ts diff --git a/src/validation/discover.ts b/src/validation/discover.ts new file mode 100644 index 0000000..73d912b --- /dev/null +++ b/src/validation/discover.ts @@ -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 { + 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 { + 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 | 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 }>; + resolvePromise(arr[0]?.buildSettings ?? null); + } catch { + resolvePromise(null); + } + }); + child.on("error", () => resolvePromise(null)); + }); +} + +async function readBundleId(infoPlistPath: string): Promise { + 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)); + }); +} diff --git a/src/validation/index.ts b/src/validation/index.ts index 7f8d213..01f8687 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -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"; diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 7a2805c..fa9b515 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -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", () => { @@ -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", () => {