diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..8f0fdc8 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,74 @@ +name: PostHog Examples - Playwright Tests +permissions: + contents: read + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + # Runs nightly at 12am PST (which is 8am UTC) + - cron: '0 8 * * *' + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + examples: ${{ steps.set-examples.outputs.examples }} + steps: + - uses: actions/checkout@v4 + + - name: Discover examples with Playwright configs + id: set-examples + run: | + examples=$(find basics -maxdepth 2 -name "playwright.config.ts" -exec dirname {} \; | sed 's|^basics/||' | jq -R -s -c 'split("\n")[:-1]') + echo "examples=$examples" >> $GITHUB_OUTPUT + echo "Found examples: $examples" + + test: + needs: discover + if: needs.discover.outputs.examples != '[]' + timeout-minutes: 60 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + example: ${{ fromJson(needs.discover.outputs.examples) }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Install dependencies + run: | + cd basics/${{ matrix.example }} + pnpm install + + - name: Install Playwright Browsers + run: | + cd basics/${{ matrix.example }} + pnpm exec playwright install chromium --with-deps + + - name: Run Playwright tests + run: | + cd basics/${{ matrix.example }} + pnpm exec playwright test + env: + NEXT_PUBLIC_POSTHOG_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} + NEXT_PUBLIC_POSTHOG_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_HOST }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.example }} + path: basics/${{ matrix.example }}/playwright-report/ + retention-days: 30 + +# TODO: report to PostHog which will warn in channel for failure. \ No newline at end of file diff --git a/basics/next-app-router/.env.example b/basics/next-app-router/.env.example new file mode 100644 index 0000000..4f97ddd --- /dev/null +++ b/basics/next-app-router/.env.example @@ -0,0 +1,5 @@ +# PostHog Configuration +# Get your PostHog API key from: https://app.posthog.com/project/settings +NEXT_PUBLIC_POSTHOG_KEY=your_posthog_project_api_key_here +# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com \ No newline at end of file diff --git a/basics/next-app-router/.gitignore b/basics/next-app-router/.gitignore index 5ef6a52..0164aaa 100644 --- a/basics/next-app-router/.gitignore +++ b/basics/next-app-router/.gitignore @@ -12,6 +12,10 @@ # testing /coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ # next.js /.next/ @@ -31,7 +35,7 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env # vercel .vercel diff --git a/basics/next-app-router/instrumentation-client.ts b/basics/next-app-router/instrumentation-client.ts index f94a15a..4b14db5 100644 --- a/basics/next-app-router/instrumentation-client.ts +++ b/basics/next-app-router/instrumentation-client.ts @@ -9,4 +9,9 @@ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { capture_exceptions: true, // Turn on debug in development mode debug: process.env.NODE_ENV === "development", + // @ignoreBlockStart + // Disable request batching in test environment + request_batching: false, + opt_out_useragent_filter: true, // This disables bot detection + // @ignoreBlockEnd }); diff --git a/basics/next-app-router/package.json b/basics/next-app-router/package.json index 5348d6a..d2e9a08 100644 --- a/basics/next-app-router/package.json +++ b/basics/next-app-router/package.json @@ -6,7 +6,11 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "postinstall": "playwright install chromium", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report" }, "dependencies": { "next": "15.5.2", @@ -17,9 +21,11 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.56.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "15.5.2", "typescript": "^5" diff --git a/basics/next-app-router/playwright.config.ts b/basics/next-app-router/playwright.config.ts new file mode 100644 index 0000000..7836625 --- /dev/null +++ b/basics/next-app-router/playwright.config.ts @@ -0,0 +1,56 @@ +// @ignoreFile +import { defineConfig, devices } from '@playwright/test'; +import { config } from 'dotenv'; + +// Load environment variables from .env file +config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:3333', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Global timeout settings */ + timeout: 180000, // 3 minutes for individual tests + expect: { + timeout: 60000, // 1 minute for expect assertions + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}', + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm dev --port 3333', + url: 'http://127.0.0.1:3333', + reuseExistingServer: false, // Always start fresh + }, +}); + + diff --git a/basics/next-app-router/pnpm-lock.yaml b/basics/next-app-router/pnpm-lock.yaml index c73be62..8dbcfc6 100644 --- a/basics/next-app-router/pnpm-lock.yaml +++ b/basics/next-app-router/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: next: specifier: 15.5.2 - version: 15.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.5.2(@playwright/test@1.56.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) posthog-js: specifier: ^1.261.6 version: 1.261.6 @@ -27,6 +27,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.1 + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 '@types/node': specifier: ^20 version: 20.19.13 @@ -36,6 +39,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.1.9(@types/react@19.1.12) + dotenv: + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9 version: 9.34.0 @@ -306,6 +312,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@posthog/core@1.0.2': resolution: {integrity: sha512-hWk3rUtJl2crQK0WNmwg13n82hnTwB99BT99/XI5gZSvIlYZ1TPmMZE8H2dhJJ98J/rm9vYJ/UXNzw3RV5HTpQ==} @@ -687,6 +698,10 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -901,6 +916,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1294,6 +1314,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1807,6 +1837,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@posthog/core@1.0.2': {} '@rtsao/scc@1.1.0': {} @@ -2214,6 +2248,8 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2576,6 +2612,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -2877,7 +2916,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.5.2(@playwright/test@1.56.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.5.2 '@swc/helpers': 0.5.15 @@ -2895,6 +2934,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.2 '@next/swc-win32-arm64-msvc': 15.5.2 '@next/swc-win32-x64-msvc': 15.5.2 + '@playwright/test': 1.56.1 sharp: 0.34.3 transitivePeerDependencies: - '@babel/core' @@ -2981,6 +3021,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: diff --git a/basics/next-app-router/src/lib/posthog-server.ts b/basics/next-app-router/src/lib/posthog-server.ts index e62bac1..7b5640f 100644 --- a/basics/next-app-router/src/lib/posthog-server.ts +++ b/basics/next-app-router/src/lib/posthog-server.ts @@ -12,6 +12,7 @@ export function getPostHogClient() { flushInterval: 0 } ); + posthogClient.debug(true); } return posthogClient; } diff --git a/basics/next-app-router/tests/example.spec.ts b/basics/next-app-router/tests/example.spec.ts new file mode 100644 index 0000000..8a0a253 --- /dev/null +++ b/basics/next-app-router/tests/example.spec.ts @@ -0,0 +1,227 @@ +// @ignoreFile +import { test, expect } from '@playwright/test'; + +// Global variables to store the generated username +let generatedUsername: string; + +// PostHog event tracking +let eventCounts: Record = {}; +let capturedEvents: Array<{ + event: string; + timestamp: string; + uuid?: string; + properties?: any; + fullMessage: string; +}> = []; + +test('verify user is logged in', async ({ page }) => { + // Reset event tracking + eventCounts = {}; + capturedEvents = []; + + // Setup PostHog event monitoring + setupPostHogEventMonitoring(page); + + // Navigate to home page and wait for network to be idle + await loginAsTestAgent(page); + + // Wait for expected PostHog events + await waitForExpectedEvents(['$pageview', 'user_logged_in', '$identify']); + + // Verify we can see the welcome message (user should be logged in from beforeEach) + await expect(page.getByText(`Welcome back, ${generatedUsername}!`)).toBeVisible(); + + // Print PostHog events summary + printPostHogEventsSummary(); + + // Assert PostHog events snapshot using Playwright's built-in snapshot functionality + const eventsSnapshot = createPostHogEventsSnapshot(); + expect(JSON.stringify(eventsSnapshot, null, 2)).toMatchSnapshot('posthog-events.json'); + + // Verify expected events were captured + verifyExpectedEvents(); +}); + +// Helper functions + +// PostHog Events Snapshot Helper - Using Playwright's built-in snapshot functionality +function createPostHogEventsSnapshot() { + return { + eventCounts: eventCounts, + events: capturedEvents.map(event => ({ + event: event.event, + properties: event.properties, + fullMessage: sanitizeMessage(event.fullMessage) + })) + }; +} + +// Sanitize dynamic values in messages for stable snapshots +function sanitizeMessage(message: string): string { + return message + // Replace UUIDs with placeholder + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '[UUID]') + // Replace PostHog UUIDs (longer format) + .replace(/[0-9a-f]{24}/gi, '[POSTHOG_UUID]') + // Replace all timestamp formats with placeholder (including timezone descriptions) + .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z|[A-Za-z]{3} \w{3} \d{1,2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4}( \([^)]+\))?/g, '[TIMESTAMP]'); +} + +// Wait for expected PostHog events +async function waitForExpectedEvents(expectedEvents: string[], timeout: number = 10000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const foundEvents = expectedEvents.filter(eventName => eventCounts[eventName] > 0); + + if (foundEvents.length === expectedEvents.length) { + console.log(`āœ… All expected events found: ${foundEvents.join(', ')}`); + return; + } + + const remainingEvents = expectedEvents.filter(eventName => !foundEvents.includes(eventName)); + if (remainingEvents.length > 0) { + console.log(`ā³ Still waiting for events: ${remainingEvents.join(', ')}`); + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + console.log(`ā° Timeout waiting for events: ${expectedEvents.join(', ')}`); + console.log(`šŸ“Š Current event counts:`, eventCounts); +} + +// Verify expected events were captured +function verifyExpectedEvents() { + const expectedEvents = ['$pageview', 'user_logged_in', '$identify']; + const missingEvents = expectedEvents.filter(eventName => !eventCounts[eventName]); + + if (missingEvents.length > 0) { + console.log(`āš ļø Missing expected events: ${missingEvents.join(', ')}`); + } else { + console.log('āœ… All expected events were captured!'); + } + + // Assert that we have at least some events + expect(capturedEvents.length).toBeGreaterThan(0); + + // Assert that we have the key events + expect(eventCounts['$pageview']).toBeGreaterThan(0); + expect(eventCounts['user_logged_in']).toBeGreaterThan(0); + expect(eventCounts['$identify']).toBeGreaterThan(0); +} + +// Setup PostHog event monitoring +function setupPostHogEventMonitoring(page: any) { + page.on('console', (msg: any) => { + const text = msg.text(); + + // Capture PostHog events from console logs + if (text.includes('[PostHog.js] send "') && text.includes('{uuid:')) { + const eventMatch = text.match(/\[PostHog\.js\] send "([^"]+)"/); + if (eventMatch) { + const eventName = eventMatch[1]; + eventCounts[eventName] = (eventCounts[eventName] || 0) + 1; + + // Extract UUID from the message + const uuidMatch = text.match(/{uuid: ([^,}]+)/); + const uuid = uuidMatch ? uuidMatch[1] : undefined; + + // Extract properties from the message - try multiple patterns + let properties = undefined; + + // Try to extract properties object + const propertiesMatch = text.match(/properties: (Object|{[^}]+})/); + if (propertiesMatch) { + if (propertiesMatch[1] === 'Object') { + properties = '[Object - see full message for details]'; + } else { + try { + properties = JSON.parse(propertiesMatch[1]); + } catch (e) { + properties = propertiesMatch[1]; + } + } + } + + // Also try to extract $set and $set_once for identify events + const setMatch = text.match(/\$set: (Object|{[^}]+})/); + const setOnceMatch = text.match(/\$set_once: (Object|{[^}]+})/); + + if (setMatch || setOnceMatch) { + // Create a clean object for $set and $set_once + const identifyProps: any = {}; + + if (setMatch) { + identifyProps.$set = setMatch[1] === 'Object' ? '[Object]' : setMatch[1]; + } + + if (setOnceMatch) { + identifyProps.$set_once = setOnceMatch[1] === 'Object' ? '[Object]' : setOnceMatch[1]; + } + + // If we already have properties, merge them properly + if (properties && typeof properties === 'object') { + properties = { ...properties, ...identifyProps }; + } else { + properties = identifyProps; + } + } + + capturedEvents.push({ + event: eventName, + timestamp: new Date().toISOString(), + uuid: uuid, + properties: properties, + fullMessage: text + }); + + } + } + }); +} + +// Print PostHog events summary +function printPostHogEventsSummary() { + console.log('\nšŸŽÆ ===== POSTHOG EVENTS SUMMARY ====='); + console.log(`šŸ“Š Total Events Captured: ${capturedEvents.length}`); + console.log(`šŸ“ˆ Event Counts:`); + + Object.entries(eventCounts).forEach(([eventName, count]) => { + console.log(` - ${eventName}: ${count}`); + }); + + console.log('\nšŸ“‹ Detailed Event Information:'); + capturedEvents.forEach((event, index) => { + console.log(`\n${index + 1}. Event: ${event.event}`); + console.log(` Timestamp: ${event.timestamp}`); + console.log(` UUID: ${event.uuid || 'N/A'}`); + if (event.properties) { + console.log(` Properties:`, JSON.stringify(event.properties, null, 2)); + } + console.log(` Full Message: ${event.fullMessage}`); + }); + + console.log('\nšŸŽÆ ===== END POSTHOG EVENTS SUMMARY =====\n'); +} + +// Login helper function +async function loginAsTestAgent(page: any) { + await page.goto('/'); + + const randomPassword = 'test_password_123'; + + generatedUsername = 'test_user'; + + // Fill in the username field + await page.getByLabel('Username:').fill(generatedUsername); + + // Fill in the password field with random password + await page.getByLabel('Password:').fill(randomPassword); + + // Click the Sign In button + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Expect to see the welcome message after successful login + await expect(page.getByText(`Welcome back, ${generatedUsername}!`)).toBeVisible(); +} \ No newline at end of file diff --git a/basics/next-app-router/tests/example.spec.ts-snapshots/posthog-events.json b/basics/next-app-router/tests/example.spec.ts-snapshots/posthog-events.json new file mode 100644 index 0000000..3860ab1 --- /dev/null +++ b/basics/next-app-router/tests/example.spec.ts-snapshots/posthog-events.json @@ -0,0 +1,48 @@ +{ + "eventCounts": { + "$pageview": 1, + "$autocapture": 4, + "$identify": 1, + "user_logged_in": 1 + }, + "events": [ + { + "event": "$pageview", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$pageview\" {uuid: [UUID], event: $pageview, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$identify", + "properties": { + "$set": "[Object]", + "$set_once": "[Object]" + }, + "fullMessage": "[PostHog.js] send \"$identify\" {uuid: [UUID], event: $identify, properties: Object, $set: Object, $set_once: Object}" + }, + { + "event": "user_logged_in", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"user_logged_in\" {uuid: [UUID], event: user_logged_in, properties: Object, timestamp: [TIMESTAMP]}" + } + ] +} \ No newline at end of file diff --git a/basics/next-pages-router/.gitignore b/basics/next-pages-router/.gitignore index 5ef6a52..935ba89 100644 --- a/basics/next-pages-router/.gitignore +++ b/basics/next-pages-router/.gitignore @@ -12,6 +12,10 @@ # testing /coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ # next.js /.next/ diff --git a/basics/next-pages-router/instrumentation-client.ts b/basics/next-pages-router/instrumentation-client.ts index f94a15a..4b14db5 100644 --- a/basics/next-pages-router/instrumentation-client.ts +++ b/basics/next-pages-router/instrumentation-client.ts @@ -9,4 +9,9 @@ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { capture_exceptions: true, // Turn on debug in development mode debug: process.env.NODE_ENV === "development", + // @ignoreBlockStart + // Disable request batching in test environment + request_batching: false, + opt_out_useragent_filter: true, // This disables bot detection + // @ignoreBlockEnd }); diff --git a/basics/next-pages-router/package.json b/basics/next-pages-router/package.json index e52cae3..c23fe98 100644 --- a/basics/next-pages-router/package.json +++ b/basics/next-pages-router/package.json @@ -6,7 +6,11 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "postinstall": "playwright install chromium", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report" }, "dependencies": { "next": "15.5.5", @@ -16,12 +20,14 @@ "react-dom": "19.1.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.56.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "15.5.5", - "@eslint/eslintrc": "^3" + "typescript": "^5" } } diff --git a/basics/next-pages-router/playwright.config.ts b/basics/next-pages-router/playwright.config.ts new file mode 100644 index 0000000..b8e9fac --- /dev/null +++ b/basics/next-pages-router/playwright.config.ts @@ -0,0 +1,55 @@ +// @ignoreFile +import { defineConfig, devices } from '@playwright/test'; +import { config } from 'dotenv'; + +// Load environment variables from .env file +config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:3333', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Global timeout settings */ + timeout: 180000, // 3 minutes for individual tests + expect: { + timeout: 60000, // 1 minute for expect assertions + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}', + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm dev --port 3333', + url: 'http://127.0.0.1:3333', + reuseExistingServer: false, // Always start fresh + }, +}); + diff --git a/basics/next-pages-router/pnpm-lock.yaml b/basics/next-pages-router/pnpm-lock.yaml index 71cfe0a..77bb1f1 100644 --- a/basics/next-pages-router/pnpm-lock.yaml +++ b/basics/next-pages-router/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: next: specifier: 15.5.5 - version: 15.5.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.5.5(@playwright/test@1.56.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) posthog-js: specifier: ^1.261.6 version: 1.276.0 @@ -27,6 +27,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.1 + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 '@types/node': specifier: ^20 version: 20.19.21 @@ -36,6 +39,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.2(@types/react@19.2.2) + dotenv: + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9 version: 9.37.0 @@ -310,6 +316,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@posthog/core@1.3.0': resolution: {integrity: sha512-hxLL8kZNHH098geedcxCz8y6xojkNYbmJEW+1vFXsmPcExyCXIUUJ/34X6xa9GcprKxd0Wsx3vfJQLQX4iVPhw==} @@ -684,6 +695,10 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -898,6 +913,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1292,6 +1312,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1807,6 +1837,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@posthog/core@1.3.0': {} '@rtsao/scc@1.1.0': {} @@ -2202,6 +2236,8 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2564,6 +2600,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -2865,7 +2904,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.5.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.5.5(@playwright/test@1.56.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.5.5 '@swc/helpers': 0.5.15 @@ -2883,6 +2922,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.5 '@next/swc-win32-arm64-msvc': 15.5.5 '@next/swc-win32-x64-msvc': 15.5.5 + '@playwright/test': 1.56.1 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' @@ -2969,6 +3009,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: diff --git a/basics/next-pages-router/tests/example.spec.ts b/basics/next-pages-router/tests/example.spec.ts new file mode 100644 index 0000000..594d0f8 --- /dev/null +++ b/basics/next-pages-router/tests/example.spec.ts @@ -0,0 +1,227 @@ +// @ignoreFile +import { test, expect } from '@playwright/test'; + +// Global variables to store the generated username +let generatedUsername: string; + +// PostHog event tracking +let eventCounts: Record = {}; +let capturedEvents: Array<{ + event: string; + timestamp: string; + uuid?: string; + properties?: any; + fullMessage: string; +}> = []; + +test('verify user is logged in', async ({ page }) => { + // Reset event tracking + eventCounts = {}; + capturedEvents = []; + + // Setup PostHog event monitoring + setupPostHogEventMonitoring(page); + + // Navigate to home page and wait for network to be idle + await loginAsTestAgent(page); + + // Wait for expected PostHog events + await waitForExpectedEvents(['$pageview', 'user_logged_in', '$identify']); + + // Verify we can see the welcome message (user should be logged in from beforeEach) + await expect(page.getByText(`Welcome back, ${generatedUsername}!`)).toBeVisible(); + + // Print PostHog events summary + printPostHogEventsSummary(); + + // Assert PostHog events snapshot using Playwright's built-in snapshot functionality + const eventsSnapshot = createPostHogEventsSnapshot(); + expect(JSON.stringify(eventsSnapshot, null, 2)).toMatchSnapshot('posthog-events.json'); + + // Verify expected events were captured + verifyExpectedEvents(); +}); + +// Helper functions + +// PostHog Events Snapshot Helper - Using Playwright's built-in snapshot functionality +function createPostHogEventsSnapshot() { + return { + eventCounts: eventCounts, + events: capturedEvents.map(event => ({ + event: event.event, + properties: event.properties, + fullMessage: sanitizeMessage(event.fullMessage) + })) + }; +} + +// Sanitize dynamic values in messages for stable snapshots +function sanitizeMessage(message: string): string { + return message + // Replace UUIDs with placeholder + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '[UUID]') + // Replace PostHog UUIDs (longer format) + .replace(/[0-9a-f]{24}/gi, '[POSTHOG_UUID]') + // Replace all timestamp formats with placeholder (including timezone descriptions) + .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z|[A-Za-z]{3} \w{3} \d{1,2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4}( \([^)]+\))?/g, '[TIMESTAMP]'); +} + +// Wait for expected PostHog events +async function waitForExpectedEvents(expectedEvents: string[], timeout: number = 10000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const foundEvents = expectedEvents.filter(eventName => eventCounts[eventName] > 0); + + if (foundEvents.length === expectedEvents.length) { + console.log(`āœ… All expected events found: ${foundEvents.join(', ')}`); + return; + } + + const remainingEvents = expectedEvents.filter(eventName => !foundEvents.includes(eventName)); + if (remainingEvents.length > 0) { + console.log(`ā³ Still waiting for events: ${remainingEvents.join(', ')}`); + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + console.log(`ā° Timeout waiting for events: ${expectedEvents.join(', ')}`); + console.log(`šŸ“Š Current event counts:`, eventCounts); +} + +// Verify expected events were captured +function verifyExpectedEvents() { + const expectedEvents = ['$pageview', 'user_logged_in', '$identify']; + const missingEvents = expectedEvents.filter(eventName => !eventCounts[eventName]); + + if (missingEvents.length > 0) { + console.log(`āš ļø Missing expected events: ${missingEvents.join(', ')}`); + } else { + console.log('āœ… All expected events were captured!'); + } + + // Assert that we have at least some events + expect(capturedEvents.length).toBeGreaterThan(0); + + // Assert that we have the key events + expect(eventCounts['$pageview']).toBeGreaterThan(0); + expect(eventCounts['user_logged_in']).toBeGreaterThan(0); + expect(eventCounts['$identify']).toBeGreaterThan(0); +} + +// Setup PostHog event monitoring +function setupPostHogEventMonitoring(page: any) { + page.on('console', (msg: any) => { + const text = msg.text(); + + // Capture PostHog events from console logs + if (text.includes('[PostHog.js] send "') && text.includes('{uuid:')) { + const eventMatch = text.match(/\[PostHog\.js\] send "([^"]+)"/); + if (eventMatch) { + const eventName = eventMatch[1]; + eventCounts[eventName] = (eventCounts[eventName] || 0) + 1; + + // Extract UUID from the message + const uuidMatch = text.match(/{uuid: ([^,}]+)/); + const uuid = uuidMatch ? uuidMatch[1] : undefined; + + // Extract properties from the message - try multiple patterns + let properties = undefined; + + // Try to extract properties object + const propertiesMatch = text.match(/properties: (Object|{[^}]+})/); + if (propertiesMatch) { + if (propertiesMatch[1] === 'Object') { + properties = '[Object - see full message for details]'; + } else { + try { + properties = JSON.parse(propertiesMatch[1]); + } catch (e) { + properties = propertiesMatch[1]; + } + } + } + + // Also try to extract $set and $set_once for identify events + const setMatch = text.match(/\$set: (Object|{[^}]+})/); + const setOnceMatch = text.match(/\$set_once: (Object|{[^}]+})/); + + if (setMatch || setOnceMatch) { + // Create a clean object for $set and $set_once + const identifyProps: any = {}; + + if (setMatch) { + identifyProps.$set = setMatch[1] === 'Object' ? '[Object]' : setMatch[1]; + } + + if (setOnceMatch) { + identifyProps.$set_once = setOnceMatch[1] === 'Object' ? '[Object]' : setOnceMatch[1]; + } + + // If we already have properties, merge them properly + if (properties && typeof properties === 'object') { + properties = { ...properties, ...identifyProps }; + } else { + properties = identifyProps; + } + } + + capturedEvents.push({ + event: eventName, + timestamp: new Date().toISOString(), + uuid: uuid, + properties: properties, + fullMessage: text + }); + + } + } + }); +} + +// Print PostHog events summary +function printPostHogEventsSummary() { + console.log('\nšŸŽÆ ===== POSTHOG EVENTS SUMMARY ====='); + console.log(`šŸ“Š Total Events Captured: ${capturedEvents.length}`); + console.log(`šŸ“ˆ Event Counts:`); + + Object.entries(eventCounts).forEach(([eventName, count]) => { + console.log(` - ${eventName}: ${count}`); + }); + + console.log('\nšŸ“‹ Detailed Event Information:'); + capturedEvents.forEach((event, index) => { + console.log(`\n${index + 1}. Event: ${event.event}`); + console.log(` Timestamp: ${event.timestamp}`); + console.log(` UUID: ${event.uuid || 'N/A'}`); + if (event.properties) { + console.log(` Properties:`, JSON.stringify(event.properties, null, 2)); + } + console.log(` Full Message: ${event.fullMessage}`); + }); + + console.log('\nšŸŽÆ ===== END POSTHOG EVENTS SUMMARY =====\n'); +} + +// Login helper function +async function loginAsTestAgent(page: any) { + await page.goto('/'); + + const randomPassword = 'test_password_123'; + + generatedUsername = 'test_user'; + + // Fill in the username field + await page.getByLabel('Username:').fill(generatedUsername); + + // Fill in the password field with random password + await page.getByLabel('Password:').fill(randomPassword); + + // Click the Sign In button + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Expect to see the welcome message after successful login + await expect(page.getByText(`Welcome back, ${generatedUsername}!`)).toBeVisible(); +} diff --git a/basics/next-pages-router/tests/example.spec.ts-snapshots/posthog-events.json b/basics/next-pages-router/tests/example.spec.ts-snapshots/posthog-events.json new file mode 100644 index 0000000..3860ab1 --- /dev/null +++ b/basics/next-pages-router/tests/example.spec.ts-snapshots/posthog-events.json @@ -0,0 +1,48 @@ +{ + "eventCounts": { + "$pageview": 1, + "$autocapture": 4, + "$identify": 1, + "user_logged_in": 1 + }, + "events": [ + { + "event": "$pageview", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$pageview\" {uuid: [UUID], event: $pageview, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$autocapture", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"$autocapture\" {uuid: [UUID], event: $autocapture, properties: Object, timestamp: [TIMESTAMP]}" + }, + { + "event": "$identify", + "properties": { + "$set": "[Object]", + "$set_once": "[Object]" + }, + "fullMessage": "[PostHog.js] send \"$identify\" {uuid: [UUID], event: $identify, properties: Object, $set: Object, $set_once: Object}" + }, + { + "event": "user_logged_in", + "properties": "[Object - see full message for details]", + "fullMessage": "[PostHog.js] send \"user_logged_in\" {uuid: [UUID], event: user_logged_in, properties: Object, timestamp: [TIMESTAMP]}" + } + ] +} \ No newline at end of file