diff --git a/bin.ts b/bin.ts index a61e4cc..803fdf0 100644 --- a/bin.ts +++ b/bin.ts @@ -504,6 +504,49 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'You must specify a subcommand (add or remove)') .help(); }) + .command( + 'setup-revenue-analytics', + 'Set up Stripe revenue analytics with PostHog', + (yargs) => { + return yargs.options({ + 'install-dir': { + describe: + 'Directory of the project to set up\nenv: POSTHOG_WIZARD_INSTALL_DIR', + type: 'string', + }, + }); + }, + (argv) => { + const options = { ...argv }; + + void (async () => { + // Always use LoggingUI for this subcommand (simple linear flow) + setUI(new LoggingUI()); + + const { readApiKeyFromEnv } = await import( + './src/utils/env-api-key.js' + ); + const { buildSession } = await import('./src/lib/wizard-session.js'); + const { runSetupRevenueAnalytics } = await import( + './src/setup-revenue-analytics/index.js' + ); + + const apiKey = + (options.apiKey as string | undefined) || readApiKeyFromEnv(); + + const session = buildSession({ + debug: options.debug as boolean | undefined, + installDir: options.installDir as string | undefined, + ci: options.ci as boolean | undefined, + localMcp: options.localMcp as boolean | undefined, + apiKey, + projectId: options.projectId as string | undefined, + }); + + await runSetupRevenueAnalytics(session); + })(); + }, + ) .help() .alias('help', 'h') .version() diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 1dca0cc..860b880 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -1188,7 +1188,6 @@ function handleSDKMessage( const statusMatch = block.text.match(statusRegex); if (statusMatch) { const statusText = statusMatch[1].trim(); - getUI().pushStatus(statusText); spinner.message(statusText); } } diff --git a/src/setup-revenue-analytics/__tests__/language-detection.test.ts b/src/setup-revenue-analytics/__tests__/language-detection.test.ts new file mode 100644 index 0000000..c770fbd --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/language-detection.test.ts @@ -0,0 +1,151 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { + detectLanguageFromFiles, + languageFromIntegration, +} from '../language-detection'; +import { Integration } from '../../lib/constants'; + +describe('languageFromIntegration', () => { + const cases: [Integration, string | null][] = [ + [Integration.nextjs, 'node'], + [Integration.nuxt, 'node'], + [Integration.vue, 'node'], + [Integration.reactRouter, 'node'], + [Integration.tanstackStart, 'node'], + [Integration.tanstackRouter, 'node'], + [Integration.reactNative, 'node'], + [Integration.angular, 'node'], + [Integration.astro, 'node'], + [Integration.sveltekit, 'node'], + [Integration.javascript_web, 'node'], + [Integration.javascriptNode, 'node'], + [Integration.django, 'python'], + [Integration.flask, 'python'], + [Integration.fastapi, 'python'], + [Integration.python, 'python'], + [Integration.laravel, 'php'], + [Integration.rails, 'ruby'], + [Integration.ruby, 'ruby'], + [Integration.swift, null], + [Integration.android, null], + ]; + + test.each(cases)('%s → %s', (integration, expected) => { + expect(languageFromIntegration(integration)).toBe(expected); + }); +}); + +describe('detectLanguageFromFiles', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-lang-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('detects node from package.json', async () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); + }); + + test('detects python from requirements.txt', async () => { + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe==5.0.0'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('python'); + }); + + test('detects python from pyproject.toml', async () => { + fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[project]'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('python'); + }); + + test('detects ruby from Gemfile', async () => { + fs.writeFileSync( + path.join(tmpDir, 'Gemfile'), + "source 'https://rubygems.org'", + ); + expect(await detectLanguageFromFiles(tmpDir)).toBe('ruby'); + }); + + test('detects php from composer.json', async () => { + fs.writeFileSync(path.join(tmpDir, 'composer.json'), '{}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('php'); + }); + + test('detects go from go.mod', async () => { + fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module myapp'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('go'); + }); + + test('detects java from build.gradle', async () => { + fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'plugins {}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('java'); + }); + + test('detects java from pom.xml', async () => { + fs.writeFileSync(path.join(tmpDir, 'pom.xml'), ''); + expect(await detectLanguageFromFiles(tmpDir)).toBe('java'); + }); + + test('detects dotnet from .csproj', async () => { + fs.writeFileSync(path.join(tmpDir, 'MyApp.csproj'), ''); + expect(await detectLanguageFromFiles(tmpDir)).toBe('dotnet'); + }); + + test('detects dotnet from .sln', async () => { + fs.writeFileSync( + path.join(tmpDir, 'MyApp.sln'), + 'Microsoft Visual Studio Solution', + ); + expect(await detectLanguageFromFiles(tmpDir)).toBe('dotnet'); + }); + + test('returns null for empty directory', async () => { + expect(await detectLanguageFromFiles(tmpDir)).toBeNull(); + }); + + test('returns first match when multiple indicators present', async () => { + // node (package.json) comes before python in indicator order + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'stripe'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); + }); + + describe('monorepo support', () => { + test('detects node from subdirectory package.json', async () => { + fs.mkdirSync(path.join(tmpDir, 'backend'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'backend', 'package.json'), '{}'); + expect(await detectLanguageFromFiles(tmpDir)).toBe('node'); + }); + + test('detects python from subdirectory requirements.txt', async () => { + fs.mkdirSync(path.join(tmpDir, 'server'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'server', 'requirements.txt'), + 'django', + ); + expect(await detectLanguageFromFiles(tmpDir)).toBe('python'); + }); + + test('detects ruby from nested Gemfile', async () => { + fs.mkdirSync(path.join(tmpDir, 'api'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'api', 'Gemfile'), "gem 'rails'"); + expect(await detectLanguageFromFiles(tmpDir)).toBe('ruby'); + }); + + test('ignores node_modules subdirectories', async () => { + fs.mkdirSync(path.join(tmpDir, 'node_modules', 'stripe'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpDir, 'node_modules', 'stripe', 'package.json'), + '{}', + ); + expect(await detectLanguageFromFiles(tmpDir)).toBeNull(); + }); + }); +}); diff --git a/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts new file mode 100644 index 0000000..c983d8e --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/posthog-detection.test.ts @@ -0,0 +1,400 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { detectPostHogDistinctId } from '../posthog-detection'; + +function createFixture(files: Record): { + dir: string; + cleanup: () => void; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-posthog-')); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(dir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +describe('detectPostHogDistinctId', () => { + describe('Node.js / JavaScript', () => { + test('finds distinct_id from posthog.identify() — frontend SDK', async () => { + const { dir, cleanup } = createFixture({ + 'src/analytics.ts': `import posthog from 'posthog-js'; +posthog.identify(user.id, { name: user.name });`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('user.id'); + expect(result.sourceFile).toBe('src/analytics.ts'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from client.capture() — backend Node SDK', async () => { + const { dir, cleanup } = createFixture({ + 'src/events.ts': `client.capture({ + distinctId: req.user.id, + event: 'purchase', +});`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('req.user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from client.alias() — backend Node SDK', async () => { + const { dir, cleanup } = createFixture({ + 'src/auth.ts': `client.alias({ + distinctId: user.frontendId, + alias: user.backendId, +});`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('user.frontendId'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from variable assignment', async () => { + const { dir, cleanup } = createFixture({ + 'src/posthog.ts': `const distinctId = session.user.id;`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('session.user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from get_distinct_id() assignment', async () => { + const { dir, cleanup } = createFixture({ + 'src/tracking.ts': `const userId = posthog.get_distinct_id();`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('userId'); + } finally { + cleanup(); + } + }); + + test('returns null when no PostHog usage found', async () => { + const { dir, cleanup } = createFixture({ + 'src/app.ts': `console.log('hello');`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + }); + + describe('Python', () => { + test('finds distinct_id from posthog.identify()', async () => { + const { dir, cleanup } = createFixture({ + 'analytics.py': `import posthog +posthog.identify(request.user.pk)`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('request.user.pk'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from posthog.capture() — positional arg', async () => { + const { dir, cleanup } = createFixture({ + 'events.py': `posthog.capture(user.id, 'purchase')`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from keyword argument', async () => { + const { dir, cleanup } = createFixture({ + 'track.py': `posthog.capture(distinct_id=user.email, event='sign_up')`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('user.email'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from posthog.alias()', async () => { + const { dir, cleanup } = createFixture({ + 'alias.py': `posthog.alias(previous_id=old_id, distinct_id=user.uuid)`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'python'); + expect(result.distinctIdExpression).toBe('user.uuid'); + } finally { + cleanup(); + } + }); + }); + + describe('Ruby', () => { + test('finds distinct_id from capture() hash', async () => { + const { dir, cleanup } = createFixture({ + 'app/services/tracking.rb': `posthog.capture({ + distinct_id: current_user.id, + event: 'purchase' +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'ruby'); + expect(result.distinctIdExpression).toBe('current_user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from identify() hash', async () => { + const { dir, cleanup } = createFixture({ + 'app/controllers/sessions_controller.rb': `posthog.identify({ + distinct_id: @user.id, + properties: { email: @user.email } +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'ruby'); + expect(result.distinctIdExpression).toBe('@user.id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from alias() hash', async () => { + const { dir, cleanup } = createFixture({ + 'app/services/alias.rb': `posthog.alias({ + distinct_id: user.frontend_id, + alias: user.backend_id, +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'ruby'); + expect(result.distinctIdExpression).toBe('user.frontend_id'); + } finally { + cleanup(); + } + }); + }); + + describe('PHP', () => { + test('finds distinct_id from PostHog::capture()', async () => { + const { dir, cleanup } = createFixture({ + 'app/Http/Controllers/EventController.php': `PostHog::capture([ + 'distinctId' => $user->id, + 'event' => 'purchase', +]);`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'php'); + expect(result.distinctIdExpression).toBe('$user->id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from PostHog::identify()', async () => { + const { dir, cleanup } = createFixture({ + 'app/Listeners/LoginListener.php': `PostHog::identify([ + 'distinctId' => Auth::id(), +]);`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'php'); + expect(result.distinctIdExpression).toBe('Auth::id()'); + } finally { + cleanup(); + } + }); + }); + + describe('Go', () => { + test('finds DistinctId from posthog.Capture struct', async () => { + const { dir, cleanup } = createFixture({ + 'analytics/track.go': `client.Enqueue(posthog.Capture{ + DistinctId: user.ID, + Event: "purchase", +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'go'); + expect(result.distinctIdExpression).toBe('user.ID'); + } finally { + cleanup(); + } + }); + + test('finds DistinctId from posthog.Identify struct', async () => { + const { dir, cleanup } = createFixture({ + 'analytics/identify.go': `client.Enqueue(posthog.Identify{ + DistinctId: req.UserID, +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'go'); + expect(result.distinctIdExpression).toBe('req.UserID'); + } finally { + cleanup(); + } + }); + + test('finds DistinctId from posthog.Alias struct', async () => { + const { dir, cleanup } = createFixture({ + 'analytics/alias.go': `client.Enqueue(posthog.Alias{ + DistinctId: user.FrontendID, + Alias: user.BackendID, +})`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'go'); + expect(result.distinctIdExpression).toBe('user.FrontendID'); + } finally { + cleanup(); + } + }); + }); + + describe('Java / Kotlin', () => { + test('finds distinct_id from posthog.capture() — Java', async () => { + const { dir, cleanup } = createFixture({ + 'src/main/java/Analytics.java': `posthog.capture(user.getId(), "purchase");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'java'); + expect(result.distinctIdExpression).toBe('user.getId()'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from posthog.identify() — Java', async () => { + const { dir, cleanup } = createFixture({ + 'src/main/java/Auth.java': `posthog.identify(session.getUserId());`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'java'); + expect(result.distinctIdExpression).toBe('session.getUserId()'); + } finally { + cleanup(); + } + }); + + test('finds distinctId from PostHog.identify() — Kotlin/Android SDK', async () => { + const { dir, cleanup } = createFixture({ + 'app/src/main/kotlin/Analytics.kt': `PostHog.identify(distinctId = currentUser.uid)`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'java'); + expect(result.distinctIdExpression).toBe('currentUser.uid'); + } finally { + cleanup(); + } + }); + }); + + describe('.NET', () => { + test('finds DistinctId from property assignment', async () => { + const { dir, cleanup } = createFixture({ + 'Services/Analytics.cs': `var options = new CaptureOptions +{ + DistinctId = user.Id, + Event = "purchase", +};`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'dotnet'); + expect(result.distinctIdExpression).toBe('user.Id'); + } finally { + cleanup(); + } + }); + + test('finds distinct_id from Capture() call', async () => { + const { dir, cleanup } = createFixture({ + 'Services/Tracking.cs': `await posthog.CaptureAsync(userId, "purchase");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'dotnet'); + expect(result.distinctIdExpression).toBe('userId'); + } finally { + cleanup(); + } + }); + }); + + describe('filtering', () => { + test('ignores string literal distinct_ids', async () => { + const { dir, cleanup } = createFixture({ + 'test.ts': `posthog.identify("hardcoded-id");`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + + test('ignores placeholder values from docs examples', async () => { + const { dir, cleanup } = createFixture({ + 'example.ts': `posthog.identify(distinct_id, { email: 'test@test.com' });`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + // "distinct_id" is a placeholder, should be skipped + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + + test('ignores test files', async () => { + const { dir, cleanup } = createFixture({ + 'src/__tests__/analytics.test.ts': `posthog.identify(testUser.id);`, + 'src/analytics.spec.ts': `posthog.identify(mockUser.id);`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBeNull(); + } finally { + cleanup(); + } + }); + + test('prefers identify() over capture() when both present', async () => { + const { dir, cleanup } = createFixture({ + 'src/posthog.ts': `posthog.identify(auth.user.id, { name: user.name }); +posthog.capture('page_view', { url: window.location.href });`, + }); + try { + const result = await detectPostHogDistinctId(dir, 'node'); + expect(result.distinctIdExpression).toBe('auth.user.id'); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts new file mode 100644 index 0000000..8fe5183 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/prompt-builder.test.ts @@ -0,0 +1,177 @@ +import { buildRevenueAnalyticsPrompt } from '../prompt-builder'; +import type { StripeDetectionResult, PostHogDistinctIdResult } from '../types'; +import { STRIPE_DOCS_FALLBACK } from '../stripe-docs-fallback'; + +function makeContext(overrides?: { + language?: StripeDetectionResult['language']; + customerCalls?: StripeDetectionResult['customerCreationCalls']; + chargeCalls?: StripeDetectionResult['chargeCalls']; + distinctId?: string | null; + usesCheckout?: boolean; +}) { + const language = overrides?.language ?? 'node'; + return { + language, + stripeDetection: { + sdkPackage: 'package.json', + sdkVersion: '14.21.0', + language, + customerCreationCalls: overrides?.customerCalls ?? [ + { + file: 'src/billing.ts', + line: 10, + snippet: 'stripe.customers.create({', + }, + ], + chargeCalls: overrides?.chargeCalls ?? [ + { + file: 'src/payments.ts', + line: 20, + snippet: 'stripe.paymentIntents.create({', + type: 'payment_intent' as const, + }, + ], + usesCheckoutSessions: overrides?.usesCheckout ?? false, + } satisfies StripeDetectionResult, + posthogDetection: { + distinctIdExpression: + overrides && 'distinctId' in overrides + ? overrides.distinctId ?? null + : 'user.id', + sourceFile: overrides?.distinctId === null ? null : 'src/analytics.ts', + sourceLine: + overrides?.distinctId === null ? null : 'posthog.identify(user.id)', + } satisfies PostHogDistinctIdResult, + stripeDocs: STRIPE_DOCS_FALLBACK[language], + }; +} + +describe('buildRevenueAnalyticsPrompt', () => { + test('includes language and Stripe SDK info', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('node'); + expect(prompt).toContain('v14.21.0'); + }); + + test('includes detected distinct_id expression and source line', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('`user.id`'); + expect(prompt).toContain('src/analytics.ts'); + expect(prompt).toContain('posthog.identify(user.id)'); + }); + + test('instructs agent to search codebase when distinct_id not detected', () => { + const prompt = buildRevenueAnalyticsPrompt( + makeContext({ distinctId: null }), + ); + expect(prompt).toContain('Not automatically detected'); + expect(prompt).toContain('Search the codebase'); + }); + + test('explains how to find distinct_id from posthog.identify', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('posthog.identify('); + expect(prompt).toContain('FIRST ARGUMENT is the distinct_id'); + }); + + test('uses placeholder in code examples', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain(''); + expect(prompt).toContain('Replace `` with the actual'); + }); + + describe('guiding tenets', () => { + test('includes never-fabricate tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Never fabricate the value'); + expect(prompt).toContain('A wrong value is worse than no value'); + }); + + test('includes thread-the-value tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain("Thread the value, don't invent it"); + expect(prompt).toContain('optional parameter'); + }); + + test('includes minimize-api-calls tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Minimize extra API calls'); + expect(prompt).toContain('network round-trip'); + }); + + test('includes follow-abstractions tenet', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Follow existing Stripe abstraction patterns'); + expect(prompt).toContain('utility/service layer'); + }); + + test('warns against fabricating user.posthogDistinctId', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('user.posthogDistinctId'); + expect(prompt).toContain('does not exist'); + }); + }); + + test('includes customer creation locations', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('src/billing.ts:10'); + }); + + test('includes charge call locations with types', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('src/payments.ts:20 [payment_intent]'); + }); + + test('handles no customer creation calls found', () => { + const prompt = buildRevenueAnalyticsPrompt( + makeContext({ customerCalls: [] }), + ); + expect(prompt).toContain('none found'); + expect(prompt).toContain('search the codebase yourself'); + }); + + test('includes checkout session note when detected', () => { + const prompt = buildRevenueAnalyticsPrompt( + makeContext({ + usesCheckout: true, + chargeCalls: [ + { + file: 'src/checkout.ts', + line: 5, + snippet: 'stripe.checkout.sessions.create({', + type: 'checkout_session', + }, + ], + }), + ); + expect(prompt).toContain('Stripe Checkout detected'); + expect(prompt).toContain('checkout.session.completed webhook'); + }); + + test('does not include checkout note when not using checkout', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).not.toContain('Stripe Checkout detected'); + }); + + test('includes Stripe docs code examples', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('stripe.customers.create'); + expect(prompt).toContain('stripe.customers.update'); + expect(prompt).toContain('posthog_person_distinct_id'); + }); + + test('includes constraints section', () => { + const prompt = buildRevenueAnalyticsPrompt(makeContext()); + expect(prompt).toContain('Do NOT modify charge/payment logic'); + expect(prompt).toContain('Do NOT remove any existing code'); + }); + + test.each(['python', 'ruby', 'php', 'go', 'java', 'dotnet'] as const)( + 'generates prompt for %s', + (language) => { + const prompt = buildRevenueAnalyticsPrompt(makeContext({ language })); + expect(prompt).toContain(language); + expect(prompt).toContain('posthog_person_distinct_id'); + }, + ); +}); diff --git a/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts new file mode 100644 index 0000000..bb8e183 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/stripe-detection.test.ts @@ -0,0 +1,383 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { detectStripe } from '../stripe-detection'; + +function createFixture(files: Record): { + dir: string; + cleanup: () => void; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-stripe-')); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(dir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +describe('detectStripe', () => { + describe('Node.js', () => { + test('detects Stripe from package.json', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('package.json'); + expect(result!.language).toBe('node'); + } finally { + cleanup(); + } + }); + + test('extracts version from package-lock.json', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'package-lock.json': JSON.stringify({ + packages: { 'node_modules/stripe': { version: '14.21.0' } }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkVersion).toBe('14.21.0'); + } finally { + cleanup(); + } + }); + + test('finds customer creation calls', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'src/billing.ts': `import Stripe from 'stripe'; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +const customer = await stripe.customers.create({ + email: user.email, +});`, + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.customerCreationCalls).toHaveLength(1); + expect(result!.customerCreationCalls[0].file).toBe('src/billing.ts'); + expect(result!.customerCreationCalls[0].line).toBe(4); + } finally { + cleanup(); + } + }); + + test('finds charge patterns', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'src/payments.ts': `const intent = await stripe.paymentIntents.create({ + amount: 1000, + currency: 'usd', +}); + +const sub = await stripe.subscriptions.create({ + customer: customerId, +}); + +const session = await stripe.checkout.sessions.create({ + mode: 'payment', +});`, + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.chargeCalls).toHaveLength(3); + expect(result!.chargeCalls.map((c) => c.type)).toEqual([ + 'payment_intent', + 'subscription', + 'checkout_session', + ]); + expect(result!.usesCheckoutSessions).toBe(true); + } finally { + cleanup(); + } + }); + + test('returns null when Stripe is not installed', () => { + const { dir, cleanup } = createFixture({ + 'package.json': JSON.stringify({ + dependencies: { express: '^4.0.0' }, + }), + }); + try { + expect(detectStripe(dir, 'node')).toBeNull(); + } finally { + cleanup(); + } + }); + }); + + describe('Python', () => { + test('detects Stripe from requirements.txt', () => { + const { dir, cleanup } = createFixture({ + 'requirements.txt': 'stripe>=5.0.0\nflask', + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('requirements.txt'); + } finally { + cleanup(); + } + }); + + test('detects Stripe from uv.lock', () => { + const { dir, cleanup } = createFixture({ + 'uv.lock': `[[package]] +name = "stripe" +version = "11.4.1" +source = { registry = "https://pypi.org/simple" }`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('uv.lock'); + } finally { + cleanup(); + } + }); + + test('detects Stripe from poetry.lock', () => { + const { dir, cleanup } = createFixture({ + 'poetry.lock': `[[package]] +name = "stripe" +version = "11.4.1"`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('poetry.lock'); + } finally { + cleanup(); + } + }); + + test('finds Python customer creation', () => { + const { dir, cleanup } = createFixture({ + 'requirements.txt': 'stripe>=5.0.0', + 'billing.py': `import stripe + +customer = stripe.Customer.create( + email=user.email, +)`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.customerCreationCalls).toHaveLength(1); + } finally { + cleanup(); + } + }); + + test('finds Python charge patterns', () => { + const { dir, cleanup } = createFixture({ + 'requirements.txt': 'stripe>=5.0.0', + 'payments.py': `import stripe + +intent = stripe.PaymentIntent.create( + amount=1000, + currency="usd", +) + +sub = stripe.Subscription.create( + customer=customer_id, +)`, + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.chargeCalls).toHaveLength(2); + } finally { + cleanup(); + } + }); + }); + + describe('Ruby', () => { + test('detects Stripe from Gemfile', () => { + const { dir, cleanup } = createFixture({ + Gemfile: "gem 'stripe'", + }); + try { + const result = detectStripe(dir, 'ruby'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('Gemfile'); + } finally { + cleanup(); + } + }); + + test('finds Ruby customer creation', () => { + const { dir, cleanup } = createFixture({ + Gemfile: "gem 'stripe'", + 'app/services/billing.rb': `Stripe::Customer.create({ + email: user.email, +})`, + }); + try { + const result = detectStripe(dir, 'ruby'); + expect(result!.customerCreationCalls).toHaveLength(1); + } finally { + cleanup(); + } + }); + }); + + describe('PHP', () => { + test('detects Stripe from composer.json', () => { + const { dir, cleanup } = createFixture({ + 'composer.json': JSON.stringify({ + require: { 'stripe/stripe-php': '^12.0' }, + }), + }); + try { + const result = detectStripe(dir, 'php'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('composer.json'); + } finally { + cleanup(); + } + }); + }); + + describe('Go', () => { + test('detects Stripe from go.mod', () => { + const { dir, cleanup } = createFixture({ + 'go.mod': `module myapp + +require github.com/stripe/stripe-go/v76 v76.0.0`, + }); + try { + const result = detectStripe(dir, 'go'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('go.mod'); + } finally { + cleanup(); + } + }); + }); + + describe('Java', () => { + test('detects Stripe from build.gradle', () => { + const { dir, cleanup } = createFixture({ + 'build.gradle': `dependencies { + implementation 'com.stripe:stripe-java:24.0.0' +}`, + }); + try { + const result = detectStripe(dir, 'java'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('build.gradle'); + } finally { + cleanup(); + } + }); + }); + + describe('.NET', () => { + test('detects Stripe from .csproj', () => { + const { dir, cleanup } = createFixture({ + 'MyApp.csproj': ` + + + +`, + }); + try { + const result = detectStripe(dir, 'dotnet'); + expect(result).not.toBeNull(); + expect(result!.sdkVersion).toBe('43.0.0'); + } finally { + cleanup(); + } + }); + }); + + describe('monorepo support', () => { + test('detects Stripe from subdirectory package.json', () => { + const { dir, cleanup } = createFixture({ + 'backend/package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('backend/package.json'); + } finally { + cleanup(); + } + }); + + test('extracts version from subdirectory lockfile', () => { + const { dir, cleanup } = createFixture({ + 'backend/package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'backend/package-lock.json': JSON.stringify({ + packages: { 'node_modules/stripe': { version: '14.21.0' } }, + }), + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.sdkVersion).toBe('14.21.0'); + } finally { + cleanup(); + } + }); + + test('finds customer creation calls in subdirectory', () => { + const { dir, cleanup } = createFixture({ + 'backend/package.json': JSON.stringify({ + dependencies: { stripe: '^14.0.0' }, + }), + 'backend/src/billing.ts': `const customer = await stripe.customers.create({ + email: user.email, +});`, + }); + try { + const result = detectStripe(dir, 'node'); + expect(result).not.toBeNull(); + expect(result!.customerCreationCalls).toHaveLength(1); + expect(result!.customerCreationCalls[0].file).toBe( + 'backend/src/billing.ts', + ); + } finally { + cleanup(); + } + }); + + test('detects Python Stripe in subdirectory', () => { + const { dir, cleanup } = createFixture({ + 'server/requirements.txt': 'stripe>=5.0.0\nflask', + }); + try { + const result = detectStripe(dir, 'python'); + expect(result).not.toBeNull(); + expect(result!.sdkPackage).toBe('server/requirements.txt'); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts b/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts new file mode 100644 index 0000000..251c252 --- /dev/null +++ b/src/setup-revenue-analytics/__tests__/stripe-docs-fetcher.test.ts @@ -0,0 +1,81 @@ +/** + * We only test decodeHtmlEntities here — the fetch logic is tested + * via integration tests against the live Stripe docs site. + */ + +// decodeHtmlEntities is not exported, so we reach it through the module internals. +// Re-implement as a standalone to test the logic in isolation. +// This mirrors the implementation in stripe-docs-fetcher.ts exactly. +function decodeHtmlEntities(text: string): string { + let stripped = text; + let prev: string; + do { + prev = stripped; + stripped = stripped.replace(/<[^>]+>/g, ''); + } while (stripped !== prev); + + return stripped + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +describe('decodeHtmlEntities', () => { + test('decodes basic HTML entities', () => { + expect(decodeHtmlEntities('a & b')).toBe('a & b'); + expect(decodeHtmlEntities('a < b')).toBe('a < b'); + expect(decodeHtmlEntities('a > b')).toBe('a > b'); + expect(decodeHtmlEntities('"hello"')).toBe('"hello"'); + }); + + test('decodes apostrophe entities', () => { + expect(decodeHtmlEntities('it's')).toBe("it's"); + expect(decodeHtmlEntities('it's')).toBe("it's"); + }); + + test('strips HTML tags', () => { + expect(decodeHtmlEntities('hello')).toBe('hello'); + expect(decodeHtmlEntities('link')).toBe('link'); + expect(decodeHtmlEntities('before
after')).toBe('beforeafter'); + }); + + test('strips nested/reconstructed tags via loop', () => { + // ipt> is matched as one tag, leaving harmless "ipt>" text. + // The key point: no executable '); + expect(result).not.toContain(' { + // <script> should decode to ', + ); + }); + + test('handles double-encoded entities', () => { + // &lt; → strip tags (none present) → decode < → < (NO) + // We intentionally decode & last, so &lt;script&gt; becomes + // <script> rather than