diff --git a/packages/cli/src/lib/init/verify-doctor.ts b/packages/cli/src/lib/init/verify-doctor.ts new file mode 100644 index 000000000..9cbed24e7 --- /dev/null +++ b/packages/cli/src/lib/init/verify-doctor.ts @@ -0,0 +1,303 @@ +/** + * Doctor-based post-init verification for embedded frameworks (Flutter, Expo). + * + * Runs `flutter doctor` or `npx expo doctor`, surfaces a short status to the + * user, and reports failures to Sentry telemetry the same way Spotlight-based + * verification does — without blocking the successful init. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { captureException } from "@sentry/node-core/light"; +import { logger } from "../logger.js"; +import { whichSync } from "../which.js"; +import type { WorkflowRunResult } from "./types.js"; +import type { WizardUI } from "./ui/types.js"; +import type { + ExpoDoctorStrategy, + FlutterDoctorStrategy, +} from "./verify-strategy.js"; + +/** Doctor commands can take longer than a local Spotlight probe. */ +const DOCTOR_TIMEOUT_S = 60; + +/** Maximum output lines retained for telemetry. */ +const MAX_OUTPUT_LINES = 50; + +/** Absolute-path pattern — scrub user-specific directory paths from telemetry. */ +const ABS_PATH_RE = /(?:\/[\w.@-]+){2,}/g; + +/** Key=value pattern for redaction (env vars and --flag=value args). */ +const KEY_VALUE_RE = /(?:--?)?[A-Za-z_][\w-]*=\S+/g; + +/** URI userinfo (user:password@ or :password@) pattern for redaction. */ +const URI_USERINFO_RE = /\/\/[^@/\s]*:[^@/\s]+@/g; + +/** Newline splitter — hoisted to top level per lint rule. */ +const NEWLINE_RE = /\r?\n/; + +export type DoctorStrategy = FlutterDoctorStrategy | ExpoDoctorStrategy; + +/** Strip absolute paths, env-var values, and URI credentials from output. */ +export function scrubOutputLine(line: string): string { + return line + .replace(URI_USERINFO_RE, "//[REDACTED]@") + .replace(KEY_VALUE_RE, (m) => `${m.split("=")[0]}=[REDACTED]`) + .replace(ABS_PATH_RE, "[PATH]"); +} + +/** + * Run the platform doctor command and report the outcome. + * + * Never throws for doctor failures — init already succeeded. + */ +export async function verifyWithDoctor( + strategy: DoctorStrategy, + result: WorkflowRunResult, + ui: WizardUI, + cwd: string +): Promise { + const [cmd = "", ...cmdArgs] = strategy.args; + if (!cmd) { + ui.log.info("Skipping verification — doctor command was empty"); + return; + } + + if (strategy.tool === "flutter" && !whichSync("flutter")) { + ui.log.info("Skipping verification — flutter is not on PATH"); + captureException(new Error("init verification skipped"), { + tags: { + "wizard.platform": String(result.result?.platform ?? "unknown"), + "wizard.verify": "no_flutter", + }, + extra: { source: strategy.source }, + }); + return; + } + + if (strategy.tool === "expo" && !whichSync(cmd)) { + ui.log.info("Skipping verification — npx is not on PATH"); + captureException(new Error("init verification skipped"), { + tags: { + "wizard.platform": String(result.result?.platform ?? "unknown"), + "wizard.verify": "no_npx", + }, + extra: { source: strategy.source }, + }); + return; + } + + const label = strategy.tool === "flutter" ? "flutter doctor" : "expo doctor"; + logger.debug( + `Verification command: ${strategy.args.join(" ")} (${strategy.source})` + ); + + const childEnv = buildDoctorEnv(strategy, cwd); + let child: ChildProcess; + try { + child = spawn(cmd, cmdArgs, { + cwd, + detached: process.platform !== "win32", + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + logger.debug(`Failed to spawn ${label}`, error); + ui.log.warn(`Skipping verification — could not start ${label}.`); + return; + } + + const { lines, exitCode, timedOut, signalReceived } = await waitForDoctor( + child, + DOCTOR_TIMEOUT_S * 1000 + ); + + if (signalReceived) { + process.kill(process.pid, signalReceived); + return; + } + + reportDoctorOutcome({ + ui, + result, + strategy, + label, + lines, + exitCode, + timedOut, + }); +} + +/** Augment PATH for Expo so local `expo` resolves via node_modules/.bin. */ +function buildDoctorEnv( + strategy: DoctorStrategy, + cwd: string +): Record { + if (strategy.tool !== "expo") { + return { ...process.env }; + } + const binDir = resolve(cwd, "node_modules", ".bin"); + const sep = process.platform === "win32" ? ";" : ":"; + return { + ...process.env, + PATH: process.env.PATH ? `${binDir}${sep}${process.env.PATH}` : binDir, + // Avoid npx interactive prompts hanging the wizard. + npm_config_yes: "true", + }; +} + +type DoctorWaitResult = { + lines: string[]; + exitCode: number | null; + timedOut: boolean; + signalReceived: NodeJS.Signals | null; +}; + +async function waitForDoctor( + child: ChildProcess, + timeoutMs: number +): Promise { + const lines: string[] = []; + let signalReceived: NodeJS.Signals | null = null; + + const append = (raw: Buffer) => { + for (const segment of raw.toString("utf-8").split(NEWLINE_RE)) { + const trimmed = segment.trim(); + if (!trimmed) { + continue; + } + if (lines.length < MAX_OUTPUT_LINES) { + lines.push(trimmed); + } + } + }; + + child.stdout?.on("data", append); + child.stderr?.on("data", append); + + const safeKill = (sig: NodeJS.Signals) => { + try { + if (child.pid !== undefined && process.platform !== "win32") { + process.kill(-child.pid, sig); + return; + } + child.kill(sig); + } catch (error) { + logger.debug(`Failed to signal doctor process with ${sig}`, error); + } + }; + + const onSigint = () => { + signalReceived = "SIGINT"; + safeKill("SIGINT"); + }; + const onSigterm = () => { + signalReceived = "SIGTERM"; + safeKill("SIGTERM"); + }; + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + + let timedOut = false; + let timeoutHandle: ReturnType | undefined; + + const exitPromise = new Promise((resolveExit) => { + child.on("close", (code) => resolveExit(code)); + child.on("error", (error) => { + logger.debug("Doctor process errored", error); + resolveExit(1); + }); + }); + + const timeoutPromise = new Promise<"timeout">((resolveTimeout) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + safeKill("SIGTERM"); + resolveTimeout("timeout"); + }, timeoutMs); + }); + + const raced = await Promise.race([ + exitPromise.then((code) => ({ kind: "exit" as const, code })), + timeoutPromise.then(() => ({ kind: "timeout" as const })), + ]); + + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + + // If we timed out, wait briefly for the process to exit after SIGTERM. + let exitCode: number | null = + raced.kind === "exit" ? raced.code : child.exitCode; + if (raced.kind === "timeout") { + await Promise.race([ + exitPromise, + new Promise((r) => { + setTimeout(r, 2000); + }), + ]); + if (child.exitCode === null && child.pid !== undefined) { + safeKill("SIGKILL"); + } + exitCode = child.exitCode; + } + + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + child.stdout?.destroy(); + child.stderr?.destroy(); + + return { lines, exitCode, timedOut, signalReceived }; +} + +type ReportArgs = { + ui: WizardUI; + result: WorkflowRunResult; + strategy: DoctorStrategy; + label: string; + lines: string[]; + exitCode: number | null; + timedOut: boolean; +}; + +function reportDoctorOutcome(args: ReportArgs): void { + const { ui, result, strategy, label, lines, exitCode, timedOut } = args; + const telemetryTags = { + "wizard.platform": String(result.result?.platform ?? "unknown"), + }; + const telemetryExtra = { + features: result.result?.features, + detectedCommand: scrubOutputLine(strategy.args.join(" ")), + detectedSource: strategy.source, + doctorTool: strategy.tool, + outputLines: lines.length, + outputTail: lines.slice(-10).map(scrubOutputLine), + }; + + if (timedOut) { + ui.log.warn( + `Could not verify — ${label} timed out after ${DOCTOR_TIMEOUT_S}s` + ); + captureException(new Error("init verification failed"), { + tags: { ...telemetryTags, "wizard.verify": "doctor_timeout" }, + extra: telemetryExtra, + }); + return; + } + + if (exitCode === 0) { + ui.log.success(`Verified — ${label} passed`); + return; + } + + const codeLabel = exitCode === null ? "unknown" : String(exitCode); + const lastLine = lines.at(-1); + const detail = lastLine ? `: ${scrubOutputLine(lastLine).slice(0, 200)}` : ""; + ui.log.warn( + `Could not verify — ${label} exited with code ${codeLabel}${detail}` + ); + captureException(new Error("init verification failed"), { + tags: { ...telemetryTags, "wizard.verify": "doctor_failed" }, + extra: { ...telemetryExtra, exitCode }, + }); +} diff --git a/packages/cli/src/lib/init/verify-setup.ts b/packages/cli/src/lib/init/verify-setup.ts index e88ffc71d..c636f3303 100644 --- a/packages/cli/src/lib/init/verify-setup.ts +++ b/packages/cli/src/lib/init/verify-setup.ts @@ -1,7 +1,13 @@ /** - * Post-init verification: run the dev server and check for SDK events. + * Post-init verification: confirm the wizard setup is healthy. * - * Uses a two-signal approach: + * Dispatches by project type: + * - **Flutter / Expo** — run `flutter doctor` / `npx expo doctor` (embedded + * apps cannot be probed via a short-lived Spotlight-backed dev server). + * - **Everything else** — start a local Spotlight sidecar and run the + * detected dev command (stdout + envelope two-signal check). + * + * Local path two-signal approach: * 1. **Stdout-based**: Pipe the child's stdout/stderr and watch for output. * If the process produces output without fatal error patterns, the app * started successfully. @@ -22,6 +28,8 @@ import { detectDevCommand } from "../dev-script.js"; import { logger } from "../logger.js"; import type { WorkflowRunResult } from "./types.js"; import type { WizardUI } from "./ui/types.js"; +import { scrubOutputLine, verifyWithDoctor } from "./verify-doctor.js"; +import { resolveVerifyStrategy } from "./verify-strategy.js"; /** Verification timeout in seconds. */ const VERIFY_TIMEOUT_S = 15; @@ -59,23 +67,6 @@ const FATAL_ERROR_PATTERNS = [ /** Maximum number of output lines to keep for error reporting. */ const MAX_OUTPUT_LINES = 50; -/** Absolute-path pattern — scrub user-specific directory paths from telemetry. */ -const ABS_PATH_RE = /(?:\/[\w.@-]+){2,}/g; - -/** Key=value pattern for redaction (env vars and --flag=value args). */ -const KEY_VALUE_RE = /(?:--?)?[A-Za-z_][\w-]*=\S+/g; - -/** URI userinfo (user:password@ or :password@) pattern for redaction. */ -const URI_USERINFO_RE = /\/\/[^@/\s]*:[^@/\s]+@/g; - -/** Strip absolute paths, env-var values, and URI credentials from output. */ -function scrubOutputLine(line: string): string { - return line - .replace(URI_USERINFO_RE, "//[REDACTED]@") - .replace(KEY_VALUE_RE, (m) => `${m.split("=")[0]}=[REDACTED]`) - .replace(ABS_PATH_RE, "[PATH]"); -} - /** Newline splitter — hoisted to top level per lint rule. */ const NEWLINE_RE = /\r?\n/; @@ -321,8 +312,10 @@ async function cleanupProcessTree(child: ChildProcess): Promise { } /** - * Run the dev server, spawn the child process, and verify that the Sentry - * SDK is working or at minimum that the app starts without errors. + * Verify the post-init setup. + * + * Embedded frameworks (Flutter, Expo) use their platform doctor commands. + * Everything else runs the Spotlight-backed local verification path. * * Called before `formatResult` in the wizard success path. On failure this * logs a warning and reports to Sentry telemetry — it does NOT throw, since @@ -332,6 +325,24 @@ export async function verifySetup( result: WorkflowRunResult, ui: WizardUI, cwd: string +): Promise { + const strategy = await resolveVerifyStrategy(result.result?.platform, cwd); + if (strategy.kind === "doctor") { + await verifyWithDoctor(strategy, result, ui, cwd); + return; + } + + await verifyWithLocal(result, ui, cwd); +} + +/** + * Run the dev server, spawn the child process, and verify that the Sentry + * SDK is working or at minimum that the app starts without errors. + */ +async function verifyWithLocal( + result: WorkflowRunResult, + ui: WizardUI, + cwd: string ): Promise { const detected = await detectDevCommand(cwd); if (!detected) { diff --git a/packages/cli/src/lib/init/verify-strategy.ts b/packages/cli/src/lib/init/verify-strategy.ts new file mode 100644 index 000000000..4067dba47 --- /dev/null +++ b/packages/cli/src/lib/init/verify-strategy.ts @@ -0,0 +1,163 @@ +/** + * Resolve which post-init verification strategy to use. + * + * Embedded frameworks (Flutter, Expo) cannot be verified by starting a local + * Spotlight-backed dev server the way web/node apps can. Prefer wizard + * platform when present, then fall back to filesystem markers. + */ + +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { logger } from "../logger.js"; + +/** Flutter doctor verification. */ +export type FlutterDoctorStrategy = { + kind: "doctor"; + tool: "flutter"; + /** Args passed to spawn (executable is first element). */ + args: string[]; + /** Why this strategy was chosen (platform or filesystem marker). */ + source: string; +}; + +/** Expo doctor verification. */ +export type ExpoDoctorStrategy = { + kind: "doctor"; + tool: "expo"; + args: string[]; + source: string; +}; + +/** Default Spotlight + detectDevCommand verification. */ +export type LocalVerifyStrategy = { + kind: "local"; +}; + +export type VerifyStrategy = + | FlutterDoctorStrategy + | ExpoDoctorStrategy + | LocalVerifyStrategy; + +/** + * Match Flutter's pubspec SDK pin (`sdk: flutter`), typically under + * `dependencies.flutter` (not the Dart `environment.sdk` constraint). + */ +const FLUTTER_SDK_RE = /^\s*sdk:\s*['"]?flutter['"]?\s*$/m; + +/** Match Expo-related platform ids from the remote wizard. */ +const EXPO_PLATFORM_RE = /(?:^|[.-])expo(?:$|[.-])/i; + +/** + * Resolve the verification strategy for a completed init run. + * + * Priority: + * 1. Wizard platform (`flutter`, or any id containing `expo`) + * 2. Filesystem: Flutter via `pubspec.yaml`, else Expo via package.json / app config + * 3. Default local Spotlight verification + */ +export async function resolveVerifyStrategy( + platform: string | undefined, + cwd: string +): Promise { + const normalized = platform?.trim().toLowerCase(); + + if (normalized === "flutter") { + return flutterStrategy(`wizard.platform=${platform}`); + } + if (normalized && EXPO_PLATFORM_RE.test(normalized)) { + return expoStrategy(`wizard.platform=${platform}`); + } + + if (await hasFlutterProject(cwd)) { + return flutterStrategy("pubspec.yaml"); + } + if (await hasExpoProject(cwd)) { + return expoStrategy("expo project markers"); + } + + return { kind: "local" }; +} + +function flutterStrategy(source: string): FlutterDoctorStrategy { + return { + kind: "doctor", + tool: "flutter", + args: ["flutter", "doctor"], + source, + }; +} + +function expoStrategy(source: string): ExpoDoctorStrategy { + // `npx expo doctor` uses the project's local Expo CLI when present. + const npx = process.platform === "win32" ? "npx.cmd" : "npx"; + return { + kind: "doctor", + tool: "expo", + args: [npx, "expo", "doctor"], + source, + }; +} + +/** True when the directory looks like a Flutter app. */ +export async function hasFlutterProject(cwd: string): Promise { + try { + const pubspec = await readFile(join(cwd, "pubspec.yaml"), "utf-8"); + return FLUTTER_SDK_RE.test(pubspec); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug("Failed to read pubspec.yaml for Flutter detection", error); + } + return false; + } +} + +/** True when the directory looks like an Expo app. */ +export async function hasExpoProject(cwd: string): Promise { + if (await packageJsonDependsOnExpo(cwd)) { + return true; + } + return await hasExpoAppConfig(cwd); +} + +async function packageJsonDependsOnExpo(cwd: string): Promise { + try { + const raw = await readFile(join(cwd, "package.json"), "utf-8"); + const pkg = JSON.parse(raw) as { + dependencies?: Record; + devDependencies?: Record; + }; + return Boolean(pkg.dependencies?.expo || pkg.devDependencies?.expo); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug("Failed to read package.json for Expo detection", error); + } + return false; + } +} + +async function hasExpoAppConfig(cwd: string): Promise { + for (const name of ["app.json", "app.config.json"]) { + try { + const raw = await readFile(join(cwd, name), "utf-8"); + const parsed = JSON.parse(raw) as { expo?: unknown }; + if (parsed.expo !== undefined) { + return true; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug(`Failed to read ${name} for Expo detection`, error); + } + } + } + + for (const name of ["app.config.js", "app.config.ts", "app.config.mjs"]) { + try { + await access(join(cwd, name)); + return true; + } catch { + // Missing — try next + } + } + + return false; +} diff --git a/packages/cli/test/lib/init/verify-doctor.test.ts b/packages/cli/test/lib/init/verify-doctor.test.ts new file mode 100644 index 000000000..aefaa0640 --- /dev/null +++ b/packages/cli/test/lib/init/verify-doctor.test.ts @@ -0,0 +1,190 @@ +/** + * Doctor-based verification tests with child_process mocked before import. + */ + +import { EventEmitter } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEST_TMP_DIR } from "../../constants.js"; +import { createMockUI } from "./ui/mock-ui.js"; + +const mocks = vi.hoisted(() => ({ + captureException: vi.fn(), + spawnCalls: [] as Array<{ + command: string; + args: string[]; + options: { cwd?: string; env?: Record }; + }>, + whichSync: vi.fn((cmd: string) => (cmd === "missing" ? null : `/bin/${cmd}`)), + nextExitCode: 0 as number, + nextStdout: "" as string, + nextStderr: "" as string, + spawnError: null as Error | null, +})); + +vi.mock("@sentry/node-core/light", () => ({ + captureException: mocks.captureException, +})); + +vi.mock("../../../src/lib/which.js", () => ({ + whichSync: mocks.whichSync, +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn( + ( + command: string, + args: string[], + options: { cwd?: string; env?: Record } + ) => { + mocks.spawnCalls.push({ command, args, options }); + if (mocks.spawnError) { + throw mocks.spawnError; + } + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null; + kill: (signal: NodeJS.Signals) => boolean; + pid: number; + stderr: PassThrough; + stdout: PassThrough; + }; + child.pid = 4242; + child.exitCode = null; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + queueMicrotask(() => { + if (mocks.nextStdout) { + child.stdout.write(`${mocks.nextStdout}\n`); + } + if (mocks.nextStderr) { + child.stderr.write(`${mocks.nextStderr}\n`); + } + child.exitCode = mocks.nextExitCode; + child.emit("close", mocks.nextExitCode); + }); + return child; + } + ), + }; +}); + +import { verifyWithDoctor } from "../../../src/lib/init/verify-doctor.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "verify-doctor-")); + mocks.captureException.mockClear(); + mocks.spawnCalls.length = 0; + mocks.whichSync.mockImplementation((cmd: string) => + cmd === "missing" ? null : `/bin/${cmd}` + ); + mocks.nextExitCode = 0; + mocks.nextStdout = + "Doctor summary (to see all details, run flutter doctor -v):"; + mocks.nextStderr = ""; + mocks.spawnError = null; +}); + +// Keep the long fixture string readable without wrapping mid-sentence. +const EXPO_FAIL_STDOUT = + "14/15 checks passed. 1 checks failed. See above for details."; + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("verifyWithDoctor", () => { + test("reports success when flutter doctor exits 0", async () => { + const { ui, calls } = createMockUI(); + await verifyWithDoctor( + { + kind: "doctor", + tool: "flutter", + args: ["flutter", "doctor"], + source: "wizard.platform=flutter", + }, + { status: "success", result: { platform: "flutter" } }, + ui, + tmpDir + ); + + expect(mocks.spawnCalls[0]).toMatchObject({ + command: "flutter", + args: ["doctor"], + }); + expect(calls).toContainEqual({ + kind: "log.success", + message: "Verified — flutter doctor passed", + }); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + test("reports failure and captures telemetry when expo doctor fails", async () => { + mocks.nextExitCode = 1; + mocks.nextStdout = EXPO_FAIL_STDOUT; + const { ui, calls } = createMockUI(); + + await verifyWithDoctor( + { + kind: "doctor", + tool: "expo", + args: ["npx", "expo", "doctor"], + source: "expo project markers", + }, + { status: "success", result: { platform: "javascript-expo" } }, + ui, + tmpDir + ); + + expect(calls.some((c) => c.kind === "log.warn")).toBe(true); + expect(mocks.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "init verification failed" }), + expect.objectContaining({ + tags: expect.objectContaining({ + "wizard.verify": "doctor_failed", + "wizard.platform": "javascript-expo", + }), + extra: expect.objectContaining({ + doctorTool: "expo", + exitCode: 1, + }), + }) + ); + }); + + test("skips when flutter is missing from PATH", async () => { + mocks.whichSync.mockReturnValue(null); + const { ui, calls } = createMockUI(); + + await verifyWithDoctor( + { + kind: "doctor", + tool: "flutter", + args: ["flutter", "doctor"], + source: "pubspec.yaml", + }, + { status: "success", result: { platform: "flutter" } }, + ui, + tmpDir + ); + + expect(mocks.spawnCalls).toHaveLength(0); + expect(calls).toContainEqual({ + kind: "log.info", + message: "Skipping verification — flutter is not on PATH", + }); + expect(mocks.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "init verification skipped" }), + expect.objectContaining({ + tags: expect.objectContaining({ "wizard.verify": "no_flutter" }), + }) + ); + }); +}); diff --git a/packages/cli/test/lib/init/verify-strategy.test.ts b/packages/cli/test/lib/init/verify-strategy.test.ts new file mode 100644 index 000000000..92f0e4890 --- /dev/null +++ b/packages/cli/test/lib/init/verify-strategy.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for post-init verification strategy selection (Flutter / Expo / local). + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + hasExpoProject, + hasFlutterProject, + resolveVerifyStrategy, +} from "../../../src/lib/init/verify-strategy.js"; +import { TEST_TMP_DIR } from "../../constants.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "verify-strategy-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("resolveVerifyStrategy", () => { + test("uses flutter doctor when wizard platform is flutter", async () => { + const strategy = await resolveVerifyStrategy("flutter", tmpDir); + expect(strategy).toEqual({ + kind: "doctor", + tool: "flutter", + args: ["flutter", "doctor"], + source: "wizard.platform=flutter", + }); + }); + + test("uses expo doctor when wizard platform contains expo", async () => { + const strategy = await resolveVerifyStrategy("javascript-expo", tmpDir); + expect(strategy.kind).toBe("doctor"); + if (strategy.kind !== "doctor") { + return; + } + expect(strategy.tool).toBe("expo"); + expect(strategy.args.slice(1)).toEqual(["expo", "doctor"]); + expect(strategy.source).toBe("wizard.platform=javascript-expo"); + }); + + test("prefers wizard flutter platform over expo filesystem markers", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ dependencies: { expo: "~52.0.0" } }) + ); + const strategy = await resolveVerifyStrategy("flutter", tmpDir); + expect(strategy).toMatchObject({ kind: "doctor", tool: "flutter" }); + }); + + test("detects Flutter from pubspec.yaml when platform is unset", async () => { + await writeFile( + join(tmpDir, "pubspec.yaml"), + "name: demo\nenvironment:\n sdk: flutter\n" + ); + const strategy = await resolveVerifyStrategy(undefined, tmpDir); + expect(strategy).toEqual({ + kind: "doctor", + tool: "flutter", + args: ["flutter", "doctor"], + source: "pubspec.yaml", + }); + }); + + test("detects Expo from package.json when platform is unset", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ dependencies: { expo: "51.0.0", react: "18.0.0" } }) + ); + const strategy = await resolveVerifyStrategy(undefined, tmpDir); + expect(strategy).toMatchObject({ + kind: "doctor", + tool: "expo", + source: "expo project markers", + }); + }); + + test("detects Expo from app.json expo key", async () => { + await writeFile( + join(tmpDir, "app.json"), + JSON.stringify({ expo: { name: "demo", slug: "demo" } }) + ); + expect(await hasExpoProject(tmpDir)).toBe(true); + const strategy = await resolveVerifyStrategy("react-native", tmpDir); + expect(strategy).toMatchObject({ kind: "doctor", tool: "expo" }); + }); + + test("prefers Flutter filesystem markers over Expo when both exist", async () => { + await writeFile( + join(tmpDir, "pubspec.yaml"), + "name: demo\ndependencies:\n flutter:\n sdk: flutter\n" + ); + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ dependencies: { expo: "51.0.0" } }) + ); + const strategy = await resolveVerifyStrategy(undefined, tmpDir); + expect(strategy).toMatchObject({ kind: "doctor", tool: "flutter" }); + }); + + test("falls back to local verification for ordinary JS projects", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "next dev" } }) + ); + expect(await resolveVerifyStrategy("javascript-nextjs", tmpDir)).toEqual({ + kind: "local", + }); + }); +}); + +describe("hasFlutterProject", () => { + test("requires an sdk: flutter constraint", async () => { + await writeFile( + join(tmpDir, "pubspec.yaml"), + "name: pure_dart\nenvironment:\n sdk: '>=3.0.0 <4.0.0'\n" + ); + expect(await hasFlutterProject(tmpDir)).toBe(false); + }); +});