Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions src/__tests__/programs-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
const { mockRunWizard, mockRunWizardCI } = vi.hoisted(() => ({
mockRunWizard: vi.fn(),
mockRunWizardCI: vi.fn(),
}));
const { mockRunWizard, mockRunWizardCI, mockRunWizardHeadless } = vi.hoisted(
() => ({
mockRunWizard: vi.fn(),
mockRunWizardCI: vi.fn(),
mockRunWizardHeadless: vi.fn(),
}),
);

vi.mock('@lib/runners', () => ({
runWizard: mockRunWizard,
runWizardCI: mockRunWizardCI,
runWizardHeadless: mockRunWizardHeadless,
}));

vi.mock('@lib/wizard-tools', async (importOriginal) => {
Expand Down Expand Up @@ -163,7 +167,7 @@
});

test('migrate dispatches with migrate-statsig skillId', () => {
migrateCommand.handler!(makeArgv({ installDir: '/tmp/some-app' }));

Check warning on line 170 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
const [config, opts] = mockRunWizard.mock.calls[0] as [
{ skillId?: string },
Record<string, unknown>,
Expand All @@ -173,13 +177,13 @@
});

test('revenue-analytics dispatches with revenue-analytics-setup skillId', () => {
revenueCommand.handler!(makeArgv({ debug: true }));

Check warning on line 180 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }];
expect(config.skillId).toBe('revenue-analytics-setup');
});

test('mcp-analytics dispatches with mcp-analytics skillId', () => {
mcpAnalyticsCommand.handler!(makeArgv({ debug: true }));

Check warning on line 186 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }];
expect(config.skillId).toBe('mcp-analytics');
});
Expand Down Expand Up @@ -226,6 +230,41 @@
expect(canonical.debug).toBe(true);
expect(legacy.debug).toBe(true);
});

test('parses the upload-source-maps headless selection flags', async () => {
const argv = await parseCommand(
uploadSourcemapsCommand,
'upload-source-maps --headless-DONOTUSE-EXPERIMENTAL --selected-path apps/web --selected-variant nextjs',
);
expect(argv['headless-DONOTUSE-EXPERIMENTAL']).toBe(true);
expect(argv.selectedPath).toBe('apps/web');
expect(argv.selectedVariant).toBe('nextjs');
});

test('routes headless upload-source-maps runs to runWizardHeadless', () => {
// This describe has no mock-clearing hook; earlier dispatch tests leave
// runner-mock calls behind, so start from a clean slate.
vi.clearAllMocks();
uploadSourcemapsCommand.handler?.(
makeArgv({
'headless-DONOTUSE-EXPERIMENTAL': true,
selectedPath: '.',
selectedVariant: 'nextjs',
}),
);
expect(mockRunWizardHeadless).toHaveBeenCalledTimes(1);
expect(mockRunWizard).not.toHaveBeenCalled();
expect(mockRunWizardCI).not.toHaveBeenCalled();
const [config, opts] = mockRunWizardHeadless.mock.calls[0] as [
{ id?: string },
Record<string, unknown>,
];
expect(config.id).toBe('error-tracking-upload-source-maps');
expect(opts).toMatchObject({
selectedPath: '.',
selectedVariant: 'nextjs',
});
});
});

describe('pickerChildrenToShow (today: picker shows only the default leaf)', () => {
Expand Down
22 changes: 20 additions & 2 deletions src/commands/upload-sourcemaps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { runWizard, runWizardCI } from '@lib/runners';
import { runWizard, runWizardCI, runWizardHeadless } from '@lib/runners';
import { errorTrackingUploadSourceMapsConfig } from '@lib/programs/error-tracking-upload-source-maps/index';
import { runDetectOnly } from '@lib/programs/error-tracking-upload-source-maps/detect-only';
import { regionOption } from '@lib/headless-mode';
import { headlessOption, isHeadless, regionOption } from '@lib/headless-mode';
import { runCommandHandler } from './factories/shared';
import { skillProgramOptions } from './skill-program-options';
import type { Command } from './command';
Expand Down Expand Up @@ -29,6 +29,22 @@ export const uploadSourcemapsCommand: Command = {
type: 'string' as const,
hidden: true,
},
// Project selection for non-interactive runs, passed verbatim from a
// stored detection report row (the project the user picked in the
// PostHog app). Hidden like the headless flag: the contract is unstable.
'selected-path': {
describe:
"Project directory to instrument, relative to the repo root ('.' for the root). Non-interactive runs only.",
type: 'string' as const,
hidden: true,
},
'selected-variant': {
describe:
'Source-maps skill variant of the selected project (e.g. nextjs). Non-interactive runs only.',
type: 'string' as const,
hidden: true,
},
...headlessOption,
...regionOption,
},
handler: (argv) => {
Expand All @@ -39,6 +55,8 @@ export const uploadSourcemapsCommand: Command = {
const options = { ...argv, ...extras };
if (options.detectOnly) {
runCommandHandler(() => runDetectOnly(options));
} else if (isHeadless(options)) {
runWizardHeadless(errorTrackingUploadSourceMapsConfig, options);
} else if (options.ci) {
runWizardCI(errorTrackingUploadSourceMapsConfig, options);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockWizardAbort } = vi.hoisted(() => ({
mockWizardAbort: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('@utils/wizard-abort', async (importOriginal) => {
const actual = await importOriginal<typeof import('@utils/wizard-abort')>();
return { ...actual, wizardAbort: mockWizardAbort };
});

import { getUI, setUI } from '@ui';
import { LoggingUI } from '@ui/logging-ui';
import { buildSession, type WizardSession } from '@lib/wizard-session';
import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/detect';
import { seedNonInteractiveSelection } from '@lib/programs/error-tracking-upload-source-maps/non-interactive-selection';

describe('seedNonInteractiveSelection', () => {
let installDir: string;
let session: WizardSession;

beforeEach(() => {
installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sourcemaps-select-'));
fs.mkdirSync(path.join(installDir, 'apps', 'web'), { recursive: true });
session = buildSession({ installDir, ci: true });
setUI(new LoggingUI());
mockWizardAbort.mockClear();
});

afterEach(() => {
fs.rmSync(installDir, { recursive: true, force: true });
});

it.each([
{ selectedPath: '.', label: 'the repo root' },
{ selectedPath: 'apps/web', label: 'a monorepo subproject' },
])('seeds the selection for $label', async ({ selectedPath }) => {
await seedNonInteractiveSelection(session, {
selectedPath,
selectedVariant: 'nextjs',
});

expect(mockWizardAbort).not.toHaveBeenCalled();
const ui = getUI();
expect(
ui.getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedVariant),
).toBe('nextjs');
expect(ui.getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath)).toBe(
selectedPath,
);
expect(
ui.getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName),
).toBe('Next.js');
});

it.each([
{
name: 'both flags missing',
options: {},
messagePart: '--selected-path',
},
{
name: 'variant missing',
options: { selectedPath: '.' },
messagePart: '--selected-variant',
},
{
name: 'unknown variant',
options: { selectedPath: '.', selectedVariant: 'cobol' },
messagePart: 'Unknown --selected-variant "cobol"',
},
{
name: 'path not in the repository',
options: { selectedPath: 'apps/missing', selectedVariant: 'nextjs' },
messagePart: 'run a new scan',
},
{
name: 'path escaping the install dir',
options: { selectedPath: '../outside', selectedVariant: 'nextjs' },
messagePart: 'outside the install directory',
},
])('aborts on $name without seeding', async ({ options, messagePart }) => {
await seedNonInteractiveSelection(session, options);

expect(mockWizardAbort).toHaveBeenCalledTimes(1);
expect(mockWizardAbort.mock.calls[0][0].message).toContain(messagePart);
expect(
getUI().getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedVariant),
).toBeUndefined();
});
});
60 changes: 60 additions & 0 deletions src/lib/programs/__tests__/source-maps-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,63 @@ describe('buildSourceMapsUploadPrompt rust workspace scope', () => {
expect(prompt).not.toContain('Cargo workspace exception');
});
});

describe('buildSourceMapsUploadPrompt non-interactive mode', () => {
const prompt = buildSourceMapsUploadPrompt({
...baseParams,
nonInteractive: true,
});

it('never references the ask tool or the API-key prompt', () => {
expect(prompt).not.toContain('wizard_ask');
expect(prompt).not.toContain('secretRef');
expect(prompt).not.toContain('Paste your PostHog personal API key');
});

it('drops the local-test offer', () => {
expect(prompt).not.toContain('Test the local setup');
expect(prompt).not.toContain('Want me to help you test');
});

it('forbids real env files and env tools, allowing only committed examples', () => {
expect(prompt).toContain('NEVER read, create, or modify real env files');
expect(prompt).toContain('never call check_env_keys or set_env_values');
expect(prompt).toContain('committed env example file');
expect(prompt).not.toContain('Then call set_env_values');
});

it('routes dependency changes through the package manager', () => {
// A package.json edit without its lockfile fails npm ci in the PR.
expect(prompt).toContain(
'Dependency changes go through the package manager',
);
expect(prompt).toContain('lockfile');
});

it('hands the API key off as a documented follow-up', () => {
expect(prompt).toContain('you\ncannot obtain one');
expect(prompt).toContain('"What you still need to do"');
expect(prompt).toContain(baseParams.settingsUrl);
});

it('keeps the monorepo scope rules', () => {
const monorepoPrompt = buildSourceMapsUploadPrompt({
...baseParams,
projectPath: 'backend',
nonInteractive: true,
});

expect(monorepoPrompt).toContain('scope your work to `backend`');
expect(monorepoPrompt).toContain(
"Project directory (relative to the wizard's working directory): backend",
);
});

it('leaves the interactive prompt untouched', () => {
const interactive = buildSourceMapsUploadPrompt(baseParams);

expect(interactive).toContain('wizard_ask');
expect(interactive).toContain('Test the local setup');
expect(interactive).toContain('secretRef');
});
});
11 changes: 10 additions & 1 deletion src/lib/programs/error-tracking-upload-source-maps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type SkillVariant,
} from './detect.js';
import { getContentBlocks } from './content/index.js';
import { seedNonInteractiveSelection } from './non-interactive-selection.js';
import { getUI } from '@ui';
import { installOrUpdatePostHogCli } from '@steps/install-cli-steering';
import { analytics } from '@utils/analytics';
Expand Down Expand Up @@ -59,8 +60,15 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = {
reportFile: REPORT_FILE,
getContentBlocks,
requires: ['posthog-integration'],
// Non-interactive runs have no detect+pick screen; the selection arrives as
// CLI flags and is seeded into framework context before the agent runs.
ciPreRun: seedNonInteractiveSelection,

run: (session: WizardSession): Promise<ProgramRun> => {
// `ci` is set once by buildSession and never changes, so it is safe to
// read here even though the store forks the session reference.
const nonInteractive = session.ci === true;

run: (_session: WizardSession): Promise<ProgramRun> => {
// Read the picked project LIVE at prompt-build time, not here: the picker
// screen runs AFTER this run config is resolved (post-auth), and the store
// forks the session reference, so the `session` passed in never sees the
Expand Down Expand Up @@ -121,6 +129,7 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = {
settingsUrl: `${uiHost}/project/${ctx.projectId}/settings/user-api-keys`,
uiHost,
reportFile: REPORT_FILE,
nonInteractive,
});
},

Expand Down
Loading
Loading