import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; const root = resolve(import.meta.dirname, "../.."); const cli = join(root, "dist", "cli.js"); const { defaultOutputDirectory } = await import(join(root, "dist", "cli", "binary-commands.js")); function fail(args) { // Without an Expo Services login (as on CI) argument and archive errors must still come first. const result = spawnSync(process.execPath, [cli, "explore:binary", ...args, "--json"], { cwd: root, encoding: "utf8", env: { ...process.env, EXPO_TOKEN: "", EXPO_SESSION_SECRET: "" } }); assert.notEqual(result.status, 0, result.stdout); // Progress lines precede the structured error on stderr. const payload = JSON.parse(result.stderr.trim().split("\n").at(-1)); assert.equal(payload.ok, false); assert.equal(payload.command, "explore:binary"); return payload.error; } test("explore:binary is in the short help with its archive kinds and --no-upload", () => { const help = spawnSync(process.execPath, [cli, "help"], { cwd: root, encoding: "utf8" }); assert.equal(help.status, 0, help.stderr); assert.match(help.stdout, /explore:binary .*--simulator eas\|local.*--no-upload/); }); test("explore:binary validates its arguments before touching Expo Services or a model", () => { assert.match(fail([]), /Pass the app archive/); assert.match(fail(["./app.apk", "--upload", "--no-upload"]), /either --upload or --no-upload/); assert.match(fail(["./app.apk", "--platform", "web"]), /platform/); assert.match(fail(["./app.apk", "--simulator", "cloud"]), /simulator/); // A local run needs no Expo Services login, so the archive is the first thing it checks. assert.match(fail(["./does-not-exist.apk", "--simulator", "local"]), /Archive not found: .*does-not-exist\.apk/); assert.match(fail(["./does-not-exist.apk", "--no-upload"]), /Archive not found: .*does-not-exist\.apk/); assert.match(fail(["./package.json", "--no-upload"]), /Unsupported archive: package\.json/); const human = spawnSync(process.execPath, [cli, "explore:binary", "./does-not-exist.apk", "--no-upload"], { cwd: root, encoding: "utf8", env: { ...process.env, EXPO_TOKEN: "", EXPO_SESSION_SECRET: "" } }); assert.notEqual(human.status, 0); assert.match(human.stderr, /Archive not found: .*does-not-exist\.apk\n→ Check the path/); }); test("--expires-in is accepted from older scripts and says it no longer applies; output directory defaults", () => { const ignored = spawnSync(process.execPath, [cli, "explore:binary", "./does-not-exist.apk", "--no-upload", "--expires-in", "400d"], { cwd: root, encoding: "utf8", env: { ...process.env, EXPO_TOKEN: "", EXPO_SESSION_SECRET: "", GITHUB_ACTIONS: "" } }); assert.match(ignored.stderr, /^--expires-in no longer applies: a hosted preview lasts as long as its Expo Hosting deployment/m); assert.match(ignored.stderr, /Archive not found/, "and the run goes on"); const now = new Date("2026-10-11T16:28:48.123Z"); assert.equal(defaultOutputDirectory("/builds/euxy-ios.tar.gz", now), "explore-euxy-ios-20261011-1628"); assert.equal(defaultOutputDirectory("My App (1).app", now), "explore-My-App-1-20261011-1628"); assert.equal(defaultOutputDirectory("/x/.apk", now), "explore-app-20261011-1628"); }); function failSession(args, env = {}) { const result = spawnSync(process.execPath, [cli, "explore:session", ...args, "--json"], { cwd: root, encoding: "utf8", env: { ...process.env, EAS_SIMULATOR_SESSION_ID: "", AGENT_DEVICE_DAEMON_BASE_URL: "", AGENT_DEVICE_DAEMON_AUTH_TOKEN: "", ...env } }); assert.notEqual(result.status, 0, result.stdout); const payload = JSON.parse(result.stderr.trim().split("\n").at(-1)); assert.equal(payload.ok, false); assert.equal(payload.command, "explore:session"); return payload.error; } test("explore:session is in the help and validates its inputs before touching Expo Services", () => { const help = spawnSync(process.execPath, [cli, "help"], { cwd: root, encoding: "utf8" }); assert.match(help.stdout, /explore:session \[SESSION_ID\] \[--bundle-id ID\].*--upload/); assert.match(failSession([]), /No Expo Simulators session connection was found/); assert.match(failSession(["--bundle-id", "bad id"]), /not a bundle or package id/); assert.match(failSession(["--bundle-id", "dev.example.app"]), /No Expo Simulators session connection was found/); assert.match(failSession(["01a1-other", "--bundle-id", "dev.example.app"], { EAS_SIMULATOR_SESSION_ID: "01a1-mine", AGENT_DEVICE_DAEMON_BASE_URL: "https://daemon.example", AGENT_DEVICE_DAEMON_AUTH_TOKEN: "t" }), /EAS_SIMULATOR_SESSION_ID is 01a1-mine, not 01a1-other/); const human = spawnSync(process.execPath, [cli, "explore:session", "--bundle-id", "dev.example.app"], { cwd: root, encoding: "utf8", env: { ...process.env, EAS_SIMULATOR_SESSION_ID: "" } }); assert.notEqual(human.status, 0); assert.match(human.stderr, /No Expo Simulators session connection was found[^\n]*\n→ Start one with `eas simulator:start --type agent-device --out-config-type dotenv`/); }); test("explore:session picks up ./.env.eas-simulator from the current directory", async () => { const cwd = await mkdtemp(join(tmpdir(), "explore-session-cwd-")); try { await writeFile(join(cwd, ".env.eas-simulator"), ["# Do not commit this file.", "# Do not modify these values manually. They are managed by eas-cli.", "# It holds configuration only for the current simulator session.", "", "EAS_SIMULATOR_SESSION_ID='01a1-file'", "AGENT_DEVICE_DAEMON_BASE_URL='https://daemon.example'", "AGENT_DEVICE_DAEMON_AUTH_TOKEN='t'", // A session started with `--egress local` also carries the egress client's settings. "EAS_SIMULATOR_EGRESS_URL='wss://egress.example'", "EAS_SIMULATOR_EGRESS_TOKEN='e'", "EAS_SIMULATOR_EGRESS_FINGERPRINT='f'", "EAS_SIMULATOR_EGRESS_PORT='443'", "EAS_SIMULATOR_EGRESS_ALLOW='localhost:8081'", "EAS_SIMULATOR_EGRESS_PLATFORM='ios'", ""].join("\n")); const env = { ...process.env, EAS_SIMULATOR_SESSION_ID: "", AGENT_DEVICE_DAEMON_BASE_URL: "", AGENT_DEVICE_DAEMON_AUTH_TOKEN: "" }; const mismatch = spawnSync(process.execPath, [cli, "explore:session", "01a1-other", "--json"], { cwd, encoding: "utf8", env }); assert.notEqual(mismatch.status, 0); assert.match(JSON.parse(mismatch.stderr.trim().split("\n").at(-1)).error, /\.env\.eas-simulator describes session 01a1-file, not 01a1-other/); const fromEnvironment = spawnSync(process.execPath, [cli, "explore:session", "01a1-other", "--json"], { cwd, encoding: "utf8", env: { ...env, EAS_SIMULATOR_SESSION_ID: "01a1-env" } }); assert.match(JSON.parse(fromEnvironment.stderr.trim().split("\n").at(-1)).error, /EAS_SIMULATOR_SESSION_ID is 01a1-env, not 01a1-other/); } finally { await rm(cwd, { recursive: true, force: true }); } }); test("attached sessions are proven live, agent-device typed, and served by the given connection", async () => { const { assertAttachedSimulatorSession, resolveAttachedSimulatorSession } = await import(join(root, "dist", "capture", "simulator.js")); const connection = { sessionId: "01a1-mine", daemonBaseUrl: "https://daemon.example.eas.dev", daemonAuthToken: "secret" }; const session = (overrides = {}) => JSON.stringify({ id: "01a1-mine", name: "mine", type: "agent-device", status: "IN_PROGRESS", platform: "IOS", deviceRunSessionUrl: "https://expo.dev/accounts/a/projects/p/simulator-sessions/01a1-mine", remoteConfig: { agentDeviceRemoteSessionUrl: connection.daemonBaseUrl, agentDeviceRemoteSessionToken: "secret" }, ...overrides }); assert.deepEqual(assertAttachedSimulatorSession(session(), connection), { platform: "ios", name: "mine", deviceRunSessionUrl: "https://expo.dev/accounts/a/projects/p/simulator-sessions/01a1-mine" }); assert.equal(assertAttachedSimulatorSession(session({ platform: "ANDROID" }), connection).platform, "android"); assert.throws(() => assertAttachedSimulatorSession(session({ status: "FINISHED" }), connection), /is finished, not in progress/); assert.throws(() => assertAttachedSimulatorSession(session({ type: "interactive" }), connection), /agent-device/); assert.throws(() => assertAttachedSimulatorSession(session({ platform: "ANDROID" }), connection, "ios"), /is android, not ios/); assert.throws(() => assertAttachedSimulatorSession(session({ remoteConfig: { agentDeviceRemoteSessionUrl: connection.daemonBaseUrl, agentDeviceRemoteSessionToken: "other" } }), connection), /different agent-device connection/); assert.throws(() => assertAttachedSimulatorSession(session({ id: "01a1-other" }), connection), /attached agent-device session/); const env = { EAS_SIMULATOR_SESSION_ID: "01a1-mine", AGENT_DEVICE_DAEMON_BASE_URL: connection.daemonBaseUrl, AGENT_DEVICE_DAEMON_AUTH_TOKEN: "secret" }; assert.deepEqual(await resolveAttachedSimulatorSession(env, undefined), connection); assert.deepEqual(await resolveAttachedSimulatorSession(env, undefined, "01a1-mine"), connection); await assert.rejects(resolveAttachedSimulatorSession({}, undefined), /EAS_SIMULATOR_SESSION_ID did not return a session id/); await assert.rejects(resolveAttachedSimulatorSession({ ...env, AGENT_DEVICE_DAEMON_AUTH_TOKEN: "" }, undefined), /unsafe agent-device connection/); }); test("an attached argent session is found from ARGENT_* and proven against the session's tool-server", async () => { const { assertAttachedSimulatorSession, readSimulatorDotenvHandoff, resolveAttachedSimulatorSession } = await import(join(root, "dist", "capture", "simulator.js")); const connection = { sessionId: "01a1-mine", daemonBaseUrl: "https://tools.example.eas.dev", daemonAuthToken: "secret", controller: "argent" }; const env = { EAS_SIMULATOR_SESSION_ID: "01a1-mine", ARGENT_TOOLS_URL: connection.daemonBaseUrl, ARGENT_AUTH_TOKEN: "secret" }; assert.deepEqual(await resolveAttachedSimulatorSession(env, undefined), connection); // A tool-server that needs no token is still an argent session. assert.deepEqual(await resolveAttachedSimulatorSession({ ...env, ARGENT_AUTH_TOKEN: undefined }, undefined), { ...connection, daemonAuthToken: "" }); await assert.rejects(resolveAttachedSimulatorSession({ ...env, ARGENT_TOOLS_URL: "http://tools.example.eas.dev" }, undefined), /unsafe argent/); const session = (overrides = {}) => JSON.stringify({ id: "01a1-mine", name: "mine", type: "argent", status: "IN_PROGRESS", platform: "ANDROID", remoteConfig: { toolsUrl: connection.daemonBaseUrl, toolsAuthToken: "secret" }, ...overrides }); assert.equal(assertAttachedSimulatorSession(session(), connection).platform, "android"); assert.throws(() => assertAttachedSimulatorSession(session({ type: "agent-device" }), connection), /attached argent session/); assert.throws(() => assertAttachedSimulatorSession(session({ remoteConfig: { toolsUrl: connection.daemonBaseUrl, toolsAuthToken: "other" } }), connection), /different argent connection/); const dir = await mkdtemp(join(tmpdir(), "explore-argent-dotenv-")); try { const path = join(dir, ".env.eas-simulator"); await writeFile(path, "# Do not commit this file.\n# Do not modify these values manually. They are managed by eas-cli.\n# It holds configuration only for the current simulator session.\n\nEAS_SIMULATOR_SESSION_ID='01a1-mine'\nARGENT_TOOLS_URL='https://tools.example.eas.dev'\nARGENT_AUTH_TOKEN='secret'\n"); const handoff = await readSimulatorDotenvHandoff(path); assert.equal(handoff.validationError, undefined); assert.deepEqual(handoff.connection, connection); assert.deepEqual(await resolveAttachedSimulatorSession({}, path), connection); } finally { await rm(dir, { recursive: true, force: true }); } });