diff --git a/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap b/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap index 6f64b467..3fb015df 100644 --- a/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap +++ b/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap @@ -22,6 +22,10 @@ exports[`e2e flow snapshot — posthog-integration > Next.js (with a setup quest }, { "action": "choose", + "params": { + "key": "router", + "value": "app-router", + }, "screen": "setup", }, { @@ -38,6 +42,9 @@ exports[`e2e flow snapshot — posthog-integration > Next.js (with a setup quest }, { "action": "set_mcp_outcome", + "params": { + "outcome": "skipped", + }, "screen": "mcp", }, { @@ -46,6 +53,9 @@ exports[`e2e flow snapshot — posthog-integration > Next.js (with a setup quest }, { "action": "keep_skills", + "params": { + "kept": false, + }, "screen": "keep-skills", }, ], @@ -78,6 +88,9 @@ exports[`e2e flow snapshot — posthog-integration > Node (no setup question) wa }, { "action": "set_mcp_outcome", + "params": { + "outcome": "skipped", + }, "screen": "mcp", }, { @@ -86,8 +99,102 @@ exports[`e2e flow snapshot — posthog-integration > Node (no setup question) wa }, { "action": "keep_skills", + "params": { + "kept": false, + }, "screen": "keep-skills", }, ], } `; + +exports[`e2e flow snapshot — self-driving > already-integrated (skips SDK setup) walks a stable path 1`] = ` +{ + "program": "self-driving", + "trace": [ + { + "action": "confirm_setup", + "screen": "self-driving-intro", + }, + { + "action": "set_integrate", + "params": { + "integrate": false, + }, + "screen": "self-driving-integration-check", + }, + { + "action": "dismiss_outage", + "screen": "health-check", + }, + { + "action": "(external)", + "screen": "auth", + }, + { + "action": "(external)", + "screen": "run", + }, + { + "action": "dismiss_outro", + "screen": "outro", + }, + ], +} +`; + +exports[`e2e flow snapshot — self-driving > integration-first (no existing PostHog) walks a stable path 1`] = ` +{ + "profile": { + "ask": "first", + "healthCheck": "dismiss", + "integrate": true, + "mcp": "skip", + "setup": "first", + "skills": "delete", + "slack": "skip", + }, + "program": "self-driving", + "trace": [ + { + "action": "confirm_setup", + "screen": "self-driving-intro", + }, + { + "action": "set_integrate", + "params": { + "integrate": true, + }, + "screen": "self-driving-integration-check", + }, + { + "action": "dismiss_outage", + "screen": "health-check", + }, + { + "action": "(external)", + "screen": "auth", + }, + { + "action": "(external)", + "screen": "self-driving-integration-detect", + }, + { + "action": "(external)", + "screen": "run", + }, + { + "action": "confirm_self_driving_handoff", + "screen": "self-driving-handoff", + }, + { + "action": "(external)", + "screen": "run", + }, + { + "action": "dismiss_outro", + "screen": "outro", + }, + ], +} +`; diff --git a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts index ac549020..537bb7f9 100644 --- a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts +++ b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts @@ -20,36 +20,59 @@ import { buildSession, RunPhase } from '@lib/wizard-session'; import { Integration } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { WizardReadiness } from '@lib/health-checks/readiness'; -import { Program } from '@lib/programs/program-registry'; +import { + Program, + getProgramConfig, + type ProgramId, +} from '@lib/programs/program-registry'; import { ScreenId } from '@ui/tui/router'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/detect'; import { WizardCiDriver } from '../wizard-ci-driver'; -import { decideE2eAction } from '../e2e-profile'; +import { decideE2eAction, type WizardE2eProfile } from '../e2e-profile'; import { profileFor } from '../profiles'; /** - * Walk the program flow offline using its e2e profile, injecting the external - * transitions a real run gets from the runner (auth) and the agent (runPhase) - * and the health probe. Returns the ordered (screen, action) trace. + * Walk a program flow offline using an e2e profile, injecting the external + * transitions a real run gets from the runner (auth), the agent (runPhase), and + * the health probe. Returns the ordered (screen, action) trace. Stops at the + * terminal Exit screen or when a profile decision marks the run done. */ function traceFlow( - integration: Integration, -): Array<{ screen: string; action: string }> { - const store = new WizardStore(Program.PostHogIntegration); + program: ProgramId, + profile: WizardE2eProfile, + integration?: Integration, +): Array<{ + screen: string; + action: string; + params?: Record; +}> { + const store = new WizardStore(program); setUI(new InkUI(store)); const session = buildSession({ installDir: '/tmp/e2e-snap', ci: true }); - session.integration = integration; - session.frameworkConfig = FRAMEWORK_REGISTRY[integration]; + if (integration) { + session.integration = integration; + session.frameworkConfig = FRAMEWORK_REGISTRY[integration]; + } store.session = session; const driver = new WizardCiDriver(store); - const profile = profileFor(Program.PostHogIntegration); - const trace: Array<{ screen: string; action: string }> = []; + const trace: Array<{ + screen: string; + action: string; + params?: Record; + }> = []; for (let guard = 0; guard < 40; guard++) { const state = driver.readState(); const screen = state.currentScreen; + if (screen === ScreenId.Exit) break; // terminal (self-driving outro exits) + const decision = decideE2eAction(state, profile); - trace.push({ screen, action: decision.action?.id ?? '(external)' }); + trace.push({ + screen, + action: decision.action?.id ?? '(external)', + ...(decision.action?.params ? { params: decision.action.params } : {}), + }); if (decision.action) { driver.performAction(decision.action.id, decision.action.params ?? {}); @@ -69,8 +92,31 @@ function traceFlow( host: 'https://us.posthog.com', projectId: 1, }); + } else if (screen === ScreenId.SelfDrivingIntegrationDetect) { + // The detect screen self-advances in ci by picking a project; simulate + // that pick (framework + path) so the run phase can proceed. + store.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, '.'); + store.setFrameworkConfig( + Integration.javascriptNode, + FRAMEWORK_REGISTRY[Integration.javascriptNode], + ); } else if (screen === ScreenId.Run) { - store.setRunPhase(RunPhase.Completed); + // The run screen is shared by composed run steps (a step carrying its own + // `run` thunk, e.g. self-driving's integrate-run) and the program's own + // run. Complete the active run step the way the runner would: a composed + // step via completeRunStep, the main run via runPhase. + const steps = getProgramConfig(store.router.activeProgram).steps; + const runStep = steps.find( + (s) => + s.screenId === 'run' && + (!s.show || s.show(store.session)) && + (!s.isComplete || !s.isComplete(store.session)), + ); + if (runStep?.run) { + store.completeRunStep(runStep.id); + } else { + store.setRunPhase(RunPhase.Completed); + } } if (decision.done || store.session.skillsComplete) break; @@ -79,18 +125,48 @@ function traceFlow( } describe('e2e flow snapshot — posthog-integration', () => { + const profile = profileFor(Program.PostHogIntegration); + it('Next.js (with a setup question) walks a stable path', () => { expect({ program: 'posthog-integration', - profile: profileFor(Program.PostHogIntegration), - trace: traceFlow(Integration.nextjs), + profile, + trace: traceFlow(Program.PostHogIntegration, profile, Integration.nextjs), }).toMatchSnapshot(); }); it('Node (no setup question) walks a stable path', () => { expect({ program: 'posthog-integration', - trace: traceFlow(Integration.javascriptNode), + trace: traceFlow( + Program.PostHogIntegration, + profile, + Integration.javascriptNode, + ), + }).toMatchSnapshot(); + }); +}); + +describe('e2e flow snapshot — self-driving', () => { + const profile = profileFor(Program.SelfDriving); + + it('integration-first (no existing PostHog) walks a stable path', () => { + expect({ + program: 'self-driving', + profile, + trace: traceFlow(Program.SelfDriving, profile), + }).toMatchSnapshot(); + }); + + it('already-integrated (skips SDK setup) walks a stable path', () => { + // Same flow, answering "yes, already integrated" at the check. + const alreadyIntegrated: WizardE2eProfile = { + ...profile, + integrate: false, + }; + expect({ + program: 'self-driving', + trace: traceFlow(Program.SelfDriving, alreadyIntegrated), }).toMatchSnapshot(); }); }); diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index 230923b5..d55fc416 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -172,6 +172,46 @@ describe('WizardCiDriver — wizard_ask overlay', () => { }); }); +describe('WizardCiDriver — self-driving integration check', () => { + function selfDrivingStore(): WizardStore { + const store = new WizardStore(Program.SelfDriving); + setUI(new InkUI(store)); + store.session = buildSession({ installDir: '/tmp/ci-driver-sd', ci: true }); + return store; + } + + it('exposes the integration check and commits set_integrate', () => { + const store = selfDrivingStore(); + const driver = new WizardCiDriver(store); + + // Intro → integration-check. + store.completeSetup(); + const state = driver.readState(); + expect(state.currentScreen).toBe(ScreenId.SelfDrivingIntegrationCheck); + expect(state.session.integrate).toBeNull(); + expect(state.actions.map((a) => a.id)).toContain('set_integrate'); + + // Answer "no, set it up first" → integrate=true, advances off the screen. + const next = driver.performAction('set_integrate', { integrate: true }); + expect(next.session.integrate).toBe(true); + expect(next.currentScreen).not.toBe(ScreenId.SelfDrivingIntegrationCheck); + }); + + it('skips the integration check when --integrate pre-resolved it', () => { + const store = selfDrivingStore(); + store.session = buildSession({ + installDir: '/tmp/ci-driver-sd', + integrate: true, + }); + const driver = new WizardCiDriver(store); + + store.completeSetup(); + expect(driver.readState().currentScreen).not.toBe( + ScreenId.SelfDrivingIntegrationCheck, + ); + }); +}); + describe('action registry exhaustiveness', () => { it('every screen and overlay is either actionable or explicitly no-action', () => { const allScreens = [...Object.values(ScreenId), ...Object.values(Overlay)]; diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index ee81d359..3ee7ab9f 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -70,6 +70,8 @@ export const NO_ACTION_SCREENS: ReadonlySet = new Set([ ScreenId.Exit, ScreenId.AuditRun, ScreenId.DoctorReport, + // The detector + picker are interactive; no headless e2e drives this screen. + ScreenId.SelfDrivingIntegrationDetect, ScreenId.SourceMapsDetect, ScreenId.SourceMapsOutro, ScreenId.AuditOutro, @@ -101,6 +103,28 @@ export const ACTION_REGISTRY: Partial> = { [ScreenId.WarehouseIntro]: [confirmSetupAction], [ScreenId.SelfDrivingIntro]: [confirmSetupAction], + // ── Self-driving integration check ──────────────────────────────────── + [ScreenId.SelfDrivingIntegrationCheck]: [ + { + id: 'set_integrate', + description: + 'Answer the self-driving integration check. integrate=true sets up ' + + 'the PostHog SDK first; false goes straight to Self-driving.', + params: { integrate: 'boolean (default false)' }, + apply: (store, params) => store.setIntegrate(params.integrate === true), + }, + ], + + // ── Self-driving handoff (after the integration run) ─────────────────── + [ScreenId.SelfDrivingHandoff]: [ + { + id: 'confirm_self_driving_handoff', + description: + 'Acknowledge the post-integration handoff and start the Self-driving run.', + apply: (store) => store.confirmSelfDrivingHandoff(), + }, + ], + // ── Health check — dismiss a blocking outage ────────────────────────── [ScreenId.HealthCheck]: [ { diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index dda16295..6c404ba1 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -27,8 +27,15 @@ export interface WizardE2eProfile { slack: 'skip'; /** Keep or delete the wizard-installed skills at the end. */ skills: 'keep' | 'delete'; - /** Default answer strategy for an agent `wizard_ask` overlay. */ + /** Answer strategy for an agent `wizard_ask` overlay: the first option (its + * affirmative "continue" — e.g. self-driving's "GitHub connected → done"). */ ask: 'first'; + /** + * Self-driving integration-check answer: `true` → "no, set it up first" + * (integrate the SDK as part of the run); `false` → "yes, already + * integrated". Only read on the integration-check screen. + */ + integrate?: boolean; } /** Happy-path default: take every screen forward, leave nothing behind. */ @@ -39,6 +46,7 @@ export const DEFAULT_E2E_PROFILE: WizardE2eProfile = { slack: 'skip', skills: 'delete', ask: 'first', + integrate: false, }; /** What the harness should do for the current screen. */ @@ -92,6 +100,17 @@ export function decideE2eAction( }; } + case ScreenId.SelfDrivingIntegrationCheck: + return { + action: { + id: 'set_integrate', + params: { integrate: profile.integrate === true }, + }, + }; + + case ScreenId.SelfDrivingHandoff: + return { action: { id: 'confirm_self_driving_handoff' } }; + case ScreenId.Outro: return { action: { id: 'dismiss_outro' } }; @@ -145,6 +164,7 @@ export const E2E_DRIVABLE_SCREENS: readonly ScreenName[] = [ ScreenId.Intro, ScreenId.HealthCheck, ScreenId.Setup, + ScreenId.SelfDrivingIntegrationCheck, ScreenId.Outro, ScreenId.Mcp, ScreenId.McpSuggestedPrompts, diff --git a/e2e-harness/profiles.ts b/e2e-harness/profiles.ts index fd094ccf..08fad0d2 100644 --- a/e2e-harness/profiles.ts +++ b/e2e-harness/profiles.ts @@ -11,10 +11,12 @@ import { Program, type ProgramId } from '@lib/programs/program-registry'; import { DEFAULT_E2E_PROFILE, type WizardE2eProfile } from './e2e-profile.js'; import posthogIntegrationE2e from '@lib/programs/posthog-integration/test/e2e.json'; +import selfDrivingE2e from '@lib/programs/self-driving/test/e2e.json'; const PROFILES: Partial> = { [Program.PostHogIntegration]: posthogIntegrationE2e.profile as WizardE2eProfile, + [Program.SelfDriving]: selfDrivingE2e.profile as WizardE2eProfile, }; /** The e2e profile for a program, or the happy-path default if none is set. */ diff --git a/e2e-harness/wizard-ci-driver.ts b/e2e-harness/wizard-ci-driver.ts index e23ce973..6554f68e 100644 --- a/e2e-harness/wizard-ci-driver.ts +++ b/e2e-harness/wizard-ci-driver.ts @@ -51,6 +51,8 @@ export interface CiState { detectedFrameworkLabel: string | null; detectionComplete: boolean; setupConfirmed: boolean; + /** Self-driving integration-check answer; null until decided. */ + integrate: boolean | null; hasCredentials: boolean; projectId: number | null; mcpComplete: boolean; @@ -98,6 +100,7 @@ export class WizardCiDriver { detectedFrameworkLabel: s.detectedFrameworkLabel, detectionComplete: s.detectionComplete, setupConfirmed: s.setupConfirmed, + integrate: s.integrate, hasCredentials: s.credentials !== null, projectId: s.credentials?.projectId ?? null, mcpComplete: s.mcpComplete, diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 0c725eed..9a3c4a02 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -17,12 +17,22 @@ import fs from 'fs'; import net from 'net'; import { startTUI } from '@ui/tui/start-tui'; import { VERSION } from '@lib/version'; -import { Program } from '@lib/programs/program-registry'; +import { + Program, + getProgramConfig, + type ProgramId, +} from '@lib/programs/program-registry'; import { buildSession } from '@lib/wizard-session'; -import { posthogIntegrationConfig } from '@lib/programs/posthog-integration'; import { runAgent } from '@lib/agent/agent-runner'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { logToFile } from '@utils/debug'; +import { join } from 'path'; +import { detectFramework } from '@lib/detection/index'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import type { Integration } from '@lib/constants'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/detect'; +import { ScreenId } from '@ui/tui/router'; import { WizardCiDriver } from '@e2e-harness/wizard-ci-driver'; import { decideE2eAction, @@ -33,6 +43,39 @@ import { profileFor } from '@e2e-harness/profiles'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const mark = (m: string) => logToFile(`[tui-host] ${m}`); +/** + * Pick the project to set PostHog up in, headlessly: the repo root if it's a + * single app, else the first instrumentable sub-app of a monorepo (under + * `apps/` or `packages/`). Mirrors what the detect screen's picker commits, so + * the e2e host can drive a monorepo fixture (e.g. a Turborepo) without + * keystrokes — the store driver can't actuate the interactive picker. + */ +async function pickIntegrationTarget( + root: string, +): Promise<{ integration: Integration; path: string } | null> { + // A monorepo: integrate a real sub-app, not the workspace root (which tends + // to detect as generic node). Scan apps/ then packages/ for the first one + // with a framework. Fall back to the root for a single-app fixture. + for (const group of ['apps', 'packages']) { + let entries: string[]; + try { + entries = fs.readdirSync(join(root, group)).sort(); + } catch { + continue; // group dir absent + } + for (const name of entries) { + const rel = `${group}/${name}`; + if (!fs.statSync(join(root, rel)).isDirectory()) continue; + const fw = await detectFramework(join(root, rel)); + if (fw && FRAMEWORK_REGISTRY[fw]) return { integration: fw, path: rel }; + } + } + const rootFw = await detectFramework(root); + return rootFw && FRAMEWORK_REGISTRY[rootFw] + ? { integration: rootFw, path: '.' } + : null; +} + async function main() { const apiKey = ( process.env.POSTHOG_PERSONAL_API_KEY ?? @@ -41,8 +84,19 @@ async function main() { : '') ).trim(); const projectId = process.env.PROJECT_ID!; + // Which program to drive — defaults to the integration flow. Set PROGRAM to + // an id (e.g. `self-driving`) to host a different one. + const programId = + (process.env.PROGRAM as ProgramId) || Program.PostHogIntegration; + const programConfig = getProgramConfig(programId); - const { store } = startTUI(VERSION, Program.PostHogIntegration); + // This host answers wizard_ask via its e2e driver, so keep the ask bridge + // wired even though the session is `ci` (which here is only for headless + // auth). Without it, ask-driven flows like self-driving abort with + // requires-interactive-mode the moment they need to ask a question. + process.env.WIZARD_ASK_AUTODRIVE = '1'; + + const { store } = startTUI(VERSION, programId); store.session = buildSession({ installDir: process.env.APP_DIR!, ci: true, @@ -50,6 +104,11 @@ async function main() { projectId, region: 'us', }); + // Optional skip-ahead: pre-resolve the self-driving integration check so its + // screen never shows (INTEGRATE=true integrates first; false = already set up). + if (process.env.INTEGRATE === 'true' || process.env.INTEGRATE === 'false') { + store.setIntegrate(process.env.INTEGRATE === 'true'); + } const driver = new WizardCiDriver(store); // Resolve credentials from the phx key (same bearer as an OAuth token) and set @@ -60,7 +119,7 @@ async function main() { ci: true, apiKey, projectId: Number(projectId), - programId: Program.PostHogIntegration, + programId, }); store.setCredentials({ accessToken: d.accessToken, @@ -70,12 +129,45 @@ async function main() { }); }; - // Pass the intro and health-check gates and run the real integration agent. - // The auth and run screens never advance on their own; this is what moves them. - const runIntegration = async () => { + // Pass the pre-run gates and run the program's real agent. The auth and run + // screens never advance on their own; this is what moves them. Mirrors + // run-wizard's flow, including in-program run phases. + const runProgram = async () => { await store.getGate('intro'); + await store.getGate('integration-check'); await store.getGate('health-check'); - await runAgent(posthogIntegrationConfig, store.session); + + // Mirror run-wizard's composed walk for programs whose steps splice in + // their own run steps (self-driving: detect → integrate → handoff → run). + // `authenticate` here resolves the phx key, not OAuth, since the session is + // built with ci + apiKey. + if (programConfig.steps.some((s) => s.run)) { + for (const step of programConfig.steps) { + if (step.screenId === 'outro') break; + if (step.show && !step.show(store.session)) continue; + if (step.screenId === 'auth') { + await authenticate(store.session, programConfig.id); + } else if (step.run) { + const live = store.session; + const runSession = step.targetDir + ? { + ...live, + installDir: step.targetDir(live), + frameworkContext: { ...live.frameworkContext }, + } + : live; + if (step.onRunPrep) await step.onRunPrep(runSession); + await step.run(runSession); + store.completeRunStep(step.id); + } else if (step.screenId === 'run') { + await runAgent(programConfig, store.session); + } else if (step.isComplete) { + await store.waitUntil(step.isComplete); + } + } + } else { + await runAgent(programConfig, store.session); + } }; if (process.env.MODE === 'serve') return serve(); @@ -115,7 +207,7 @@ async function main() { runStatus = 'running'; void (async () => { try { - await runIntegration(); + await runProgram(); runStatus = 'done'; } catch (e) { runStatus = 'failed'; @@ -160,7 +252,7 @@ async function main() { // ---- CI route: self-drive the fixed profile, snapshot each screen ---- async function fixed() { const CTRL = process.env.SNAP_CTRL!; - const profile: WizardE2eProfile = profileFor(Program.PostHogIntegration); + const profile: WizardE2eProfile = profileFor(programId); const screenPath: string[] = []; // Snapshot on key moments — a screen change, a task-list update, or a // runPhase change — so the run screen's progression (the agent working) is @@ -176,6 +268,10 @@ async function main() { overlay: store.router.hasOverlay, tasks: store.tasks.map((t) => [t.label, t.status, t.done]), phase: store.session.runPhase, + // Snap on within-screen state too: when a screen publishes new + // framework-context (e.g. the detector's projects), so the picker frame + // is captured, not just the loading state. Generic — keys, not values. + ctx: Object.keys(store.session.frameworkContext).sort().join(','), }); const snap = (): Promise => { const sig = signature(); @@ -198,6 +294,29 @@ async function main() { await snap(); // capture this screen as presented, before acting const state = driver.readState(); const before = state.currentScreen; + + // Headless detect: the screen runs a real detector + an interactive + // pick the store driver can't actuate. Inject the pick — the repo root + // for a single app, or a monorepo's first instrumentable sub-app — so + // the composed integrate-run can proceed. + if ( + state.currentScreen === ScreenId.SelfDrivingIntegrationDetect && + state.session.integration == null + ) { + const pick = await pickIntegrationTarget(store.session.installDir); + if (pick) { + store.setFrameworkContext( + SELF_DRIVING_INTEGRATE_PATH_KEY, + pick.path, + ); + store.setFrameworkConfig( + pick.integration, + FRAMEWORK_REGISTRY[pick.integration], + ); + } + continue; + } + let acted = false; try { const decision = decideE2eAction(state, profile); @@ -218,33 +337,40 @@ async function main() { }; const drive = driverLoop(); - await store.runReadyHooks(); - await runIntegration(); - const deadline = Date.now() + 120_000; - while (!store.session.skillsComplete && Date.now() < deadline) - await driver.waitForChange(5_000); - // The run reached skillsComplete, so the driver loop is done — but it may be - // parked in waitForChange, so don't block on it; the process exit ends it. - stop = true; - void drive; - unsub(); - await snap(); // the final screen - await chain; // flush any pending snapshots - - // Structured result the --e2e assertion path reads: run phase, posthog deps, - // env file, and the screens walked. - if (process.env.E2E_RESULT_JSON) { + // Write the structured result the --e2e assertion path reads. Programs whose + // outro is terminal (self-driving) exit via the outro's ExitScreen before + // the end-of-run path below, so capture it the moment the outro is reached; + // integration re-writes it after keep-skills (skillsComplete). + const writeResult = (): void => { + if (!process.env.E2E_RESULT_JSON) return; const appDir = process.env.APP_DIR!; - let deps: string[] = []; - try { - const pkg = JSON.parse( - fs.readFileSync(`${appDir}/package.json`, 'utf8'), - ); - deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }); - } catch { - /* some frameworks have no package.json */ + // Scan the root and any workspace package.json — a monorepo installs the + // SDK into the picked sub-app (e.g. apps/expo), not the root, so a + // root-only read would miss it. Mirrors pickIntegrationTarget's + // apps/packages scan. + const pkgPaths = [`${appDir}/package.json`]; + for (const ws of ['apps', 'packages']) { + try { + for (const sub of fs.readdirSync(`${appDir}/${ws}`)) + pkgPaths.push(`${appDir}/${ws}/${sub}/package.json`); + } catch { + /* not a monorepo */ + } } - const posthogDeps = deps.filter((d) => d.includes('posthog')); + const deps: string[] = []; + for (const p of pkgPaths) { + try { + const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); + deps.push( + ...Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }), + ); + } catch { + /* missing or no package.json */ + } + } + const posthogDeps = [ + ...new Set(deps.filter((d) => d.includes('posthog'))), + ]; let envFile: string | null = null; try { const hit = fs @@ -273,7 +399,25 @@ async function main() { 2, ), ); - } + }; + const unsubResult = store.subscribe(() => { + if (store.currentScreen === 'outro') writeResult(); + }); + + await store.runReadyHooks(); + await runProgram(); + const deadline = Date.now() + 120_000; + while (!store.session.skillsComplete && Date.now() < deadline) + await driver.waitForChange(5_000); + // The run reached skillsComplete, so the driver loop is done — but it may be + // parked in waitForChange, so don't block on it; the process exit ends it. + stop = true; + void drive; + unsub(); + unsubResult(); + await snap(); // the final screen + await chain; // flush any pending snapshots + writeResult(); // final write (integration: after keep-skills) process.exit(0); } } diff --git a/src/lib/agent/runner/linear.ts b/src/lib/agent/runner/linear.ts index 8da01a2d..57a4135c 100644 --- a/src/lib/agent/runner/linear.ts +++ b/src/lib/agent/runner/linear.ts @@ -93,10 +93,12 @@ export async function runLinearProgram( getUI().startRun(); - // wizard_ask is only available in interactive mode. CI/signup users have - // no way to answer; we omit the bridge so the tool returns an actionable - // error rather than hanging on a never-resolving prompt. - const askDisabled = shouldDisableAsk(session); + // wizard_ask needs an answerer. A human answers at the keyboard; the e2e + // snapshot/MCP host answers via its driver and sets WIZARD_ASK_AUTODRIVE. + // CI/signup with neither has no answerer, so we omit the bridge and the tool + // returns an actionable error rather than hanging on a never-resolving prompt. + const askDisabled = + shouldDisableAsk(session) && process.env.WIZARD_ASK_AUTODRIVE !== '1'; const askBridge = askDisabled ? undefined : createWizardAskBridge({ diff --git a/src/lib/programs/self-driving/test/e2e.json b/src/lib/programs/self-driving/test/e2e.json new file mode 100644 index 00000000..180d25bf --- /dev/null +++ b/src/lib/programs/self-driving/test/e2e.json @@ -0,0 +1,51 @@ +{ + "program": "self-driving", + "summary": "Self-driving, integration-first: answer \"no, set it up first\" at the integration check so the run sets up the PostHog SDK before Self-driving. After auth the detector scans the repo and a project is picked; the composed integration run instruments it; a handoff bridges to the Self-driving run; then the terminal outro exits (no keep-skills). The integration run's own screens + task list are not captured in this self-driving control-plane trace.", + "profile": { + "setup": "first", + "healthCheck": "dismiss", + "mcp": "skip", + "slack": "skip", + "skills": "delete", + "ask": "first", + "integrate": true + }, + "path": [ + { + "screen": "self-driving-intro", + "auto": "confirm & continue" + }, + { + "screen": "self-driving-integration-check", + "auto": "answer \"no, set it up first\" → integrate=true. \"--integrate\" pre-answers this and skips the screen." + }, + { + "screen": "health-check", + "auto": "dismiss outage — proceed even if the readiness probe flags an issue" + }, + { + "screen": "auth", + "auto": "(external) — the runner injects credentials" + }, + { + "screen": "self-driving-integration-detect", + "auto": "(external) — the detector scans the repo; the pick (framework + path) commits which project to set PostHog up in" + }, + { + "screen": "run", + "auto": "(external) — the composed integration run sets up the PostHog SDK in the picked project" + }, + { + "screen": "self-driving-handoff", + "auto": "acknowledge — \"PostHog is installed, now set up Self-driving\"" + }, + { + "screen": "run", + "auto": "(external) — the Self-driving agent run (signal sources, scouts, inbox)" + }, + { + "screen": "outro", + "auto": "dismiss — terminal: self-driving has no keep-skills, so the outro exits the wizard" + } + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 26d06cec..a91ac723 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -71,8 +71,6 @@ export default defineConfig({ '**/node_modules/**', '**/dist/**', '**/e2e-tests/**', - // The e2e harness and its tests live in a separate stacked PR. - '**/e2e-harness/**', '**/*.no-jest.*', '**/*.d.ts', ],