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..58553e2b 100644 --- a/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap +++ b/e2e-harness/__tests__/__snapshots__/e2e-flow-snapshot.test.ts.snap @@ -198,3 +198,46 @@ exports[`e2e flow snapshot — self-driving > integration-first (no existing Pos ], } `; + +exports[`e2e flow snapshot — upload-source-maps > walks a stable path 1`] = ` +{ + "profile": { + "ask": "first", + "healthCheck": "dismiss", + "mcp": "skip", + "setup": "first", + "skills": "delete", + "slack": "skip", + }, + "program": "error-tracking-upload-source-maps", + "trace": [ + { + "action": "confirm_setup", + "screen": "source-maps-intro", + }, + { + "action": "(external)", + "screen": "auth", + }, + { + "action": "(external)", + "screen": "source-maps-detect", + }, + { + "action": "(external)", + "screen": "run", + }, + { + "action": "dismiss_outro", + "screen": "source-maps-outro", + }, + { + "action": "keep_skills", + "params": { + "kept": false, + }, + "screen": "keep-skills", + }, + ], +} +`; diff --git a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts index 537bb7f9..94ad8090 100644 --- a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts +++ b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts @@ -100,6 +100,13 @@ function traceFlow( Integration.javascriptNode, FRAMEWORK_REGISTRY[Integration.javascriptNode], ); + } else if (screen === ScreenId.SourceMapsDetect) { + // The detect screen runs an agentic scan + an interactive pick; commit + // the pick through the driver the way the e2e host injection does. + driver.performAction('pick_source_maps_project', { + variant: 'node', + path: '.', + }); } else if (screen === ScreenId.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 @@ -170,3 +177,15 @@ describe('e2e flow snapshot — self-driving', () => { }).toMatchSnapshot(); }); }); + +describe('e2e flow snapshot — upload-source-maps', () => { + const profile = profileFor(Program.ErrorTrackingUploadSourceMaps); + + it('walks a stable path', () => { + expect({ + program: 'error-tracking-upload-source-maps', + profile, + trace: traceFlow(Program.ErrorTrackingUploadSourceMaps, profile), + }).toMatchSnapshot(); + }); +}); diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index d55fc416..9e0c86a7 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -20,6 +20,7 @@ import { ScreenId, Overlay } from '@ui/tui/router'; import { Program } from '@lib/programs/program-registry'; import { WizardCiDriver, UnknownActionError } from '../wizard-ci-driver'; import { ACTION_REGISTRY, NO_ACTION_SCREENS } from '../action-registry'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/index'; function freshStore(): WizardStore { const store = new WizardStore(Program.PostHogIntegration); @@ -212,6 +213,58 @@ describe('WizardCiDriver — self-driving integration check', () => { }); }); +describe('WizardCiDriver — source-maps project pick', () => { + function sourceMapsStore(): WizardStore { + const store = new WizardStore(Program.ErrorTrackingUploadSourceMaps); + setUI(new InkUI(store)); + store.session = buildSession({ installDir: '/tmp/ci-driver-sm', ci: true }); + return store; + } + + function toDetectScreen(store: WizardStore): void { + // Intro → auth → detect. + store.completeSetup(); + store.setCredentials({ + accessToken: 'phx_x', + projectApiKey: 'phc_x', + host: 'https://us.posthog.com', + projectId: 1, + }); + } + + it('commits the pick the way the detect screen would and advances', () => { + const store = sourceMapsStore(); + const driver = new WizardCiDriver(store); + + toDetectScreen(store); + const state = driver.readState(); + expect(state.currentScreen).toBe(ScreenId.SourceMapsDetect); + expect(state.actions.map((a) => a.id)).toContain( + 'pick_source_maps_project', + ); + + const next = driver.performAction('pick_source_maps_project', { + variant: 'node', + path: '.', + }); + const ctx = store.session.frameworkContext; + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedVariant]).toBe('node'); + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName]).toBe('Node.js'); + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedPath]).toBe('.'); + expect(next.currentScreen).toBe(ScreenId.Run); + }); + + it('requires the variant and path params', () => { + const store = sourceMapsStore(); + const driver = new WizardCiDriver(store); + + toDetectScreen(store); + expect(() => + driver.performAction('pick_source_maps_project', { variant: 'node' }), + ).toThrow('requires param "path"'); + }); +}); + 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 3ee7ab9f..a7a99580 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -16,6 +16,10 @@ import type { WizardStore } from '@ui/tui/store'; import { ScreenId, Overlay, type ScreenName } from '@ui/tui/router'; import { McpOutcome } from '@lib/wizard-session'; import type { AskAnswers } from '@lib/wizard-session'; +import { + SOURCE_MAPS_CONTEXT_KEYS, + VARIANT_DISPLAY_NAME, +} from '@lib/programs/error-tracking-upload-source-maps/index'; /** One commit action legal on a given screen. */ export interface DriverAction { @@ -60,8 +64,7 @@ function requireString( * (agent sets runPhase), ai-opt-in (org approval / ci auto-consent), exit, * and the no-dismiss terminal overlays. * - screens of programs the integration e2e profile never enters (audit, - * doctor, source-maps). source-maps-detect is interactive, so wire it into - * ACTION_REGISTRY when a source-maps profile starts driving that program. + * doctor). */ export const NO_ACTION_SCREENS: ReadonlySet = new Set([ ScreenId.Auth, @@ -72,8 +75,6 @@ export const NO_ACTION_SCREENS: ReadonlySet = new Set([ ScreenId.DoctorReport, // The detector + picker are interactive; no headless e2e drives this screen. ScreenId.SelfDrivingIntegrationDetect, - ScreenId.SourceMapsDetect, - ScreenId.SourceMapsOutro, ScreenId.AuditOutro, Overlay.ManagedSettings, Overlay.AuthError, @@ -125,6 +126,45 @@ export const ACTION_REGISTRY: Partial> = { }, ], + // ── Source-maps project pick + outro ─────────────────────────────────── + [ScreenId.SourceMapsDetect]: [ + { + id: 'pick_source_maps_project', + description: + 'Commit the project to wire source-map upload for, as the detect ' + + "screen's picker would. The candidate list lives in the screen's " + + 'agentic report, so the caller supplies the pick.', + params: { + variant: 'skill variant (e.g. "node", "nextjs")', + path: 'project path relative to the repo root ("." = root)', + }, + apply: (store, params) => { + const variant = requireString( + 'pick_source_maps_project', + params, + 'variant', + ); + const path = requireString('pick_source_maps_project', params, 'path'); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, + variant, + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName, + (VARIANT_DISPLAY_NAME as Record)[variant] ?? variant, + ); + store.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath, path); + }, + }, + ], + [ScreenId.SourceMapsOutro]: [ + { + id: 'dismiss_outro', + description: 'Dismiss the source-maps outro (sets outroDismissed).', + apply: (store) => store.setOutroDismissed(), + }, + ], + // ── Health check — dismiss a blocking outage ────────────────────────── [ScreenId.HealthCheck]: [ { diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 6c404ba1..bcf7a16e 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -112,6 +112,7 @@ export function decideE2eAction( return { action: { id: 'confirm_self_driving_handoff' } }; case ScreenId.Outro: + case ScreenId.SourceMapsOutro: return { action: { id: 'dismiss_outro' } }; case ScreenId.Mcp: @@ -166,6 +167,7 @@ export const E2E_DRIVABLE_SCREENS: readonly ScreenName[] = [ ScreenId.Setup, ScreenId.SelfDrivingIntegrationCheck, ScreenId.Outro, + ScreenId.SourceMapsOutro, ScreenId.Mcp, ScreenId.McpSuggestedPrompts, ScreenId.SlackConnect, diff --git a/e2e-harness/profiles.ts b/e2e-harness/profiles.ts index 08fad0d2..939c5b5b 100644 --- a/e2e-harness/profiles.ts +++ b/e2e-harness/profiles.ts @@ -12,11 +12,14 @@ 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'; +import sourceMapsE2e from '@lib/programs/error-tracking-upload-source-maps/test/e2e.json'; const PROFILES: Partial> = { [Program.PostHogIntegration]: posthogIntegrationE2e.profile as WizardE2eProfile, [Program.SelfDriving]: selfDrivingE2e.profile as WizardE2eProfile, + [Program.ErrorTrackingUploadSourceMaps]: + sourceMapsE2e.profile as WizardE2eProfile, }; /** The e2e profile for a program, or the happy-path default if none is set. */ diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 6587d554..40aca23c 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -32,7 +32,11 @@ 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 { + detectSourceMapsPrerequisites, + SOURCE_MAPS_CONTEXT_KEYS, +} from '@lib/programs/error-tracking-upload-source-maps/index'; +import { ScreenId, Overlay } from '@ui/tui/router'; import { WizardCiDriver } from '@e2e-harness/wizard-ci-driver'; import { decideE2eAction, @@ -253,6 +257,18 @@ async function main() { async function fixed() { const CTRL = process.env.SNAP_CTRL!; const profile: WizardE2eProfile = profileFor(programId); + // Program-specific wizard_ask answers the generic 'first' strategy can't + // give. Source-maps STEP 1 wants a real upload key — the driver supplies + // the raw value and the wizard_ask tool vaults it, so the agent only ever + // sees a secretRef — and STEP 8's "test it now?" must be declined: nobody + // is at the keyboard to run a build. An unset env answer falls through to + // the generic strategy (the 'e2e' sentinel). + const askOverrides: Record> = { + [Program.ErrorTrackingUploadSourceMaps]: { + 'api-key': process.env.SOURCE_MAPS_CLI_KEY, + 'test-affordance': 'no', + }, + }; 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 @@ -317,6 +333,48 @@ async function main() { continue; } + // Headless source-maps detect: the screen's candidate list lives in + // its own agentic report (React state), so compute the pick here with + // the static prerequisite detector — right for a single-app fixture — + // and commit it through the driver the way the picker would. + if ( + state.currentScreen === ScreenId.SourceMapsDetect && + store.session.frameworkContext[ + SOURCE_MAPS_CONTEXT_KEYS.selectedVariant + ] == null + ) { + const ctx: Record = {}; + detectSourceMapsPrerequisites(store.session, (k, v) => { + ctx[k] = v; + }); + const variant = ctx[SOURCE_MAPS_CONTEXT_KEYS.skillVariant]; + if (typeof variant !== 'string') { + mark( + 'source-maps detect found nothing to instrument: ' + + JSON.stringify(ctx[SOURCE_MAPS_CONTEXT_KEYS.detectError]), + ); + process.exit(1); + } + driver.performAction('pick_source_maps_project', { + variant, + path: '.', + }); + continue; + } + + // Program-specific ask answers take precedence over the generic + // profile strategy. + if (state.currentScreen === Overlay.WizardAsk) { + const q = state.pendingQuestion?.questions[0]; + const override = q ? askOverrides[programId]?.[q.id] : undefined; + if (q && override !== undefined) { + driver.performAction('answer_question', { + answers: { [q.id]: override }, + }); + continue; + } + } + let acted = false; try { const decision = decideE2eAction(state, profile); diff --git a/src/lib/programs/error-tracking-upload-source-maps/test/e2e.json b/src/lib/programs/error-tracking-upload-source-maps/test/e2e.json new file mode 100644 index 00000000..711fca5f --- /dev/null +++ b/src/lib/programs/error-tracking-upload-source-maps/test/e2e.json @@ -0,0 +1,38 @@ +{ + "program": "error-tracking-upload-source-maps", + "summary": "Upload source maps: intro → auth → the detect agent scans the repo and the host injects the first instrumentable project as the pick → the run agent gets the upload key via wizard_ask (id \"api-key\"; the driver supplies the raw key and the ask tool vaults it, so the agent only sees a secretRef), wires build config + CI, declines the test offer → outro → keep-skills.", + "profile": { + "setup": "first", + "healthCheck": "dismiss", + "mcp": "skip", + "slack": "skip", + "skills": "delete", + "ask": "first" + }, + "path": [ + { + "screen": "source-maps-intro", + "auto": "confirm & continue" + }, + { + "screen": "auth", + "auto": "(external) — the runner resolves credentials from the phx key" + }, + { + "screen": "source-maps-detect", + "auto": "(external) — the host computes the pick with the static prerequisite detector and commits it via pick_source_maps_project (selectedVariant/DisplayName/Path); the runner's post-auth gate then releases the run" + }, + { + "screen": "run", + "auto": "(external) — the upload agent; the host answers wizard_ask \"api-key\" with SOURCE_MAPS_CLI_KEY and \"test-affordance\" with \"no\"" + }, + { + "screen": "source-maps-outro", + "auto": "dismiss" + }, + { + "screen": "keep-skills", + "auto": "delete the installed skill" + } + ] +}