diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3154af4..5600f90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,29 @@ jobs: - run: npm run build - run: npm test + # Playwright browser/UI gate — MANDATORY. + # + # This job drives a real browser through the user-facing flows a + # dashboard change must never silently break: auth guards, the claim + # flow, dashboard render, and — the headline — the payments/upgrade + # journey (checkout success / failure / already-on-plan, invoice + # rendering, the 429 retry hint). The API and Razorpay are + # page.route()-mocked (hermetic — VITE_NO_PROXY=1, playwright.config.ts + # MOCKED mode), so the suite is deterministic and creates nothing. + # + # HARD GATE: a UI regression fails this job. For it to BLOCK a PR, the + # `playwright (mandatory UI gate)` check must be listed as a required + # status check in the branch-protection rule for `main` (Settings → + # Branches → main → Require status checks to pass). Adding the job here + # makes the check available; the operator must tick it as required. + # + # `npm run test:e2e:ci` is the exact local equivalent of the run step — + # run it before pushing a dashboard change. + # + # If you add a dashboard route or change a user-facing flow, add the + # matching e2e/*.spec.ts coverage so this gate keeps protecting it. playwright: + name: playwright (mandatory UI gate) runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -33,4 +55,17 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - - run: VITE_NO_PROXY=1 npx playwright test --project=chromium + # Hermetic mocked run — identical to `npm run test:e2e:ci` locally. + - run: npm run test:e2e:ci + + # Upload the HTML report + traces so a red gate is debuggable + # without a local re-run. Runs even on failure. + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 14 diff --git a/e2e/auth-guards.spec.ts b/e2e/auth-guards.spec.ts new file mode 100644 index 0000000..407a7eb --- /dev/null +++ b/e2e/auth-guards.spec.ts @@ -0,0 +1,60 @@ +/* auth-guards.spec.ts — Chrome-MCP suite S2 (Authentication), automated. + * + * auth.spec.ts already covers login render / 401 reject / valid token / + * OAuth button / signed-in landing. This spec extends S2 to the parts a + * dashboard change is most likely to break silently: + * - EVERY gated /app/* route bounces an unauthenticated visitor to + * /login (a new route added without AuthGate is the classic leak); + * - the deep-linked checkout intent survives the login bounce — the + * user lands back at /app/checkout?plan=pro, not a generic dashboard + * (the funnel-drop the Chrome-MCP S1-F3 finding flagged). + * + * Hermetic: page.route()-mocked. Creates nothing; no teardown. + */ + +import { expect, test } from '@playwright/test' +import { installAPIFake } from './fixtures' + +// Every authenticated surface. A route added to App.tsx without AuthGate +// would let an unauthenticated visitor through — this list is the guard. +const GATED_ROUTES = [ + '/app', + '/app/resources', + '/app/deployments', + '/app/vault', + '/app/team', + '/app/billing', + '/app/settings', + '/app/checkout?plan=pro&frequency=monthly', + '/app/admin/customers', +] + +test.describe('Auth guards — gated routes (S2.6)', () => { + for (const route of GATED_ROUTES) { + test(`unauthenticated visit to ${route} redirects to /login`, async ({ page }) => { + await page.goto(route) + await expect(page).toHaveURL(/\/login(\?.*)?$/) + await expect(page.getByRole('heading', { name: /Sign in/i })).toBeVisible() + }) + } +}) + +test.describe('Checkout intent survives the login bounce (S1.4 / S5)', () => { + test('deep-linked /app/checkout?plan=pro returns there after login', async ({ page }) => { + await installAPIFake(page) + // An unauthenticated user follows a marketing CTA to the Pro checkout. + await page.goto('/app/checkout?plan=pro&frequency=monthly') + // AuthGate bounces to /login, carrying the intent in router state. + await expect(page).toHaveURL(/\/login(\?.*)?$/) + + // Log in with a token. LoginPage reads loc.state.from and navigates + // back to the original checkout deep link — the funnel is preserved. + await page.getByTestId('toggle-token-form').click() + await page.getByTestId('token-input').fill('ink_VALID') + await page.getByTestId('login-submit').click() + + // Lands back on the checkout page for the Pro plan — NOT a generic + // /app dashboard. This is the S1-F3 funnel-drop guard. + await expect(page).toHaveURL(/\/app\/checkout\?plan=pro/) + }) +}) diff --git a/e2e/claim-flow.spec.ts b/e2e/claim-flow.spec.ts new file mode 100644 index 0000000..3323a08 --- /dev/null +++ b/e2e/claim-flow.spec.ts @@ -0,0 +1,194 @@ +/* claim-flow.spec.ts — Chrome-MCP suite S3 (Claim flow), automated. + * + * The claim flow is the anonymous→owned funnel: an agent provisions a + * 24h-TTL resource, the user opens /claim?t=, sees a preview, enters + * an email, and is dropped into the post-claim payment funnel. Before this + * spec there was ZERO Playwright coverage of /claim — a regression in the + * JWT decode, the preview render, the single-use 409 path, or the funnel + * countdown would have shipped silently. + * + * All API calls are page.route()-mocked (CLAUDE.md convention 10) so the + * suite is hermetic — it creates nothing on any backend and needs no + * teardown. The claim JWT is a hand-built unsigned token: ClaimPage only + * base64-decodes the payload client-side to render the preview; the real + * signature check happens server-side on POST /claim, which we mock. + */ + +import { expect, test, type Route } from '@playwright/test' +import { FAKE_RAZORPAY_SHORT_URL, FAKE_TEAM } from './fixtures' + +// buildClaimJWT — assembles an unsigned 3-segment JWT whose payload carries +// the resource-type + token arrays ClaimPage.decodeJWT() reads. The page +// never verifies the signature, so a literal "sig" third segment is fine. +function buildClaimJWT(payload: Record): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.sig` +} + +const VALID_CLAIM_JWT = buildClaimJWT({ + rt: ['postgres', 'redis'], + tok: ['11111111aaaa', '22222222bbbb'], + exp: Math.floor(Date.now() / 1000) + 3600, +}) + +test.describe('Claim flow (S3)', () => { + test('S3.0 — missing token renders the "Missing claim link" guard', async ({ page }) => { + await page.goto('/claim') + await expect(page.getByRole('heading', { name: /missing claim link/i })).toBeVisible() + }) + + test('S3.1 — claim preview shows resource types before claiming', async ({ page }) => { + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) + // The preview card lists each resource decoded from the JWT. + const preview = page.getByTestId('claim-preview') + await expect(preview).toBeVisible() + await expect(preview.getByText('postgres')).toBeVisible() + await expect(preview.getByText('redis')).toBeVisible() + // Email entry form is present — claim hasn't happened yet. + await expect(page.getByTestId('claim-email')).toBeVisible() + }) + + test('S3.5 — malformed/expired token surfaces the invalid-link banner', async ({ page }) => { + // A non-JWT string fails decodeJWT() → previewErr branch. Before §10.21 + // this rendered a blank email form looking like a normal claim. + await page.goto('/claim?t=not-a-real-jwt') + await expect(page.getByTestId('claim-invalid')).toBeVisible() + await expect(page.getByTestId('claim-invalid-error')).toBeVisible() + await expect(page.getByTestId('claim-invalid-pricing')).toBeVisible() + }) + + test('S3.4 — successful claim drops the user into the payment funnel', async ({ page }) => { + // POST /claim → session token. The page then mints a PAT and lists + // resources to drive the countdown. Mock all three. + await page.route('**/claim', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }), + }) + }) + await page.route('**/api/v1/auth/api-keys', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }), + }) + }) + await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => { + if (route.request().method() !== 'GET') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + total: 1, + items: [ + { + id: '11111111-aaaa-bbbb-cccc-000000000001', + token: '11111111-aaaa-bbbb-cccc-000000000001', + resource_type: 'postgres', + name: 'agent-db', + env: 'production', + tier: 'anonymous', + status: 'active', + storage_bytes: 0, + storage_limit_bytes: 10_000_000, + storage_exceeded: false, + connections_in_use: 0, + connections_limit: 2, + created_at: new Date().toISOString(), + team_id: FAKE_TEAM, + // 24h TTL — drives the funnel countdown. + expires_at: new Date(Date.now() + 23 * 3600_000).toISOString(), + }, + ], + }), + }) + }) + + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) + await page.getByTestId('claim-email').fill('founder@example.com') + await page.getByTestId('claim-submit').click() + + // Post-claim funnel: countdown banner + both checkout CTAs. + await expect(page.getByTestId('claim-funnel')).toBeVisible() + await expect(page.getByTestId('claim-countdown')).toBeVisible() + // The countdown shows a real HH:MM:SS, not the "—" no-data placeholder. + await expect(page.getByTestId('claim-countdown-value')).not.toHaveText('—') + await expect(page.getByTestId('claim-checkout-hobby')).toBeVisible() + await expect(page.getByTestId('claim-checkout-pro')).toBeVisible() + }) + + test('S3.3 — single-use claim: a 409 replay surfaces the conflict error', async ({ page }) => { + // POST /claim returns 409 — the JWT was already consumed (atomic + // single-use claim, CLAUDE.md convention 7). + await page.route('**/claim', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ ok: false, error: 'already_claimed', message: 'This claim link was already used.' }), + }) + }) + + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) + await page.getByTestId('claim-email').fill('founder@example.com') + await page.getByTestId('claim-submit').click() + + // The error stage surfaces the 409 message — no crash, no funnel. + await expect(page.getByTestId('claim-error')).toBeVisible() + await expect(page.getByTestId('claim-error')).toContainText(/already used/i) + }) + + test('S3.6 — funnel "Keep my resources" CTA opens Razorpay checkout', async ({ page }) => { + await page.route('**/claim', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }), + }) + }) + await page.route('**/api/v1/auth/api-keys', (route: Route) => + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }), + }), + ) + await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, total: 0, items: [] }), + }), + ) + // The checkout call returns a Razorpay short_url. We intercept the + // navigation to the mock URL so the test stays hermetic — it asserts + // the redirect was attempted without ever loading rzp.io. + let navigatedTo: string | null = null + await page.route('**/api/v1/billing/checkout', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, short_url: FAKE_RAZORPAY_SHORT_URL }), + }) + }) + await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => { + navigatedTo = route.request().url() + return route.fulfill({ status: 200, contentType: 'text/html', body: 'razorpay stub' }) + }) + + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) + await page.getByTestId('claim-email').fill('founder@example.com') + await page.getByTestId('claim-submit').click() + await expect(page.getByTestId('claim-funnel')).toBeVisible() + + await page.getByTestId('claim-checkout-hobby').click() + await expect.poll(() => navigatedTo).toContain('rzp.io') + }) +}) diff --git a/e2e/dashboard-trust.spec.ts b/e2e/dashboard-trust.spec.ts new file mode 100644 index 0000000..b8cfedd --- /dev/null +++ b/e2e/dashboard-trust.spec.ts @@ -0,0 +1,156 @@ +/* dashboard-trust.spec.ts — Chrome-MCP suites S4 (Dashboard) + S8 (BugBash + * regression), automated. + * + * Covers the user-facing trust surfaces a dashboard change must not + * silently break: + * - the dashboard renders for an authenticated user (S4.1); + * - the deployment count agrees between the Overview tile and the + * Billing usage panel — the S5-F4 "0 deployments vs 1/1" drift; + * - the 429 rate-limit path renders a human "retry in Ns" hint instead + * of a bare error (S4.8 / S8.9, the BugBash PR #97/#98 fix); + * - empty states render a friendly message, not a blank page (S4.7). + * + * Hermetic: every route is page.route()-mocked. Nothing is created on any + * backend; no teardown required. + */ + +import { expect, test, type Route } from '@playwright/test' +import { + FAKE_TEAM, + FAKE_USER, + installAPIFake, + installBillingAPIFake, + signIn, +} from './fixtures' + +// One running deployment — both the Overview tile (GET /api/v1/deployments) +// and the Billing usage panel (GET /api/v1/billing/usage → deployments +// count = 1, seeded in installBillingAPIFake) must agree on this number. +const ONE_DEPLOYMENT = { + ok: true, + total: 1, + items: [ + { + id: 'dep_1', + name: 'agent-app', + status: 'running', + env: 'production', + url: 'https://agent-app.deployment.instanode.dev', + created_at: '2026-05-18T00:00:00Z', + team_id: FAKE_TEAM, + }, + ], +} + +test.describe('Dashboard render + trust surfaces (S4)', () => { + test('S4.1 — authenticated user lands on the Overview, no blank page', async ({ page }) => { + await signIn(page) + await installAPIFake(page) + await page.goto('/app') + await expect(page.getByRole('heading', { level: 1, name: /Overview/ })).toBeVisible() + }) + + test('S5-F4 — deployment count agrees between Overview tile and Billing panel', async ({ page }) => { + await signIn(page) + await installAPIFake(page) + await installBillingAPIFake(page) + // Both surfaces source deployments from GET /api/v1/deployments-derived + // data; seed one running deployment so each must show "1". + await page.route(/\/api\/v1\/deployments(\?[^/]*)?$/, (route: Route) => { + if (route.request().method() !== 'GET') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(ONE_DEPLOYMENT), + }) + }) + + // Overview tile. + await page.goto('/app') + const deployStat = page.locator('.stat', { has: page.locator('.k', { hasText: /^deployments$/ }) }) + await expect(deployStat.locator('.v')).toHaveText('1') + + // Billing usage panel — the deployments UsageRow. installBillingAPIFake + // seeds /billing/usage with deployments.count = 1; both must agree. + await page.goto('/app/billing') + const deployRow = page.locator('.usage-row', { has: page.locator('.k', { hasText: /^deployments$/ }) }) + await expect(deployRow.locator('.num')).toContainText('1 /') + }) + + test('S4.8 / S8.9 — a 429 renders the "retry in Ns" hint, not a hard error', async ({ page }) => { + await signIn(page) + await installAPIFake(page) + // The TeamPage 429 banner is the user-facing retry-hint surface + // (src/pages/TeamPage.tsx). listMembers() falls back to /auth/me on a + // non-401 failure, so to actually surface the rate-limit banner we must + // 429 BOTH /team/members and the /auth/me fallback. The 30-second + // Retry-After header drives the human countdown copy. + const fulfil429 = (route: Route) => + route.fulfill({ + status: 429, + contentType: 'application/json', + headers: { 'Retry-After': '30' }, + body: JSON.stringify({ ok: false, error: 'rate_limited', message: 'Too many requests.' }), + }) + await page.route('**/api/v1/team/members', fulfil429) + // /auth/me must succeed for AuthGate to mount the page, then 429 on the + // members fetch fallback. We let the FIRST /auth/me through (AuthGate) + // and 429 the members route — listMembers' fallback re-calls fetchMe, + // which we keep succeeding, so the fallback path would mask the 429. + // To force the banner, 429 /team/members AND make the fallback fetchMe + // also 429 by counting calls. + let meCalls = 0 + await page.route('**/auth/me', (route: Route) => { + meCalls += 1 + // First call: AuthGate boot — succeed. Subsequent calls: the + // listMembers() catch-path fallback — 429 so the rate-limit banner + // wins instead of the single-owner fallback row. + if (meCalls === 1) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + user_id: FAKE_USER, + team_id: FAKE_TEAM, + email: 'aanya@example.com', + tier: 'hobby', + }), + }) + } + return fulfil429(route) + }) + + await page.goto('/app/team') + const banner = page.getByRole('alert') + await expect(banner).toBeVisible() + await expect(banner).toContainText(/rate-limited/i) + // The human retry hint — "retry in 30 seconds" — not a bare error code. + await expect(banner).toContainText(/retry in 30 second/i) + }) + + test('S4.7 — empty resources list renders a friendly empty state', async ({ page }) => { + await signIn(page) + await installAPIFake(page) + // Override /resources with an empty list AFTER installAPIFake so this + // route wins (Playwright matches most-recent-first). + await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => { + if (route.request().method() !== 'GET') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, items: [], total: 0 }), + }) + }) + await page.route(/\/api\/v1\/deployments(\?[^/]*)?$/, (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, items: [], total: 0 }), + }), + ) + await page.goto('/app') + // The "recently active" table renders its empty-state row, not a crash. + await expect(page.getByTestId('recently-active-empty')).toBeVisible() + }) +}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 8f127ea..40e0f26 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -397,3 +397,222 @@ export async function signIn(page: Page, token = 'ink_FAKE_TEST_TOKEN') { localStorage.setItem('instanode.token', tok) }, token) } + +// ─── Billing / payments fixtures ──────────────────────────────────────── +// +// These back the upgrade-journey + billing-page Playwright specs. The +// dashboard never talks to Razorpay directly — it calls the agent API's +// /api/v1/billing/* endpoints, which return a Razorpay-hosted `short_url`. +// So a hermetic test mocks the agent API responses and asserts the +// dashboard navigates to the mock short_url; it never loads Razorpay's +// real page (that stays in the manual Chrome-MCP layer, S5). + +/** Razorpay-style hosted checkout URL the mocked /billing/checkout returns. + * Tests assert the dashboard navigates here — they do NOT load it. */ +export const FAKE_RAZORPAY_SHORT_URL = 'https://rzp.io/i/FAKEcheckout123' + +/** GET /api/v1/billing payload — a hobby team with an active subscription + * and a saved card. Shaped to api/internal/handlers/billing.go's + * BillingStateResp wire contract. */ +export const FAKE_BILLING_STATE = { + ok: true, + tier: 'hobby', + subscription_status: 'active' as const, + next_renewal_at: '2026-06-19T00:00:00Z', + amount_inr: 90000, + payment_method: { type: 'card' as const, brand: 'visa', last4: '4242' }, + billing_email: 'aanya@example.com', + razorpay_subscription_id: 'sub_FAKE123', + razorpay_customer_id: 'cust_FAKE123', + razorpay_configured: true, +} + +/** GET /api/v1/billing/usage payload — server-side cached aggregate. */ +export const FAKE_BILLING_USAGE = { + ok: true, + freshness_seconds: 30, + as_of: new Date(Date.now() - 12_000).toISOString(), + usage: { + postgres: { bytes: 47_500_000, limit_bytes: 1_073_741_824 }, + redis: { bytes: 1_200_000, limit_bytes: 52_428_800 }, + mongodb: { bytes: 800_000, limit_bytes: 104_857_600 }, + // Deployment count — the canonical source. The Overview tile reads + // GET /api/v1/deployments (a list); both must agree (S5-F4 regression). + deployments: { count: 1, limit: 1 }, + webhooks: { count: 12, limit: 1000 }, + vault: { count: 2, limit: 100 }, + members: { count: 1, limit: 1 }, + }, +} + +/** GET /api/v1/billing/invoices payload — exercises the null/pending + * field paths that produced "Invalid Date" / "$NaN" before mapInvoice() + * was hardened (S5-F3 regression). One paid row, one pending row with a + * zero amount, one row with no pdf_url. */ +export const FAKE_INVOICES_WIRE = { + ok: true, + invoices: [ + { + id: 'inv_PAID001', + amount: 4900, // cents — $49.00 + currency: 'USD', + status: 'paid', + date: '2026-05-19T10:00:00Z', + pdf_url: 'https://rzp.io/invoice/inv_PAID001.pdf', + }, + { + // Pending invoice: zero amount, no pdf — must NOT render "$NaN" or a + // dead "↓ pdf" link. + id: 'inv_PENDING002', + amount: 0, + currency: 'USD', + status: 'pending', + date: '2026-05-19T11:00:00Z', + }, + { + // Unknown status from Razorpay ('issued') — collapses to 'pending'. + id: 'inv_ISSUED003', + amount: 900, + currency: 'USD', + status: 'issued', + date: '2026-05-19T12:00:00Z', + }, + ], +} + +/** + * installBillingAPIFake — layers the billing endpoints on top of + * installAPIFake(). Call AFTER installAPIFake() so the /auth/me tier here + * (hobby — has an active subscription, can upgrade) wins. + * + * Per-test overrides: register a more specific page.route() AFTER this for + * checkout success/failure/already-on-plan variants — Playwright matches + * routes most-recent-first. + */ +export async function installBillingAPIFake(page: Page) { + await page.route('**/api/v1/billing', (route: Route) => { + if (route.request().method() !== 'GET') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(FAKE_BILLING_STATE), + }) + }) + await page.route('**/api/v1/billing/usage', (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(FAKE_BILLING_USAGE), + }), + ) + await page.route('**/api/v1/billing/invoices', (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(FAKE_INVOICES_WIRE), + }), + ) + // /auth/me — hobby tier so the upgrade CTAs render. installAPIFake() + // already routes this to hobby, but we re-assert here so a caller can + // installBillingAPIFake() standalone. + await page.route('**/auth/me', (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + user_id: FAKE_USER, + team_id: FAKE_TEAM, + email: 'aanya@example.com', + tier: 'hobby', + trial_ends_at: null, + }), + }), + ) +} + +/** mockCheckoutSuccess — POST /api/v1/billing/checkout returns a Razorpay + * short_url. The dashboard navigates the browser to it. */ +export async function mockCheckoutSuccess(page: Page, shortUrl = FAKE_RAZORPAY_SHORT_URL) { + await page.route('**/api/v1/billing/checkout', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, short_url: shortUrl, subscription_id: 'sub_NEW999' }), + }) + }) +} + +/** mockCheckoutFailure — POST /api/v1/billing/checkout fails. `kind` + * selects which honest failure path the dashboard must render: + * - 'billing_not_configured' → 503, CheckoutPage shows the fallback panel + * - 'already_on_plan' → 409, the user already holds this plan + * - 'generic' → 500, inline error banner */ +export async function mockCheckoutFailure( + page: Page, + kind: 'billing_not_configured' | 'already_on_plan' | 'generic' = 'generic', +) { + const spec = { + billing_not_configured: { + status: 503, + body: { ok: false, error: 'billing_not_configured', message: 'Razorpay plan not configured for this tier.' }, + }, + already_on_plan: { + status: 409, + body: { ok: false, error: 'already_on_plan', message: 'Your team is already on this plan.' }, + }, + generic: { + status: 500, + body: { ok: false, error: 'internal', message: 'Checkout could not be created.' }, + }, + }[kind] + await page.route('**/api/v1/billing/checkout', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + return route.fulfill({ + status: spec.status, + contentType: 'application/json', + body: JSON.stringify(spec.body), + }) + }) +} + +/** mockChangePlan — POST /api/v1/billing/change-plan. `mode`: + * - 'immediate' → ok with no short_url; modal shows "Plan changed ✓" + * - 'short_url' → ok with a short_url; dashboard redirects to Razorpay + * - 'already_on_plan'→ 409; modal surfaces the error inline + * - 'server_error' → 500; modal shows error + Contact-support fallback */ +export async function mockChangePlan( + page: Page, + mode: 'immediate' | 'short_url' | 'already_on_plan' | 'server_error' = 'immediate', +) { + await page.route('**/api/v1/billing/change-plan', (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + if (mode === 'immediate') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, new_plan: 'pro', effective_date: '2026-05-19T00:00:00Z' }), + }) + } + if (mode === 'short_url') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, new_plan: 'pro', short_url: FAKE_RAZORPAY_SHORT_URL }), + }) + } + if (mode === 'already_on_plan') { + return route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ ok: false, error: 'already_on_plan', message: 'Your team is already on this plan.' }), + }) + } + return route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ ok: false, error: 'internal', message: 'Plan change failed upstream.' }), + }) + }) +} diff --git a/e2e/upgrade-journey.spec.ts b/e2e/upgrade-journey.spec.ts new file mode 100644 index 0000000..d0830b5 --- /dev/null +++ b/e2e/upgrade-journey.spec.ts @@ -0,0 +1,240 @@ +/* upgrade-journey.spec.ts — Chrome-MCP suite S5 (Payments & Upgrade), + * automated as the HEADLINE coverage of this gate. + * + * S5 is the only money path in the product. Before this spec, Playwright + * had ZERO automated coverage of it — every checkout regression (a broken + * createCheckout call, a dropped short_url redirect, an invoice render + * crash, a mis-handled 409) shipped on manual Chrome-MCP spot-checks alone. + * The Chrome-MCP S5 run is itself BLOCKED in production (the Razorpay + * account isn't recurring-enabled — S5-F1), so the automated layer here is + * the *only* gate standing between a dashboard change and a silently + * broken payment funnel. + * + * What a hermetic Playwright spec CAN cover (and does, below): + * - the dashboard's half of the checkout contract: it calls + * POST /api/v1/billing/checkout and navigates to the returned + * Razorpay short_url; + * - every honest failure branch — 503 billing_not_configured, + * 409 already_on_plan, 500 generic — renders a real error, not a + * blank page or a spinner; + * - the in-dashboard Change-plan modal: immediate success, short_url + * redirect, already_on_plan 409, and 5xx → Contact-support fallback; + * - invoice rendering with paid / pending-zero / unknown-status rows + * (the S5-F3 "Invalid Date / $NaN" regression). + * + * What it CANNOT cover (stays manual Chrome-MCP, S5): the real Razorpay + * hosted page, 3DS/OTP, card decline, the webhook→tier-elevation round + * trip. Those need a live recurring-enabled Razorpay account. + * + * Hermetic: every route is page.route()-mocked. The Razorpay short_url is + * a fake (rzp.io/i/FAKE...) and is itself intercepted — the browser never + * loads a real checkout page. Nothing is created on any backend; no + * teardown required. + */ + +import { expect, test, type Route } from '@playwright/test' +import { + FAKE_RAZORPAY_SHORT_URL, + installAPIFake, + installBillingAPIFake, + mockChangePlan, + mockCheckoutFailure, + mockCheckoutSuccess, + signIn, +} from './fixtures' + +test.describe('Upgrade journey — Billing page (S5)', () => { + test.beforeEach(async ({ page }) => { + await signIn(page) + await installAPIFake(page) + await installBillingAPIFake(page) + }) + + test('S5.0 — Billing page renders current plan + usage + invoices', async ({ page }) => { + await page.goto('/app/billing') + // The upgrade grid renders (hobby team → can pick a higher tier). + await expect(page.getByTestId('billing-upgrade-section')).toBeVisible() + await expect(page.getByText(/You're on Hobby today/i)).toBeVisible() + // Usage panel reflects the server-cached aggregate, with the + // eventual-consistency footnote (caching+consistency memory). + await expect(page.getByTestId('billing-usage-as-of')).toBeVisible() + }) + + test('S5.1 — "Get Pro" → POST /billing/checkout → navigate to Razorpay', async ({ page }) => { + await mockCheckoutSuccess(page) + // Intercept the Razorpay short_url so the redirect is observable + // without the browser ever loading rzp.io. + let navigatedTo: string | null = null + await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => { + navigatedTo = route.request().url() + return route.fulfill({ status: 200, contentType: 'text/html', body: 'rzp stub' }) + }) + + await page.goto('/app/billing') + // The Pro CTA composes with the A/B UpgradeButton — testid `upgrade-button`. + await page.getByTestId('upgrade-button').click() + await expect.poll(() => navigatedTo).toContain('rzp.io') + }) + + test('S5.6 — checkout 500 surfaces an inline error, not a blank page', async ({ page }) => { + await mockCheckoutFailure(page, 'generic') + await page.goto('/app/billing') + await page.getByTestId('upgrade-button').click() + // The BillingPage catches the APIError into checkoutErr → checkout-error. + await expect(page.getByTestId('checkout-error')).toBeVisible() + await expect(page.getByTestId('checkout-error')).toContainText(/checkout could not be created/i) + }) + + test('S5.6b — already_on_plan 409 surfaces the conflict, no redirect', async ({ page }) => { + // already_on_plan / checkout-reuse: the user clicks upgrade for a plan + // their team already holds. The dashboard must surface the 409 honestly + // and must NOT navigate anywhere. + await mockCheckoutFailure(page, 'already_on_plan') + let navigated = false + await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => { + navigated = true + return route.fulfill({ status: 200, contentType: 'text/html', body: 'x' }) + }) + await page.goto('/app/billing') + await page.getByTestId('upgrade-button').click() + await expect(page.getByTestId('checkout-error')).toBeVisible() + await expect(page.getByTestId('checkout-error')).toContainText(/already on this plan/i) + expect(navigated).toBe(false) + // The page stays on /app/billing — no half-completed navigation. + await expect(page).toHaveURL(/\/app\/billing$/) + }) + + test('S5.3 — invoices render paid / pending-zero / unknown-status rows cleanly', async ({ page }) => { + // S5-F3 regression: a Razorpay invoice payload with a zero amount, a + // missing pdf_url, or an unknown status used to render "Invalid Date" + // and "$NaN". installBillingAPIFake() seeds exactly those shapes. + await page.goto('/app/billing') + await expect(page.getByText('inv_PAID001')).toBeVisible() + await expect(page.getByText('inv_PENDING002')).toBeVisible() + await expect(page.getByText('inv_ISSUED003')).toBeVisible() + // No row renders the broken parser output. + await expect(page.getByText('Invalid Date')).toHaveCount(0) + await expect(page.getByText('$NaN')).toHaveCount(0) + // The paid row shows a real dollar amount; the pending-zero row shows $0.00. + await expect(page.getByText('$49.00')).toBeVisible() + await expect(page.getByText('$0.00')).toBeVisible() + // The unknown 'issued' status collapsed to a neutral 'pending' — the + // status column never renders the raw upstream string. Scope the + // assertion to the inv_ISSUED003 row's status cell (its id literally + // contains "issued", so a page-wide getByText would false-positive). + const issuedRow = page.locator('.invoice-row', { hasText: 'inv_ISSUED003' }) + await expect(issuedRow.getByText('pending', { exact: true })).toBeVisible() + await expect(issuedRow.getByText(/^issued$/)).toHaveCount(0) + }) + + test('S5.7 — Annual frequency toggle persists and re-anchors the Pro price', async ({ page }) => { + await page.goto('/app/billing') + // Annual is the default (billing redesign 2026-05-13). + await expect(page.getByTestId('frequency-yearly')).toBeChecked() + // Switching to monthly re-anchors the Pro CTA copy to the monthly price. + await page.getByTestId('frequency-monthly').click() + await expect(page.getByTestId('upgrade-button')).toContainText('$49/mo') + // Switch back to annual — the anchored monthly-equivalent copy returns. + await page.getByTestId('frequency-yearly').click() + await expect(page.getByTestId('upgrade-button')).toContainText('$40.83/mo') + }) + + test('S5-cancel — no self-serve cancel; only a support mailto', async ({ page }) => { + // Policy memory project_no_self_serve_cancel_downgrade.md — the + // dashboard must never offer a self-serve cancel button. + await page.goto('/app/billing') + const cancelLink = page.getByTestId('contact-support-cancel') + await expect(cancelLink).toBeVisible() + expect(await cancelLink.getAttribute('href')).toMatch(/^mailto:support@instanode\.dev/) + }) +}) + +test.describe('Change-plan modal (S5 — in-dashboard upgrade)', () => { + test.beforeEach(async ({ page }) => { + await signIn(page) + await installAPIFake(page) + await installBillingAPIFake(page) + }) + + test('S5.4 — immediate change shows "Plan changed ✓"', async ({ page }) => { + await mockChangePlan(page, 'immediate') + await page.goto('/app/billing') + // hobby team → the "Change plan" button is shown (has an upgrade target). + await page.getByTestId('open-change-plan-modal').click() + await expect(page.getByTestId('change-plan-modal')).toBeVisible() + await page.getByTestId('change-plan-confirm').click() + await expect(page.getByTestId('change-plan-success')).toBeVisible() + }) + + test('S5.4b — change-plan short_url path redirects to Razorpay', async ({ page }) => { + await mockChangePlan(page, 'short_url') + let navigatedTo: string | null = null + await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => { + navigatedTo = route.request().url() + return route.fulfill({ status: 200, contentType: 'text/html', body: 'x' }) + }) + await page.goto('/app/billing') + await page.getByTestId('open-change-plan-modal').click() + await page.getByTestId('change-plan-confirm').click() + await expect.poll(() => navigatedTo).toContain('rzp.io') + }) + + test('S5.4c — already_on_plan 409 surfaces inline in the modal', async ({ page }) => { + await mockChangePlan(page, 'already_on_plan') + await page.goto('/app/billing') + await page.getByTestId('open-change-plan-modal').click() + await page.getByTestId('change-plan-confirm').click() + await expect(page.getByTestId('change-plan-error')).toBeVisible() + await expect(page.getByTestId('change-plan-error')).toContainText(/already on this plan/i) + // A 4xx is the user's to fix — no Contact-support fallback for it. + await expect(page.getByTestId('change-plan-support-fallback')).toHaveCount(0) + }) + + test('S5.4d — 5xx change-plan failure offers the Contact-support fallback', async ({ page }) => { + await mockChangePlan(page, 'server_error') + await page.goto('/app/billing') + await page.getByTestId('open-change-plan-modal').click() + await page.getByTestId('change-plan-confirm').click() + await expect(page.getByTestId('change-plan-error')).toBeVisible() + // 5xx → the support escalation link appears. + await expect(page.getByTestId('change-plan-support-fallback')).toBeVisible() + }) +}) + +test.describe('Checkout page — marketing-funnel deep link (S1.4 / S5.1)', () => { + test.beforeEach(async ({ page }) => { + await signIn(page) + await installAPIFake(page) + await installBillingAPIFake(page) + }) + + test('S5.1b — /app/checkout?plan=pro creates a session and redirects', async ({ page }) => { + await mockCheckoutSuccess(page) + let navigatedTo: string | null = null + await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => { + navigatedTo = route.request().url() + return route.fulfill({ status: 200, contentType: 'text/html', body: 'x' }) + }) + // CheckoutPage POSTs /billing/checkout on mount and immediately calls + // window.location.assign(short_url) — so we assert the redirect fired + // rather than the (racy) checkout-page mount being visible. + await page.goto('/app/checkout?plan=pro&frequency=monthly') + await expect.poll(() => navigatedTo).toContain('rzp.io') + }) + + test('S5.x — /app/checkout with a bad plan renders the invalid-link panel', async ({ page }) => { + await page.goto('/app/checkout?plan=banana&frequency=monthly') + await expect(page.getByTestId('checkout-invalid')).toBeVisible() + await expect(page.getByTestId('checkout-invalid')).toContainText(/unknown plan/i) + }) + + test('S5.x — /app/checkout 503 billing_not_configured renders the fallback', async ({ page }) => { + // 503 billing_not_configured is the documented path before the operator + // wires the Razorpay plan_id. CheckoutPage must show the friendly + // fallback panel, not a raw error. + await mockCheckoutFailure(page, 'billing_not_configured') + await page.goto('/app/checkout?plan=pro&frequency=monthly') + await expect(page.getByTestId('checkout-fallback')).toBeVisible() + await expect(page.getByTestId('checkout-fallback')).toContainText(/not yet configured/i) + }) +}) diff --git a/package.json b/package.json index b31343e..273b1cc 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:ci": "VITE_NO_PROXY=1 playwright test --project=chromium", "test:e2e:ui": "playwright test --ui", "deploy:pages": "npm run build && echo 'Built to dist/. dist/404.html is the SPA shell (written by prerender.mjs Step 4.7). Push to main to deploy.'" },