From 54ab4734aa59a489185d14e5c7839b8e1c0211a6 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 29 Jun 2026 20:29:55 -0400 Subject: [PATCH 1/8] test(self-driving): e2e harness + snapshots Stacks the self-driving e2e testing onto the feature. The detect screen is interactive-only, so it's listed no-action; the harness covers the rest of the flow plus the offline flow-trace snapshot. Re-enables the e2e-harness test suite the base PR excludes from its run. Co-Authored-By: Claude Opus 4.8 --- .../e2e-flow-snapshot.test.ts.snap | 107 +++++++++++++++++ .../__tests__/e2e-flow-snapshot.test.ts | 110 +++++++++++++++--- .../__tests__/wizard-ci-driver.test.ts | 40 +++++++ e2e-harness/action-registry.ts | 24 ++++ e2e-harness/e2e-profile.ts | 19 +++ e2e-harness/profiles.ts | 2 + e2e-harness/wizard-ci-driver.ts | 3 + scripts/tui-host.no-jest.ts | 93 ++++++++++----- src/lib/programs/self-driving/test/e2e.json | 36 ++++++ vitest.config.ts | 2 - 10 files changed, 390 insertions(+), 46 deletions(-) create mode 100644 src/lib/programs/self-driving/test/e2e.json 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..642d1352 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 in-program run phases (steps with + // `runProgram`, e.g. self-driving's integrate-run) and the main run. If + // the active run step is such a phase, simulate completePhase; otherwise + // complete the main run. + 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?.runProgram) { + store.completePhase(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..b7aed331 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -29,6 +29,12 @@ export interface WizardE2eProfile { skills: 'keep' | 'delete'; /** Default answer strategy for an agent `wizard_ask` overlay. */ 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 +45,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 +99,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 +163,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..c16d006f 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -17,10 +17,17 @@ 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 { + hasRunPhases, + runInProgramPhases, +} from '@lib/runners/in-program-phases'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { logToFile } from '@utils/debug'; import { WizardCiDriver } from '@e2e-harness/wizard-ci-driver'; @@ -41,8 +48,13 @@ 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); + const { store } = startTUI(VERSION, programId); store.session = buildSession({ installDir: process.env.APP_DIR!, ci: true, @@ -50,6 +62,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 +77,7 @@ async function main() { ci: true, apiKey, projectId: Number(projectId), - programId: Program.PostHogIntegration, + programId, }); store.setCredentials({ accessToken: d.accessToken, @@ -70,12 +87,22 @@ 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); + + // In-program run phases (self-driving's detect → integrate → handoff), the + // same path run-wizard takes — here `authenticate` resolves the phx key, not + // OAuth, since the session is built with ci + apiKey. + if (hasRunPhases(programConfig, store.session)) { + await runInProgramPhases(store, programConfig); + } + + await runAgent(programConfig, store.session); }; if (process.env.MODE === 'serve') return serve(); @@ -115,7 +142,7 @@ async function main() { runStatus = 'running'; void (async () => { try { - await runIntegration(); + await runProgram(); runStatus = 'done'; } catch (e) { runStatus = 'failed'; @@ -160,7 +187,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 +203,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(); @@ -218,22 +249,12 @@ 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 { @@ -273,7 +294,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/programs/self-driving/test/e2e.json b/src/lib/programs/self-driving/test/e2e.json new file mode 100644 index 00000000..0d9e6a6b --- /dev/null +++ b/src/lib/programs/self-driving/test/e2e.json @@ -0,0 +1,36 @@ +{ + "program": "self-driving", + "summary": "Self-driving, integration-first: answer \"no\" to the integration check so the runner runs the real integration program as a prelude first (its own screens + task list — not captured in this self-driving control-plane trace), then the self-driving intro, run, and its own outro (no keep-skills — the outro exits).", + "profile": { + "setup": "first", + "healthCheck": "dismiss", + "mcp": "skip", + "slack": "skip", + "skills": "delete", + "ask": "first", + "integrate": true + }, + "path": [ + { + "screen": "self-driving-integration-check", + "auto": "answer \"no, set it up first\" → integrate=true. The runner then runs the integration program as a prelude (its own flow) before continuing. \"--integrate\" pre-answers this and skips the screen." + }, + { + "screen": "self-driving-intro", + "auto": "confirm & continue — \"now we can set up Self-driving\" (shown after any integration prelude)" + }, + { + "screen": "health-check", + "auto": "dismiss outage — proceed even if the readiness probe flags an issue" + }, + { "screen": "auth", "auto": "(external) — the runner injects credentials" }, + { + "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', ], From 584a018b507b8cef9bb6acc8db3dee0daba80fbe Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 29 Jun 2026 22:20:49 -0400 Subject: [PATCH 2/8] Fix tui-host: mirror run-wizard's composed walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit in-program-phases.ts was removed; the host now advances each composed step the same way run-wizard does (auth → run steps → gating screens). Co-Authored-By: Claude Opus 4.8 --- scripts/tui-host.no-jest.ts | 42 +++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index c16d006f..3b210107 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -24,10 +24,7 @@ import { } from '@lib/programs/program-registry'; import { buildSession } from '@lib/wizard-session'; import { runAgent } from '@lib/agent/agent-runner'; -import { - hasRunPhases, - runInProgramPhases, -} from '@lib/runners/in-program-phases'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { logToFile } from '@utils/debug'; import { WizardCiDriver } from '@e2e-harness/wizard-ci-driver'; @@ -95,14 +92,37 @@ async function main() { await store.getGate('integration-check'); await store.getGate('health-check'); - // In-program run phases (self-driving's detect → integrate → handoff), the - // same path run-wizard takes — here `authenticate` resolves the phx key, not - // OAuth, since the session is built with ci + apiKey. - if (hasRunPhases(programConfig, store.session)) { - await runInProgramPhases(store, programConfig); + // 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); } - - await runAgent(programConfig, store.session); }; if (process.env.MODE === 'serve') return serve(); From c618f2058e58ea3f4f6ef8ee4fc7f04b1092832e Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 29 Jun 2026 22:32:37 -0400 Subject: [PATCH 3/8] Fix e2e flow trace for the composed-run model run-step composition replaced runProgram/completePhase with run/ completeRunStep; the trace test completes the integrate-run step the same way. Regenerate the self-driving golden (detect -> integrate -> handoff -> run). Co-Authored-By: Claude Opus 4.8 --- e2e-harness/__tests__/e2e-flow-snapshot.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts index 642d1352..537bb7f9 100644 --- a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts +++ b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts @@ -101,10 +101,10 @@ function traceFlow( FRAMEWORK_REGISTRY[Integration.javascriptNode], ); } else if (screen === ScreenId.Run) { - // The run screen is shared by in-program run phases (steps with - // `runProgram`, e.g. self-driving's integrate-run) and the main run. If - // the active run step is such a phase, simulate completePhase; otherwise - // complete the main run. + // 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) => @@ -112,8 +112,8 @@ function traceFlow( (!s.show || s.show(store.session)) && (!s.isComplete || !s.isComplete(store.session)), ); - if (runStep?.runProgram) { - store.completePhase(runStep.id); + if (runStep?.run) { + store.completeRunStep(runStep.id); } else { store.setRunPhase(RunPhase.Completed); } From d3f1d548962cbd97a2a0e419b178a5e2d1a03a0f Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 29 Jun 2026 22:41:14 -0400 Subject: [PATCH 4/8] e2e: drive the self-driving detect pick + refresh the profile path - tui-host injects the detect pick (detect the framework at the install dir, single-app fixtures) so the live snapshot run advances past the interactive picker the store driver can't actuate. - e2e.json path now reflects the real flow (detect, integration run, handoff). Co-Authored-By: Claude Opus 4.8 --- scripts/tui-host.no-jest.ts | 24 ++++++++++++++++++ src/lib/programs/self-driving/test/e2e.json | 27 ++++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 3b210107..c10a6f6f 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -27,6 +27,10 @@ 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 { detectFramework } from '@lib/detection/index'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +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, @@ -249,6 +253,26 @@ 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 by detecting the + // framework at the install dir (single-app fixtures), mirroring the + // offline trace test, so the composed integrate-run can proceed. + if ( + state.currentScreen === ScreenId.SelfDrivingIntegrationDetect && + state.session.integration == null + ) { + const integration = await detectFramework(store.session.installDir); + if (integration) { + store.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, '.'); + store.setFrameworkConfig( + integration, + FRAMEWORK_REGISTRY[integration], + ); + } + continue; + } + let acted = false; try { const decision = decideE2eAction(state, profile); diff --git a/src/lib/programs/self-driving/test/e2e.json b/src/lib/programs/self-driving/test/e2e.json index 0d9e6a6b..180d25bf 100644 --- a/src/lib/programs/self-driving/test/e2e.json +++ b/src/lib/programs/self-driving/test/e2e.json @@ -1,6 +1,6 @@ { "program": "self-driving", - "summary": "Self-driving, integration-first: answer \"no\" to the integration check so the runner runs the real integration program as a prelude first (its own screens + task list — not captured in this self-driving control-plane trace), then the self-driving intro, run, and its own outro (no keep-skills — the outro exits).", + "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", @@ -12,18 +12,33 @@ }, "path": [ { - "screen": "self-driving-integration-check", - "auto": "answer \"no, set it up first\" → integrate=true. The runner then runs the integration program as a prelude (its own flow) before continuing. \"--integrate\" pre-answers this and skips the screen." + "screen": "self-driving-intro", + "auto": "confirm & continue" }, { - "screen": "self-driving-intro", - "auto": "confirm & continue — \"now we can set up Self-driving\" (shown after any integration prelude)" + "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": "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)" From b2cfd991aa2cbbca70d26cadc117495cad76d711 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Tue, 30 Jun 2026 14:31:05 -0400 Subject: [PATCH 5/8] e2e: monorepo-aware detect pick in tui-host The auto-pick detected the framework at the repo root, which a Turborepo root reports as generic node, so it would integrate the workspace root. Scan apps/ then packages/ for the first sub-app with a registered framework, falling back to the root for a single-app fixture. Co-Authored-By: Claude Opus 4.8 --- scripts/tui-host.no-jest.ts | 54 +++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index c10a6f6f..8e774739 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -27,8 +27,10 @@ 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'; @@ -41,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 ?? @@ -255,19 +290,22 @@ async function main() { const before = state.currentScreen; // Headless detect: the screen runs a real detector + an interactive - // pick the store driver can't actuate. Inject the pick by detecting the - // framework at the install dir (single-app fixtures), mirroring the - // offline trace test, so the composed integrate-run can proceed. + // 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 integration = await detectFramework(store.session.installDir); - if (integration) { - store.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, '.'); + const pick = await pickIntegrationTarget(store.session.installDir); + if (pick) { + store.setFrameworkContext( + SELF_DRIVING_INTEGRATE_PATH_KEY, + pick.path, + ); store.setFrameworkConfig( - integration, - FRAMEWORK_REGISTRY[integration], + pick.integration, + FRAMEWORK_REGISTRY[pick.integration], ); } continue; From 7ef9565d9c2f4b5b2c6e503f8bb9814fca4723ae Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Tue, 30 Jun 2026 15:31:39 -0400 Subject: [PATCH 6/8] e2e: keep wizard_ask live when an e2e driver answers; cancel self-driving's asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E snapshotting is driven, not CI — the host answers wizard_ask via its driver, so it sets WIZARD_ASK_AUTODRIVE and the runner keeps the ask bridge wired despite ci (which here is only for headless auth). Self-driving's profile declines every ask (ask: cancel) so the agent sets nothing up and walks to the outro. Co-Authored-By: Claude Opus 4.8 --- .../__snapshots__/e2e-flow-snapshot.test.ts.snap | 2 +- e2e-harness/e2e-profile.ts | 11 +++++++++-- scripts/tui-host.no-jest.ts | 6 ++++++ src/lib/agent/runner/linear.ts | 10 ++++++---- src/lib/programs/self-driving/test/e2e.json | 2 +- 5 files changed, 23 insertions(+), 8 deletions(-) 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 3fb015df..fdfafad9 100644 --- a/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap +++ b/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap @@ -146,7 +146,7 @@ exports[`e2e flow snapshot — self-driving > already-integrated (skips SDK setu exports[`e2e flow snapshot — self-driving > integration-first (no existing PostHog) walks a stable path 1`] = ` { "profile": { - "ask": "first", + "ask": "cancel", "healthCheck": "dismiss", "integrate": true, "mcp": "skip", diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index b7aed331..5a99e421 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -27,8 +27,12 @@ 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. */ - ask: 'first'; + /** + * Answer strategy for an agent `wizard_ask` overlay. `first` picks the first + * option; `cancel` declines every ask so the agent sets nothing up — used by + * flows whose questions would otherwise need real OAuth (self-driving). + */ + ask: 'first' | 'cancel'; /** * Self-driving integration-check answer: `true` → "no, set it up first" * (integrate the SDK as part of the run); `false` → "yes, already @@ -140,6 +144,9 @@ export function decideE2eAction( }; case Overlay.WizardAsk: { + // `cancel`: decline the ask so the agent sets nothing up and proceeds. + if (profile.ask === 'cancel') + return { action: { id: 'cancel_question' } }; const q = state.pendingQuestion?.questions[0]; if (!q) return { wait: true }; // 'first': first option for single/multi, sentinel for free text. diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 8e774739..6587d554 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -90,6 +90,12 @@ async function main() { (process.env.PROGRAM as ProgramId) || Program.PostHogIntegration; const programConfig = getProgramConfig(programId); + // 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!, 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 index 180d25bf..79cfadd9 100644 --- a/src/lib/programs/self-driving/test/e2e.json +++ b/src/lib/programs/self-driving/test/e2e.json @@ -7,7 +7,7 @@ "mcp": "skip", "slack": "skip", "skills": "delete", - "ask": "first", + "ask": "cancel", "integrate": true }, "path": [ From a57e9e448b95aa1d332dbad047aff13a2a72a05c Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 2 Jul 2026 08:57:31 -0400 Subject: [PATCH 7/8] e2e: answer self-driving's asks instead of cancelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling declined the required GitHub step and aborted. Answering with the first option gives each ask its affirmative 'continue' ("GitHub connected → done"), so with a scoped key the run completes to the success outro. Reverts the unused cancel strategy. Co-Authored-By: Claude Opus 4.8 --- .../__snapshots__/e2e-flow-snapshot.test.ts.snap | 2 +- e2e-harness/e2e-profile.ts | 12 +++--------- src/lib/programs/self-driving/test/e2e.json | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) 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 fdfafad9..3fb015df 100644 --- a/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap +++ b/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap @@ -146,7 +146,7 @@ exports[`e2e flow snapshot — self-driving > already-integrated (skips SDK setu exports[`e2e flow snapshot — self-driving > integration-first (no existing PostHog) walks a stable path 1`] = ` { "profile": { - "ask": "cancel", + "ask": "first", "healthCheck": "dismiss", "integrate": true, "mcp": "skip", diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 5a99e421..6c404ba1 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -27,12 +27,9 @@ export interface WizardE2eProfile { slack: 'skip'; /** Keep or delete the wizard-installed skills at the end. */ skills: 'keep' | 'delete'; - /** - * Answer strategy for an agent `wizard_ask` overlay. `first` picks the first - * option; `cancel` declines every ask so the agent sets nothing up — used by - * flows whose questions would otherwise need real OAuth (self-driving). - */ - ask: 'first' | 'cancel'; + /** 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 @@ -144,9 +141,6 @@ export function decideE2eAction( }; case Overlay.WizardAsk: { - // `cancel`: decline the ask so the agent sets nothing up and proceeds. - if (profile.ask === 'cancel') - return { action: { id: 'cancel_question' } }; const q = state.pendingQuestion?.questions[0]; if (!q) return { wait: true }; // 'first': first option for single/multi, sentinel for free text. diff --git a/src/lib/programs/self-driving/test/e2e.json b/src/lib/programs/self-driving/test/e2e.json index 79cfadd9..180d25bf 100644 --- a/src/lib/programs/self-driving/test/e2e.json +++ b/src/lib/programs/self-driving/test/e2e.json @@ -7,7 +7,7 @@ "mcp": "skip", "slack": "skip", "skills": "delete", - "ask": "cancel", + "ask": "first", "integrate": true }, "path": [ From 4d5d30223a838195bced5f9dc303cdac5bf917f8 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 2 Jul 2026 16:02:13 -0400 Subject: [PATCH 8/8] e2e: detect the posthog dep in monorepo sub-apps, not just the root The snapshot pass-check read only ${appDir}/package.json, but a monorepo installs the SDK into the picked sub-app (e.g. apps/expo), so hasPosthogDep false-negatived and the 'posthog dependency added' assertion failed even though the integration ran and the outro was reached. Scan the root plus apps/* and packages/* (mirrors pickIntegrationTarget). Co-Authored-By: Claude Opus 4.8 --- scripts/tui-host.no-jest.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 6587d554..9a3c4a02 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -344,16 +344,33 @@ async function main() { 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 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 = deps.filter((d) => d.includes('posthog')); + const posthogDeps = [ + ...new Set(deps.filter((d) => d.includes('posthog'))), + ]; let envFile: string | null = null; try { const hit = fs