mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
1e851f72a0
Fixes #1069 **Cause:** `os.homedir()` reads `USERPROFILE` on Windows and `HOME` elsewhere. The two `ios-device-runner` suites pinned `HOME` only, so on Windows the redirect was inert and they built into `%USERPROFILE%\.argent\ios-device-runner\` in the developer's real home. **Fix (test-only):** pin `USERPROFILE` at the same directory as `HOME` in both fixtures, and add it to the saved record so the existing delete-or-assign restore puts it back. This is what every other `HOME`-swapping suite in the repo already does (`avd-snapshot`, `android-binary`, `adb-resolve-avd-path`, ...); these two files were the only ones left. **Docs:** none needed - no tool, CLI, config key, flow file or user-facing capability changes. <details> <summary>Class check, discrimination proof and checks</summary> Every file assigning `process.env.HOME` in `packages/`, and whether it also pins `USERPROFILE`: ``` $ for f in $(grep -rl "process\.env\.HOME *=" --include="*.ts" packages/); do echo "$(grep -c USERPROFILE $f) $f"; done ... 0 packages/tool-server/test/ios-device-runner-artifact.test.ts 0 packages/tool-server/test/ios-device-runner-launch.test.ts ``` The other 20 already pin both, so the class is exactly these two files. Not moved onto `test/helpers/temp-home.ts`'s `scopeTempHome`: it is a file-level `beforeEach`, while both fixtures swap the home mid-test alongside `PATH` (and `ARGENT_IOS_RUNNER_PROJECT`), and `withEnsureEnv`'s per-test home name is its first argument at 11 call sites. Discrimination - with only the two `process.env.USERPROFILE = ...` assignments deleted, tests kept: ``` FAIL test/ios-device-runner-launch.test.ts > launchRunner > resolves with the launched child and per-device log and bundle paths AssertionError: expected 'PORT=unset ARGS=test-without-building...' to contain 'HOME=/var/.../argent-runner-launch-QkW7KQ USERPROFILE=...' + HOME=/var/folders/.../argent-runner-launch-QkW7KQ USERPROFILE=unset FAIL test/ios-device-runner-artifact.test.ts > ensureRunnerArtifact > redirects os.homedir() under both names it consults Test Files 2 failed (2) Tests 2 failed | 25 passed (27) ``` Restored, both pass. Checks run from the worktree root: - `npx tsc --build` - clean - `npm run typecheck:tests -w @argent/tool-server` - clean - `npm test -w @argent/tool-server` - `Test Files 433 passed (433)`, `Tests 6164 passed | 1 skipped` - `npx prettier --write` on both files - unchanged </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
import * as fsp from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { FAILURE_CODES, getFailureSignal } from "@argent/registry";
|
|
import {
|
|
computeRunnerCacheKey,
|
|
ensureRunnerArtifact,
|
|
isProfileExpiredFailure,
|
|
isProfileMissingDeviceFailure,
|
|
resolveRunnerProjectPath,
|
|
runnerBuildStaticArgs,
|
|
xcodebuildFailureSummary,
|
|
type RunnerArtifact,
|
|
} from "../src/utils/ios-device/runner-artifact";
|
|
import type { RunnerSigningConfig } from "../src/utils/ios-device/runner-signing";
|
|
|
|
let tmpRoot: string;
|
|
beforeAll(async () => {
|
|
tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "argent-runner-artifact-"));
|
|
});
|
|
afterAll(async () => {
|
|
await fsp.rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
const PROJECT = "/opt/argent/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj";
|
|
const CONFIG: RunnerSigningConfig = {
|
|
teamId: "ABCDE12345",
|
|
appBundleId: "com.argent.runner.tabcde12345",
|
|
testBundleId: "com.argent.runner.tabcde12345.uitests",
|
|
};
|
|
|
|
describe("runnerBuildStaticArgs", () => {
|
|
it("always signs automatically under the configured team", () => {
|
|
const args = runnerBuildStaticArgs(PROJECT, CONFIG);
|
|
|
|
expect(args).toContain("CODE_SIGN_STYLE=Automatic");
|
|
expect(args).toContain("DEVELOPMENT_TEAM=ABCDE12345");
|
|
// The manual-signing surface is gone: no argv may carry an identity or a
|
|
// profile, the pair xcodebuild refuses next to automatic signing.
|
|
expect(
|
|
args.filter((a) => /^(CODE_SIGN_IDENTITY|PROVISIONING_PROFILE_SPECIFIER)=/.test(a))
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("computeRunnerCacheKey", () => {
|
|
it("emits a 16-character lowercase hex key", () => {
|
|
const key = computeRunnerCacheKey("srcs", "Xcode 16.4", runnerBuildStaticArgs(PROJECT, CONFIG));
|
|
expect(key).toMatch(/^[0-9a-f]{16}$/);
|
|
});
|
|
|
|
it("changes with the signing config, which rides in via the args", () => {
|
|
const base = computeRunnerCacheKey("srcs", "x", runnerBuildStaticArgs(PROJECT, CONFIG));
|
|
for (const config of [
|
|
{ ...CONFIG, appBundleId: "com.other.argent.runner" },
|
|
{ ...CONFIG, teamId: "FGHIJ67890" },
|
|
]) {
|
|
expect(computeRunnerCacheKey("srcs", "x", runnerBuildStaticArgs(PROJECT, config))).not.toBe(
|
|
base
|
|
);
|
|
}
|
|
});
|
|
|
|
it("changes with the sources and toolchain fingerprints", () => {
|
|
const args = runnerBuildStaticArgs(PROJECT, CONFIG);
|
|
const base = computeRunnerCacheKey("srcs", "Xcode 16.4", args);
|
|
expect(computeRunnerCacheKey("other-srcs", "Xcode 16.4", args)).not.toBe(base);
|
|
expect(computeRunnerCacheKey("srcs", "Xcode 26.0", args)).not.toBe(base);
|
|
});
|
|
|
|
it("keeps the per-run destination/derived-data pair out of the static args", () => {
|
|
const args = runnerBuildStaticArgs(PROJECT, CONFIG);
|
|
expect(args).not.toContain("-destination");
|
|
expect(args).not.toContain("-derivedDataPath");
|
|
});
|
|
});
|
|
|
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function until(cond: () => boolean): Promise<void> {
|
|
for (let i = 0; i < 1_000 && !cond(); i += 1) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
expect(cond()).toBe(true);
|
|
}
|
|
|
|
describe("ensureRunnerArtifact", () => {
|
|
let fakeProject: string;
|
|
let emptyBin: string;
|
|
|
|
beforeAll(async () => {
|
|
// resolveRunnerProjectPath returns the override verbatim; only its parent
|
|
// dir is walked by the source fingerprint, so an empty one suffices.
|
|
fakeProject = path.join(tmpRoot, "fake-runner-proj", "ArgentRunner.xcodeproj");
|
|
await fsp.mkdir(path.dirname(fakeProject), { recursive: true });
|
|
// An empty PATH dir makes the toolchain fingerprint deterministically
|
|
// fall back to "unknown-xcode" instead of shelling out to real Xcode.
|
|
emptyBin = path.join(tmpRoot, "ensure-empty-bin");
|
|
await fsp.mkdir(emptyBin, { recursive: true });
|
|
});
|
|
|
|
/**
|
|
* Run `fn` with HOME — and USERPROFILE, which `os.homedir()` reads on
|
|
* Windows — moved under a per-test dir (so the build dir stays inside the
|
|
* fixture tree), PATH narrowed to `binDir` (empty by default, so
|
|
* nothing reaches the real Xcode), and the project override pointed at
|
|
* `project` (the shared empty fake by default), the env-swap fixture
|
|
* pattern launchRunner's tests established.
|
|
*/
|
|
async function withEnsureEnv<T>(
|
|
name: string,
|
|
fn: () => Promise<T>,
|
|
opts: { binDir?: string; project?: string } = {}
|
|
): Promise<T> {
|
|
const saved = {
|
|
HOME: process.env.HOME,
|
|
USERPROFILE: process.env.USERPROFILE,
|
|
PATH: process.env.PATH,
|
|
PROJECT: process.env.ARGENT_IOS_RUNNER_PROJECT,
|
|
};
|
|
const home = path.join(tmpRoot, `ensure-home-${name}`);
|
|
process.env.HOME = home;
|
|
process.env.USERPROFILE = home;
|
|
process.env.PATH = opts.binDir ?? emptyBin;
|
|
process.env.ARGENT_IOS_RUNNER_PROJECT = opts.project ?? fakeProject;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
// Assigning back an undefined saved value writes the string "undefined",
|
|
// which os.homedir() then resolves as a relative path — the same
|
|
// delete-or-assign the PROJECT restore below already does.
|
|
for (const [name, value] of Object.entries(saved)) {
|
|
const key = name === "PROJECT" ? "ARGENT_IOS_RUNNER_PROJECT" : name;
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A build seam that mints the base xctestrun exactly where the real build
|
|
* arm would, counting invocations; `gate` holds the build mid-flight (after
|
|
* the count, before any file exists) so a test can pin what happens while a
|
|
* build is provably in progress.
|
|
*/
|
|
function fakeBuild(counter: { builds: number }, gate?: Promise<void>) {
|
|
return async (derivedDataPath: string): Promise<RunnerArtifact> => {
|
|
counter.builds += 1;
|
|
if (gate) await gate;
|
|
const productsDir = path.join(derivedDataPath, "Build", "Products");
|
|
await fsp.mkdir(productsDir, { recursive: true });
|
|
const xctestrunPath = path.join(productsDir, "ArgentRunner_iphoneos18.0-arm64.xctestrun");
|
|
await fsp.writeFile(xctestrunPath, "plist");
|
|
return { xctestrunPath, derivedDataPath, fromCache: false };
|
|
};
|
|
}
|
|
|
|
// ensureRunnerArtifact builds under os.homedir(), which reads USERPROFILE on
|
|
// Windows and HOME elsewhere. Pinning one name leaves the fixture inert on
|
|
// the other platform and the build dir lands in the developer's real home.
|
|
it("redirects os.homedir() under both names it consults", async () => {
|
|
await withEnsureEnv("homedir-names", async () => {
|
|
expect(process.env.USERPROFILE).toBe(process.env.HOME);
|
|
expect(os.homedir()).toBe(path.join(tmpRoot, "ensure-home-homedir-names"));
|
|
});
|
|
});
|
|
|
|
it("reports fromCache honestly and rebuilds the same key only when forced", async () => {
|
|
await withEnsureEnv("hit-and-force", async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
|
|
const first = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(first.fromCache).toBe(false);
|
|
|
|
const hit = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(hit.fromCache).toBe(true);
|
|
expect(hit.xctestrunPath).toBe(first.xctestrunPath);
|
|
expect(counter.builds).toBe(1);
|
|
|
|
const forced = await ensureRunnerArtifact(CONFIG, { build, force: true });
|
|
expect(forced.fromCache).toBe(false);
|
|
expect(counter.builds).toBe(2);
|
|
});
|
|
});
|
|
|
|
/** Plant a fake embedded profile where the cached runner app keeps it. */
|
|
async function writeFakeProfile(derivedDataPath: string, expiresAt: string): Promise<void> {
|
|
const app = path.join(
|
|
derivedDataPath,
|
|
"Build",
|
|
"Products",
|
|
"Debug-iphoneos",
|
|
"ArgentRunnerUITests-Runner.app"
|
|
);
|
|
await fsp.mkdir(app, { recursive: true });
|
|
// A real .mobileprovision is a CMS blob with the plist inline; the reader
|
|
// only needs the ExpirationDate pair to be present as plain text.
|
|
await fsp.writeFile(
|
|
path.join(app, "embedded.mobileprovision"),
|
|
`\u0000cms\u0000<plist><dict><key>ExpirationDate</key>\n\t<date>${expiresAt}</date></dict></plist>`,
|
|
"latin1"
|
|
);
|
|
}
|
|
|
|
it("re-signs in place when the cached profile is within an hour of expiry", async () => {
|
|
await withEnsureEnv("profile-expiring", async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
|
|
const first = await ensureRunnerArtifact(CONFIG, { build });
|
|
const marker = path.join(first.derivedDataPath, "Build", "keep-me");
|
|
await fsp.writeFile(marker, "");
|
|
await writeFakeProfile(first.derivedDataPath, "2020-01-01T00:00:00Z");
|
|
|
|
const renewed = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(renewed.fromCache).toBe(false);
|
|
expect(counter.builds).toBe(2);
|
|
// The stamp still matched, so the tree was re-signed, not wiped.
|
|
await expect(fsp.access(marker)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("keeps the cache hit while the profile has more than an hour left", async () => {
|
|
await withEnsureEnv("profile-fresh", async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
|
|
const first = await ensureRunnerArtifact(CONFIG, { build });
|
|
await writeFakeProfile(first.derivedDataPath, "2999-01-01T00:00:00Z");
|
|
|
|
const hit = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(hit.fromCache).toBe(true);
|
|
expect(counter.builds).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("keeps the cache hit when the profile cannot be read", async () => {
|
|
await withEnsureEnv("profile-unreadable", async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
|
|
const first = await ensureRunnerArtifact(CONFIG, { build });
|
|
await writeFakeProfile(first.derivedDataPath, "not a date");
|
|
|
|
const hit = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(hit.fromCache).toBe(true);
|
|
expect(counter.builds).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("serializes concurrent same-key ensures into exactly one build", async () => {
|
|
await withEnsureEnv("same-key", async () => {
|
|
const counter = { builds: 0 };
|
|
const gate = deferred();
|
|
const build = fakeBuild(counter, gate.promise);
|
|
|
|
const first = ensureRunnerArtifact(CONFIG, { build });
|
|
// The first call provably holds the key's lock mid-build before the
|
|
// second even starts, so the second MUST queue, not race.
|
|
await until(() => counter.builds === 1);
|
|
const second = ensureRunnerArtifact(CONFIG, { build });
|
|
gate.resolve();
|
|
|
|
const [a, b] = await Promise.all([first, second]);
|
|
expect(counter.builds).toBe(1);
|
|
expect(a.fromCache).toBe(false);
|
|
expect(b.fromCache).toBe(true);
|
|
expect(b.xctestrunPath).toBe(a.xctestrunPath);
|
|
});
|
|
});
|
|
|
|
it("rebuilds in place when the cache key changes: one tree, no siblings", async () => {
|
|
await withEnsureEnv("key-change", async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
const first = await ensureRunnerArtifact(CONFIG, { build });
|
|
|
|
// A leftover of the first generation must not survive into the next:
|
|
// mixing products across sources/Xcode/signing generations is what the
|
|
// stamp-mismatch wipe exists to prevent.
|
|
const leftover = path.join(first.derivedDataPath, "Build", "Products", "stale-product");
|
|
await fsp.writeFile(leftover, "");
|
|
|
|
// A different bundle id changes the static args and thus the cache key.
|
|
const otherConfig: RunnerSigningConfig = {
|
|
...CONFIG,
|
|
appBundleId: "com.other.argent.runner",
|
|
testBundleId: "com.other.argent.runner.uitests",
|
|
};
|
|
const other = await ensureRunnerArtifact(otherConfig, { build });
|
|
expect(other.fromCache).toBe(false);
|
|
expect(other.derivedDataPath).toBe(first.derivedDataPath);
|
|
await expect(fsp.access(leftover)).rejects.toThrow();
|
|
|
|
// Flipping back rebuilds again: exactly one generation lives in the dir.
|
|
const back = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(back.fromCache).toBe(false);
|
|
expect(counter.builds).toBe(3);
|
|
});
|
|
});
|
|
|
|
it("keys the cache on every project file and on none of the local Xcode noise", async () => {
|
|
// A private project tree: the files planted here must not leak into the
|
|
// other cases' keys through the shared fake.
|
|
const root = path.join(tmpRoot, "fingerprint-proj");
|
|
const project = path.join(root, "ArgentRunner.xcodeproj");
|
|
const contents = path.join(root, "ArgentRunner", "Assets.xcassets", "Contents.json");
|
|
await fsp.mkdir(path.dirname(contents), { recursive: true });
|
|
await fsp.writeFile(contents, '{"info":{"version":1}}');
|
|
|
|
await withEnsureEnv(
|
|
"fingerprint",
|
|
async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
await ensureRunnerArtifact(CONFIG, { build });
|
|
|
|
// Per-user Xcode state, build output and Finder metadata never enter
|
|
// the key, whether they sit at the root or deeper in the tree.
|
|
for (const dir of ["xcuserdata", "DerivedData", ".build", "ArgentRunner/.swiftpm"]) {
|
|
await fsp.mkdir(path.join(root, dir), { recursive: true });
|
|
await fsp.writeFile(path.join(root, dir, "state"), dir);
|
|
}
|
|
await fsp.writeFile(path.join(root, ".DS_Store"), "finder");
|
|
expect((await ensureRunnerArtifact(CONFIG, { build })).fromCache).toBe(true);
|
|
|
|
// An asset catalog is not a .swift/.plist/... source, yet it ships in
|
|
// the runner, so editing it must rebuild.
|
|
await fsp.writeFile(contents, '{"info":{"version":2}}');
|
|
expect((await ensureRunnerArtifact(CONFIG, { build })).fromCache).toBe(false);
|
|
expect(counter.builds).toBe(2);
|
|
},
|
|
{ project }
|
|
);
|
|
});
|
|
|
|
it("rebuilds when the stamp is missing even though an xctestrun exists (interrupted build)", async () => {
|
|
await withEnsureEnv("no-stamp", async () => {
|
|
const counter = { builds: 0 };
|
|
const build = fakeBuild(counter);
|
|
const first = await ensureRunnerArtifact(CONFIG, { build });
|
|
|
|
// The stamp is written only after a successful build, so its absence
|
|
// means the tree cannot be trusted regardless of what files exist.
|
|
await fsp.rm(path.join(first.derivedDataPath, ".argent-cache-key"));
|
|
|
|
const again = await ensureRunnerArtifact(CONFIG, { build });
|
|
expect(again.fromCache).toBe(false);
|
|
expect(counter.builds).toBe(2);
|
|
});
|
|
});
|
|
|
|
it("stamps the build dir with the cache key after a real-arm build", async () => {
|
|
const stubBin = path.join(tmpRoot, "ensure-stub-bin");
|
|
await fsp.mkdir(stubBin, { recursive: true });
|
|
await fsp.writeFile(
|
|
path.join(stubBin, "xcodebuild"),
|
|
[
|
|
"#!/bin/sh",
|
|
// The toolchain fingerprint calls this first; it takes no derived dir.
|
|
'if [ "$1" = "-version" ]; then echo "Xcode 99.0"; exit 0; fi',
|
|
'for arg in "$@"; do derived="$arg"; done', // -derivedDataPath's value is last
|
|
'/bin/mkdir -p "$derived/Build/Products"', // PATH holds only this stub
|
|
': > "$derived/Build/Products/ArgentRunner_iphoneos18.0-arm64.xctestrun"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 }
|
|
);
|
|
|
|
const artifact = await withEnsureEnv("stamp", () => ensureRunnerArtifact(CONFIG), {
|
|
binDir: stubBin,
|
|
});
|
|
|
|
const stamp = await fsp.readFile(
|
|
path.join(artifact.derivedDataPath, ".argent-cache-key"),
|
|
"utf8"
|
|
);
|
|
expect(stamp).toMatch(/^[0-9a-f]{16}$/);
|
|
});
|
|
|
|
/** A PATH dir holding one xcodebuild stub; `-version` always answers so the fingerprint stays deterministic. */
|
|
async function stubXcodebuild(name: string, buildLines: string[]): Promise<string> {
|
|
const binDir = path.join(tmpRoot, `ensure-${name}-bin`);
|
|
await fsp.mkdir(binDir, { recursive: true });
|
|
await fsp.writeFile(
|
|
path.join(binDir, "xcodebuild"),
|
|
[
|
|
"#!/bin/sh",
|
|
'if [ "$1" = "-version" ]; then echo "Xcode 99.0"; exit 0; fi',
|
|
...buildLines,
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 }
|
|
);
|
|
return binDir;
|
|
}
|
|
|
|
it("stamps a failed xcodebuild as the not-ready build stage with the exit code", async () => {
|
|
const binDir = await stubXcodebuild("build-fails", [
|
|
'echo "/proj.xcodeproj: error: No Accounts: Add a new account in Accounts settings." >&2',
|
|
"exit 65",
|
|
]);
|
|
|
|
const caught = await withEnsureEnv(
|
|
"build-fails",
|
|
() => ensureRunnerArtifact(CONFIG).catch((error: unknown) => error),
|
|
{ binDir }
|
|
);
|
|
|
|
expect((caught as Error).message).toContain("Building the iOS device runner failed.");
|
|
expect((caught as Error).message).toContain(
|
|
"xcodebuild reported:\n/proj.xcodeproj: error: No Accounts"
|
|
);
|
|
// Every sibling stage of the runner's cold start is stamped; a build
|
|
// failure must not be the one that lands in the unclassified bucket.
|
|
const signal = getFailureSignal(caught);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_NOT_READY);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_build");
|
|
expect(signal?.error_kind).toBe("subprocess");
|
|
expect(signal?.failure_command).toBe("xcodebuild");
|
|
expect(signal?.failure_exit_code).toBe(65);
|
|
});
|
|
|
|
it("stamps a build that succeeds without producing an xctestrun the same way", async () => {
|
|
const binDir = await stubXcodebuild("no-xctestrun", ["exit 0"]);
|
|
|
|
const caught = await withEnsureEnv(
|
|
"no-xctestrun",
|
|
() => ensureRunnerArtifact(CONFIG).catch((error: unknown) => error),
|
|
{ binDir }
|
|
);
|
|
|
|
expect((caught as Error).message).toContain(
|
|
"xcodebuild reported success but no iphoneos .xctestrun was found"
|
|
);
|
|
const signal = getFailureSignal(caught);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_NOT_READY);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_build");
|
|
expect(signal?.error_kind).toBe("not_found");
|
|
expect(signal?.failure_command).toBe("xcodebuild");
|
|
});
|
|
});
|
|
|
|
describe("resolveRunnerProjectPath", () => {
|
|
it("stamps the project-not-found error with a failure signal", () => {
|
|
const saved = process.env.ARGENT_IOS_RUNNER_PROJECT;
|
|
delete process.env.ARGENT_IOS_RUNNER_PROJECT;
|
|
try {
|
|
let caught: unknown;
|
|
try {
|
|
// Under vitest __dirname is the source tree, where no copy of the
|
|
// project sits next to the module, so the not-found arm is the
|
|
// natural outcome.
|
|
resolveRunnerProjectPath();
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect((caught as Error).message).toContain(
|
|
"Could not locate the ios-device-runner Xcode project"
|
|
);
|
|
expect((caught as Error).message).toMatch(
|
|
/ios-device-runner\/ArgentRunner\/ArgentRunner\.xcodeproj/
|
|
);
|
|
// Telemetry classification (T44): the broken-install story must not
|
|
// fall into the registry's unclassified bucket.
|
|
const signal = getFailureSignal(caught);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_NOT_READY);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_project_resolve");
|
|
} finally {
|
|
if (saved === undefined) delete process.env.ARGENT_IOS_RUNNER_PROJECT;
|
|
else process.env.ARGENT_IOS_RUNNER_PROJECT = saved;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("isProfileMissingDeviceFailure", () => {
|
|
it("recognizes the fresh-team shape alongside the new-device shapes", () => {
|
|
const cases = [
|
|
"Error 0xe8008012 while installing",
|
|
"profile doesn't include the currently selected device",
|
|
"this provisioning profile cannot be installed on this device",
|
|
"error: Your team has no devices from which to generate a provisioning profile.",
|
|
];
|
|
for (const text of cases) {
|
|
expect(isProfileMissingDeviceFailure(text), text).toBe(true);
|
|
}
|
|
expect(isProfileMissingDeviceFailure("ld: symbol(s) not found")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isProfileExpiredFailure", () => {
|
|
it("recognizes installd's expiry codes and Xcode's expiry wording", () => {
|
|
const cases = [
|
|
// installd, as surfaced in the launch log (MobileDevice.framework error table).
|
|
"Failed to verify code signature of ArgentRunnerUITests-Runner.app : 0xe8008011 (This provisioning profile has expired.)",
|
|
"Failed to verify code signature of ArgentRunnerUITests-Runner.app : 0xe8008015 (A valid provisioning profile for this executable was not found.)",
|
|
// Xcode host-side wording (IDEFoundation).
|
|
'error: Provisioning profile "iOS Team Provisioning Profile: com.argent.runner" expired on 2026-09-02.',
|
|
"Profile expired on 2026-09-02.",
|
|
];
|
|
for (const text of cases) {
|
|
expect(isProfileExpiredFailure(text), text).toBe(true);
|
|
}
|
|
// Device-missing shapes belong to isProfileMissingDeviceFailure, not here.
|
|
expect(
|
|
isProfileExpiredFailure(
|
|
"0xe8008012 (This provisioning profile cannot be installed on this device.)"
|
|
)
|
|
).toBe(false);
|
|
expect(isProfileExpiredFailure("profile doesn't include the currently selected device")).toBe(
|
|
false
|
|
);
|
|
expect(isProfileExpiredFailure("ld: symbol(s) not found")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("xcodebuildFailureSummary", () => {
|
|
it("extracts the error lines, deduped, instead of the boilerplate tail", () => {
|
|
const output = [
|
|
"Build description signature: abc",
|
|
"/proj.xcodeproj: error: No Accounts: Add a new account in Accounts settings.",
|
|
"/proj.xcodeproj: error: No profiles for 'com.x' were found: Xcode couldn't find any.",
|
|
"/proj.xcodeproj: error: No Accounts: Add a new account in Accounts settings.",
|
|
"** TEST BUILD FAILED **",
|
|
"The following build commands failed:",
|
|
"\tBuilding project ArgentRunner for testing with scheme ArgentRunner",
|
|
"(1 failure)",
|
|
].join("\n");
|
|
|
|
const summary = xcodebuildFailureSummary(output);
|
|
|
|
expect(summary).toBe(
|
|
"/proj.xcodeproj: error: No Accounts: Add a new account in Accounts settings.\n" +
|
|
"/proj.xcodeproj: error: No profiles for 'com.x' were found: Xcode couldn't find any."
|
|
);
|
|
expect(summary).not.toContain("TEST BUILD FAILED");
|
|
});
|
|
|
|
it("falls back to the tail when no error line exists", () => {
|
|
const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`);
|
|
expect(xcodebuildFailureSummary(lines.join("\n"))).toBe(lines.slice(-15).join("\n"));
|
|
});
|
|
});
|