Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/agent/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface OutroData {
kind: OutroKind;
/** Main headline (green check for Success, red X for Error, etc.) */
message?: string;
/** Error-only: the primary recovery instruction, above the supporting body. */
instruction?: string;
/** Free-form body text shown under the headline. Use \n for paragraph breaks. */
body?: string;
/** Success-only: bulleted list of "what the agent did" */
Expand All @@ -42,6 +44,8 @@ export interface OutroData {
*/
nextSteps?: { heading: string; items: string[] };
docsUrl?: string;
/** Optional label above an error's documentation link. */
docsLabel?: string;
continueUrl?: string;
/** Report file the agent wrote (e.g. "posthog-setup-report.md") */
reportFile?: string;
Expand Down
17 changes: 17 additions & 0 deletions src/lib/programs/__tests__/error-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ describe('error-tracking project picker report', () => {
});

describe('error-tracking ciPreRun', () => {
test('stops when no framework is detected', async () => {
vi.mocked(detectFramework).mockResolvedValue(undefined);
const session = {
installDir: '/tmp/error-tracking-ci',
frameworkContext: {},
} as unknown as WizardSession;

await errorTrackingConfig.ciPreRun?.(session);

expect(wizardAbort).toHaveBeenCalledWith(
expect.objectContaining({ code: ErrorCodes.DetectNoFramework }),
);
expect(session.integration).toBeUndefined();
expect(session.frameworkConfig).toBeUndefined();
expect(session.skillId).toBeUndefined();
});

test('stops KMP before it sets the framework', async () => {
vi.mocked(detectFramework).mockResolvedValue(Integration.kmp);
const session = {
Expand Down
169 changes: 169 additions & 0 deletions src/lib/programs/__tests__/no-framework.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { createElement } from 'react';
import { render, cleanup } from 'ink-testing-library';
import * as detection from '@lib/detection/index';
import { PostHogIntegrationIntroScreen } from '@ui/tui/screens/PostHogIntegrationIntroScreen';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { WizardStore, ScreenId } from '@ui/tui/store';
import { InkUI } from '@ui/tui/ink-ui';
import { setUI } from '@ui/index';
import { buildSession, OutroKind } from '@lib/wizard-session';
import { ErrorCodes } from '@shared/errors';
import { Integration } from '@shared/constants';
import { analytics } from '@utils/analytics';
import { getProgramConfig } from '@lib/programs/program-registry';

vi.mock('ink', () =>
vi.importActual('../../../../node_modules/ink/build/index.js'),
);

vi.mock('@lib/detection/project-scope', () => ({
scopeInstallDirToProject: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('@utils/analytics', () => ({
analytics: {
getAllFlagsForWizard: vi.fn().mockResolvedValue({}),
capture: vi.fn(),
wizardCapture: vi.fn(),
setTag: vi.fn(),
captureException: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
},
sessionProperties: vi.fn(() => ({})),
}));

describe.each([
{
program: 'posthog-integration' as const,
integration: Integration.javascriptNode,
dependencies: {},
message: 'Could not detect a framework',
modes: ['ci', 'run'] as const,
},
{
program: 'replay-vision' as const,
integration: Integration.nextjs,
dependencies: { next: '^15.0.0' },
message: "Replay vision couldn't detect a framework",
modes: ['interactive', 'ci'] as const,
},
])(
'$program framework detection',
({ program, integration, dependencies, message, modes }) => {
let installDir: string;
let store: WizardStore;
const exit = new Error('wizard exited');

beforeEach(() => {
vi.mocked(analytics.getAllFlagsForWizard).mockResolvedValue({});
installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'no-framework-'));
store = new WizardStore(program);
store.session = buildSession({ installDir });
const ui = new InkUI(store);
setUI(ui);
vi.spyOn(process, 'exit').mockImplementation(() => {
throw exit;
});
});

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

it.each(modes)(
'shows the no-framework error for an empty project in %s mode',
async (mode) => {
store.session = buildSession({ installDir, ci: mode === 'ci' });
const config = getProgramConfig(program);
const detection =
mode === 'run' && typeof config.run === 'function'
? config.run(store.session)
: mode === 'ci'
? config.ciPreRun?.(store.session)
: store.runReadyHooks();
if (!detection) throw new Error('expected a detection hook');
const finished = expect(detection).rejects.toBe(exit);
try {
await vi.waitFor(() =>
expect(store.session.outroData).not.toBeNull(),
);

expect(store.session.outroData).toEqual(
expect.objectContaining({
kind: OutroKind.Error,
errorCode: ErrorCodes.DetectNoFramework,
message: expect.stringContaining(message),
}),
);
expect(store.session.detectionComplete).toBe(false);
expect(store.router.resolve(store.session)).toBe(ScreenId.Outro);
expect(store.session.outroData?.instruction).toContain(
'an app root directory',
);
expect(process.exit).not.toHaveBeenCalled();
} finally {
store.setOutroDismissed();
await finished;
}
expect(process.exit).toHaveBeenCalledWith(1);
},
);

if (program === 'posthog-integration') {
it('lets users pick a supported framework after auto-detection misses their project', async () => {
fs.writeFileSync(
path.join(installDir, 'package.json'),
JSON.stringify({ dependencies: { next: '^15.0.0' } }),
);
vi.spyOn(detection, 'detectFramework').mockResolvedValueOnce(undefined);

await store.runReadyHooks();

expect(store.session.detectionComplete).toBe(true);
expect(store.session.frameworkConfig).toBeNull();
expect(store.session.outroData).toBeNull();
expect(store.router.resolve(store.session)).toBe(ScreenId.Intro);
const screen = render(
createElement(PostHogIntegrationIntroScreen, { store }),
);
await vi.waitFor(() =>
expect(screen.lastFrame()).toContain('Select your framework'),
);

screen.stdin.write('\r');

await vi.waitFor(() =>
expect(store.session.integration).toBe(Integration.nextjs),
);
await vi.waitFor(() =>
expect(screen.lastFrame()).toContain('Continue'),
);
const config = getProgramConfig(program);
if (typeof config.run !== 'function')
throw new Error('expected run hook');
const run = await config.run(store.session);
expect(run.integrationLabel).toBe(Integration.nextjs);
expect(store.session.outroData).toBeNull();
expect(process.exit).not.toHaveBeenCalled();
});
}

it('completes detection and selects the skill for a recognized project', async () => {
fs.writeFileSync(
path.join(installDir, 'package.json'),
JSON.stringify({ dependencies }),
);

await store.runReadyHooks();

expect(store.session.integration).toBe(integration);
expect(store.session.skillId).toBe(integration);
expect(store.session.detectionComplete).toBe(true);
expect(process.exit).not.toHaveBeenCalled();
});
},
);
6 changes: 2 additions & 4 deletions src/lib/programs/error-tracking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { preinstallPostHogCliOnce } from '@lib/programs/shared/posthog-cli-prein
import { analytics } from '@utils/analytics';
import { wizardAbort } from '@utils/wizard-abort';
import { ErrorCodes } from '@shared/errors';
import { abortNoFrameworkDetected } from '@lib/programs/shared/abort-no-framework';

const ERROR_TRACKING_REPORT_FILE = 'posthog-error-tracking-report.md';
const ERROR_TRACKING_DOCS_URL = 'https://posthog.com/docs/error-tracking';
Expand Down Expand Up @@ -184,10 +185,7 @@ export const errorTrackingConfig: ProgramConfig = {

const integration = await detectFramework(session.installDir);
if (!integration) {
await wizardAbort({
code: ErrorCodes.DetectNoFramework,
message: 'Could not auto-detect your framework for this project.',
});
await abortNoFrameworkDetected();
return;
}
if (ERROR_TRACKING_UNSUPPORTED.has(integration)) {
Expand Down
13 changes: 6 additions & 7 deletions src/lib/programs/posthog-integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import { analytics } from '@utils/analytics';
import { detectFramework, gatherFrameworkContext } from '@lib/detection/index';
import { scopeInstallDirToProject } from '@lib/detection/project-scope';
import { FRAMEWORK_REGISTRY } from '@lib/registry';
import { wizardAbort } from '@utils/wizard-abort';
import { ErrorCodes } from '@shared/errors';
import { abortNoFrameworkDetected } from '@lib/programs/shared/abort-no-framework';
import {
WIZARD_DEFAULT_AIO_LOGS_FLAG_KEY,
WIZARD_INTERACTION_EVENT_NAME,
Expand Down Expand Up @@ -264,10 +263,7 @@ export const posthogIntegrationConfig: ProgramConfig = {

const integration = await detectFramework(session.installDir);
if (!integration) {
await wizardAbort({
code: ErrorCodes.DetectNoFramework,
message: 'Could not auto-detect your framework for this project.',
});
await abortNoFrameworkDetected();
return;
}
session.integration = integration;
Expand All @@ -292,7 +288,10 @@ export const posthogIntegrationConfig: ProgramConfig = {
},

run: async (session: WizardSession): Promise<ProgramRun> => {
const config = session.frameworkConfig!;
const config = session.frameworkConfig;
if (!config) {
return abortNoFrameworkDetected();
}

const typeScriptDetected = isUsingTypeScript({
installDir: session.installDir,
Expand Down
19 changes: 14 additions & 5 deletions src/lib/programs/replay-vision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { WizardSession } from '@lib/wizard-session';
import { analytics } from '@utils/analytics';
import { wizardAbort } from '@utils/wizard-abort';
import { ErrorCodes } from '@shared/errors';
import { abortNoFrameworkDetected } from '@lib/programs/shared/abort-no-framework';

const REPLAY_VISION_REPORT_FILE = 'posthog-replay-vision-report.md';

Expand Down Expand Up @@ -74,6 +75,12 @@ async function abortUnsupportedPlatform(
});
}

const NO_FRAMEWORK_GUIDANCE = {
message: "Replay vision couldn't detect a framework",
docsLabel: 'Supported replay platforms:',
docsUrl: 'https://posthog.com/docs/session-replay',
};

/**
* `[ABORT]` reasons the replay-vision skill emits when the run can't proceed.
* Kept in sync with the stop conditions in the skill's `description.md`
Expand Down Expand Up @@ -108,7 +115,12 @@ const DETECT_STEP: ProgramStep = {
// be the stale pre-copy object (see the warning in detect.ts).
onReady: async (ctx: ProgramReadyContext) => {
const integration = await detectFramework(ctx.session.installDir);
if (integration && !REPLAY_VISION_SUPPORTED.has(integration)) {
if (!integration) {
// Stop before skill preflight, matching the CI path below.
await abortNoFrameworkDetected(NO_FRAMEWORK_GUIDANCE);
return;
}
if (!REPLAY_VISION_SUPPORTED.has(integration)) {
await abortUnsupportedPlatform(integration);
return;
}
Expand Down Expand Up @@ -173,10 +185,7 @@ export const replayVisionConfig: ProgramConfig = {

const integration = await detectFramework(session.installDir);
if (!integration) {
await wizardAbort({
code: ErrorCodes.DetectNoFramework,
message: 'Could not auto-detect your framework for this project.',
});
await abortNoFrameworkDetected(NO_FRAMEWORK_GUIDANCE);
return;
}
if (!REPLAY_VISION_SUPPORTED.has(integration)) {
Expand Down
22 changes: 22 additions & 0 deletions src/lib/programs/shared/abort-no-framework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ErrorCodes } from '@shared/errors';
import { wizardAbort } from '@utils/wizard-abort';
import { OutroKind, type OutroData } from '@lib/wizard-session';

export function abortNoFrameworkDetected(
overrides: Pick<OutroData, 'message' | 'docsLabel' | 'docsUrl'> = {},
): Promise<never> {
const message = overrides.message ?? 'Could not detect a framework';
return wizardAbort({
code: ErrorCodes.DetectNoFramework,
message,
outroData: {
kind: OutroKind.Error,
message,
instruction: 'Run the wizard from an app root directory.',
body: "That's the folder containing package.json or an equivalent project file.",
docsLabel: 'Supported frameworks and manual setup:',
docsUrl: 'https://posthog.com/docs/libraries',
...overrides,
},
});
}
4 changes: 3 additions & 1 deletion src/ui/logging-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ export class LoggingUI implements WizardUI {

outroError(data: OutroData): void {
console.log(`✖ ${data.message ?? 'Wizard aborted'}`);
if (data.instruction) console.log(`│ ${data.instruction}`);
if (data.body) console.log(`│ ${data.body}`);
if (data.docsUrl) console.log(`│ Docs: ${data.docsUrl}`);
if (data.docsUrl)
console.log(`│ ${data.docsLabel ?? 'Docs:'} ${data.docsUrl}`);
}

waitForOutroDismissed(): Promise<void> {
Expand Down
17 changes: 17 additions & 0 deletions src/ui/tui/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ function failedRunSession() {
}

describe('WizardRouter', () => {
it.each(PROGRAM_REGISTRY.map((program) => program.id))(
'shows and dismisses an early error before setup completes in %s',
(program) => {
const router = new WizardRouter(program);
const session = baseWizardSession();
session.runPhase = RunPhase.Error;
session.outroData = {
kind: OutroKind.Error,
message: 'detection failed',
};

expect(router.resolve(session)).toBe(ScreenId.Outro);
session.outroDismissed = true;
expect(router.resolve(session)).toBe(ScreenId.Exit);
},
);

it.each(PROGRAM_REGISTRY.map((program) => program.id))(
'shows a failed run over every step and overlay in %s',
(program) => {
Expand Down
Loading
Loading