Files
explore/test/node/setup.test.mjs
Brent Vatne d26e65db47 Add a README quick start, and run local reviews with no setup
- README: a Quick start of two steps (make the app buildable on EAS, then
  `npx @expo/explore capture`), with a prompt to paste into Claude Code or Codex.
- `capture` is a one-word name for `capture:review`.
- A local review reviews the working tree by default, untracked files included,
  as `eas build` uploads it. `--committed` reviews HEAD alone, and an eas.json
  with `cli.requireCommit: true` leaves uncommitted changes out, as eas build does.
- Without `.expo-explore/config.yaml`, a local review uses the config `setup`
  would write (or an uncommitted one in the checkout), so it works before setup.
- setup prefers a simulator profile named `explore` when there are several.
2026-10-16 12:36:38 -07:00

255 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import test from "node:test";
import { tsImport } from "tsx/esm/api";
const { runExploreSetup } = await tsImport("../../src/config/setup.ts", import.meta.url);
const { readExploreConfig } = await tsImport("../../src/config/explore-config.ts", import.meta.url);
const exec = promisify(execFile);
const options = { nonInteractive: true, device: "iPhone 17", scope: "changes" };
async function fixture(t) {
const project = await mkdtemp(join(tmpdir(), "explore-setup-"));
t.after(() => rm(project, { recursive: true, force: true }));
const json = (path, value) => writeFile(join(project, path), JSON.stringify(value));
await json("package.json", { dependencies: { expo: "*", "expo-router": "*" } });
await json("app.json", { expo: { name: "Test app", scheme: "test-app", ios: { bundleIdentifier: "com.example.test" } } });
await json("eas.json", { build: { explore: { ios: { simulator: true } } } });
return { project, json, path: join(project, ".expo-explore/config.yaml") };
}
test("non-interactive CLI creates a usable app config and check validates it", async (t) => {
const { project, path } = await fixture(t);
const cli = resolve("dist/cli.js");
const { stdout } = await exec(process.execPath, [cli, "setup", "--project", project, "--device", "iPad mini (A17 Pro)", "--scope", "all", "--json"]);
const result = JSON.parse(stdout);
assert.equal(result.ok, true);
assert.equal(result.created, true);
assert.equal(result.path, await realpath(path));
const config = await readExploreConfig(project);
assert.equal(config.mode, "deterministic");
assert.equal(config.app.bundleId, "com.example.test");
assert.equal(config.app.scheme, "test-app");
assert.equal(config.navigation.provider, "expo-router");
assert.equal(config.device.name, "iPad mini (A17 Pro)");
assert.equal(config.exploration.scope, "all");
const check = await exec(process.execPath, [cli, "setup", "--project", project, "--check", "--json"]);
assert.equal(JSON.parse(check.stdout).created, false);
});
test("interactive choices are validated and cancellation writes nothing", async (t) => {
const { project, path } = await fixture(t);
const answer = (scope, confirm) => ({
log() {},
async ask(label, fallback) {
if (label === "Capture scope") return scope;
if (label.startsWith("Write")) return confirm;
return fallback;
},
});
await assert.rejects(runExploreSetup({ project }, answer("all", "no")), /canceled/);
await assert.rejects(stat(path), { code: "ENOENT" });
await assert.rejects(runExploreSetup({ project }, answer("typo", "yes")), /scope/);
await assert.rejects(stat(path), { code: "ENOENT" });
await runExploreSetup({ project }, answer("all", "yes"));
assert.equal((await readExploreConfig(project)).exploration.scope, "all");
});
test("existing config, including parameters and setup, is preserved", async (t) => {
const { project, path } = await fixture(t);
await runExploreSetup({ project, ...options });
await writeFile(path, `${await readFile(path, "utf8")}parameters:\n /cats/[id]:\n id: kitten\nsetup:\n - action: wait\n milliseconds: 500\n`);
const before = await readFile(path, "utf8");
await assert.rejects(runExploreSetup({ project, ...options, scope: "all" }), /already exists and was preserved/);
assert.equal(await readFile(path, "utf8"), before);
const checked = await runExploreSetup({ project, check: true });
assert.equal(checked.config.parameters["/cats/[id]"].id, "kitten");
});
test("EAS profile inheritance is respected and device builds cannot be selected", async (t) => {
const { project, json, path } = await fixture(t);
await json("eas.json", { build: {
base: { ios: { simulator: true } },
explore: { extends: "base", ios: { buildConfiguration: "Release" } },
production: { extends: "base", ios: { simulator: false } },
} });
await assert.rejects(runExploreSetup({ project, ...options, profile: "production" }), /--profile/);
await assert.rejects(stat(path), { code: "ENOENT" });
// Of several simulator profiles, the one named explore is the default; without one, setup asks for --profile.
await runExploreSetup({ project, ...options });
assert.equal((await readExploreConfig(project)).app.easProfile, "explore");
await rm(path);
await json("eas.json", { build: { base: { ios: { simulator: true } }, sim: { extends: "base" } } });
await assert.rejects(runExploreSetup({ project, ...options }), /--profile: base, sim/);
await runExploreSetup({ project, ...options, profile: "sim" });
assert.equal((await readExploreConfig(project)).app.easProfile, "sim");
});
test("dynamic config uses the app's Expo resolver instead of stale app.json", async (t) => {
const { project } = await fixture(t);
await writeFile(join(project, "app.config.js"), `module.exports = ({ config }) => ({ ...config, scheme: 'dynamic-app', ios: { bundleIdentifier: 'com.example.dynamic' } });`);
await assert.rejects(runExploreSetup({ project, ...options }), /installed expo\/config/);
await mkdir(join(project, "node_modules/expo"), { recursive: true });
const expoConfig = createRequire(import.meta.url).resolve("@expo/config");
await writeFile(join(project, "node_modules/expo/config.js"), `module.exports = require(${JSON.stringify(expoConfig)});`);
await runExploreSetup({ project, ...options });
const config = await readExploreConfig(project);
assert.equal(config.app.bundleId, "com.example.dynamic");
assert.equal(config.app.scheme, "dynamic-app");
});
test("app.config.json takes precedence over app.json", async (t) => {
const { project, json } = await fixture(t);
await json("app.config.json", { expo: { scheme: "static-app", ios: { bundleIdentifier: "com.example.static" } } });
await runExploreSetup({ project, ...options });
assert.equal((await readExploreConfig(project)).app.bundleId, "com.example.static");
});
test("setup fails before writing for missing prerequisites and invalid choices", async (t) => {
const { project, json, path } = await fixture(t);
await assert.rejects(runExploreSetup({ project, ...options, bundleId: "invalid" }), /reverse-DNS/);
await json("eas.json", { build: { a: { extends: "b" }, b: { extends: "a" } } });
await assert.rejects(runExploreSetup({ project, ...options }), /Circular EAS/);
await json("eas.json", { build: { explore: { extends: "missing" } } });
await assert.rejects(runExploreSetup({ project, ...options }), /Missing inherited EAS/);
await json("eas.json", { build: { production: {} } });
await assert.rejects(runExploreSetup({ project, ...options }), /No EAS build profile/);
await rm(join(project, "eas.json"));
await assert.rejects(runExploreSetup({ project, ...options }), /No eas.json/);
await json("package.json", { private: true });
await assert.rejects(runExploreSetup({ project, ...options }), /--project/);
await assert.rejects(stat(path), { code: "ENOENT" });
});
test("setup does not follow a config-directory symlink or overwrite a file created during prompts", async (t) => {
const { project, path } = await fixture(t);
const outside = await mkdtemp(join(tmpdir(), "explore-setup-outside-"));
t.after(() => rm(outside, { recursive: true, force: true }));
await symlink(outside, join(project, ".expo-explore"));
await assert.rejects(runExploreSetup({ project, ...options }), /symlink/);
await rm(join(project, ".expo-explore"));
await assert.rejects(runExploreSetup({ project }, {
log() {},
async ask(label, fallback) {
if (label.startsWith("Write")) {
await mkdir(join(project, ".expo-explore"));
await writeFile(path, "another editor's config");
}
return fallback;
},
}), { code: "EEXIST" });
assert.equal(await readFile(path, "utf8"), "another editor's config");
});
const { DRAFT_INSTRUCTIONS_MARKER } = await tsImport("../../src/config/setup.ts", import.meta.url);
/** Source analysis as the CLI supplies it, reduced to what setup asks about. */
function analysis({ guards = true } = {}) {
const route = (logicalPath, extra = {}) => ({ id: `expo-router:${logicalPath}`, logicalPath, routeFile: `src/app${logicalPath}.tsx`, path: logicalPath,
status: "ready", parameterKey: logicalPath, missingParameters: [], guards: [], searchParameters: { unset: [], opaque: false }, openedBySetup: false, excluded: false, ...extra });
const guard = guards ? [{ to: "/welcome", sourceFile: "src/app/(member)/_layout.tsx" }] : [];
return () => ({ commit: "a".repeat(40), provider: "expo-router", diagnostics: [], unusedParameterKeys: [], unknownSetupRoutes: [], unknownExcludedRoutes: [], routes: [
route("/welcome"),
route("/(member)/home", { guards: guard }),
route("/(member)/items/[id]", { path: "/items/[id]", status: "needs-parameters", parameterKey: "/items/[id]", missingParameters: ["id"], guards: guard }),
] });
}
function scripted(answers) {
const asked = [];
const logs = [];
return { asked, logs, interaction: {
log(message) { logs.push(message); },
async ask(label, fallback) {
asked.push(label);
const match = Object.entries(answers).find(([key]) => label.startsWith(key));
return match ? match[1] : fallback;
},
} };
}
test("setup asks for the mode, a real value for each dynamic route, and how to prepare guarded screens", async (t) => {
const { project } = await fixture(t);
const { asked, logs, interaction } = scripted({ "/items/[id] → id": "item-1", "Prepare app state with": "setup template" });
const result = await runExploreSetup({ project }, interaction, { analyzeRoutes: analysis() });
assert.ok(asked.indexOf("Capture mode") < asked.indexOf("/items/[id] → id"));
assert.match(logs.join("\n"), /no API key, no AI charges/);
assert.match(logs.join("\n"), /src\/app\/\(member\)\/_layout\.tsx → \/welcome \(2 routes\)/);
const config = await readExploreConfig(project);
assert.equal(config.mode, "deterministic");
assert.deepEqual(config.parameters, { "/items/[id]": { id: "item-1" } });
const yaml = await readFile(join(project, ".expo-explore/config.yaml"), "utf8");
assert.match(yaml, /# setup: put the app in the state each screen needs/);
assert.match(yaml, /# {5}url: test-app:\/\//);
assert.deepEqual(result.routes.needsParameters, ["/items/[id]"]);
assert.match(result.nextSteps[0], /coding agent.*explore-setup\/SKILL\.md/s);
assert.ok(result.nextSteps.some(step => step.startsWith("gh secret set EXPO_TOKEN")));
assert.ok(!result.nextSteps.some(step => step.includes("ANTHROPIC_API_KEY")));
});
test("choosing agent mode for guarded screens asks for the provider and model and drafts instructions from the answers", async (t) => {
const { project } = await fixture(t);
const { logs, interaction } = scripted({
"Prepare app state with": "agent mode", "Model provider": "anthropic", "Model ID": "example-model",
"What state or test inputs": "Sign in with {{secret:EXAMPLE_LOGIN}} and {{secret:EXAMPLE_PASSWORD}}.",
});
const result = await runExploreSetup({ project }, interaction, { analyzeRoutes: analysis() });
const config = await readExploreConfig(project);
assert.equal(config.mode, "agent");
assert.deepEqual(config.agent, { provider: "anthropic", model: "example-model", instructions: ".expo-explore/instructions.md" });
const instructions = await readFile(join(project, ".expo-explore/instructions.md"), "utf8");
assert.ok(instructions.startsWith(DRAFT_INSTRUCTIONS_MARKER));
assert.match(instructions, /Sign in with \{\{secret:EXAMPLE_LOGIN\}\}/);
assert.match(instructions, /- src\/app\/\(member\)\/_layout\.tsx → \/welcome/);
assert.match(logs.join("\n"), /platform\.claude\.com\/docs\/en\/about-claude\/models/);
const steps = result.nextSteps.join("\n");
assert.match(steps, /gh secret set ANTHROPIC_API_KEY.*platform\.claude\.com\/settings\/keys.*billed/);
assert.match(steps, /ANTHROPIC_WORKSPACE_ID/);
assert.match(steps, /gh secret set EXAMPLE_LOGIN/);
assert.match(steps, /gh secret set EXAMPLE_PASSWORD/);
assert.match(steps, /expo-explore doctor --check-model/);
});
test("non-interactive setup takes every answer from flags and installs CI files with the referenced secrets", async (t) => {
const { project } = await fixture(t);
await assert.rejects(runExploreSetup({ project, ...options, mode: "agent" }, undefined, {}), /--provider .* and --model/);
await assert.rejects(runExploreSetup({ project, ...options, params: ["/items/[id]=x"] }, undefined, {}), /ROUTE\.NAME=VALUE/);
const installs = [];
const result = await runExploreSetup({ project, ...options, mode: "agent", provider: "openai", model: "example-model",
params: ["/items/[id].id=item-1"], workflows: true }, undefined,
{ analyzeRoutes: analysis(), installWorkflows: async (_project, secrets) => { installs.push(secrets); return { written: [".github/workflows/explore-review.yml"], kept: [], notes: [] }; } });
const config = await readExploreConfig(project);
assert.equal(config.agent.provider, "openai");
assert.deepEqual(config.parameters, { "/items/[id]": { id: "item-1" } });
assert.deepEqual(installs, [[]]);
assert.ok(!result.nextSteps.some(step => step.includes("setup --workflows")));
assert.match(result.nextSteps.join("\n"), /gh secret set OPENAI_API_KEY/);
assert.match(await readFile(join(project, ".expo-explore/instructions.md"), "utf8"), /## Reaching screens/);
});
test("without source redirects or parameter gaps, setup does not ask about app state", async (t) => {
const { project } = await fixture(t);
const { asked, interaction } = scripted({});
const noGaps = () => ({ ...analysis({ guards: false })(), routes: analysis({ guards: false })().routes.filter(route => route.status === "ready") });
const result = await runExploreSetup({ project }, interaction, { analyzeRoutes: noGaps });
assert.ok(!asked.includes("Prepare app state with"));
assert.ok(!result.nextSteps.some(step => step.includes("coding agent")));
assert.match(result.nextSteps.join("\n"), /expo-explore setup --workflows/);
});
test("non-interactive setup uses the wizard defaults and names a missing bundle identifier", async (t) => {
const { project, json } = await fixture(t);
await runExploreSetup({ project, nonInteractive: true });
const config = await readExploreConfig(project);
assert.equal(config.device.name, "iPhone 17");
assert.equal(config.exploration.scope, "changes");
await rm(join(project, ".expo-explore"), { recursive: true });
await json("app.json", { expo: { name: "Test app" } });
await assert.rejects(runExploreSetup({ project, nonInteractive: true }), /No iOS bundle identifier found.*--bundle-id/);
});