Skip to content

Commit 93f784b

Browse files
test(e2e): make Playwright browser/UI testing a mandatory CI gate
A dashboard change could previously ship having broken login, claim, the dashboard, or the payments/upgrade flow — the Playwright suite covered none of those money-critical paths. This makes browser/UI testing a hard, hermetic gate so a UI regression fails CI before it can merge. Coverage closed (vs the Chrome-MCP S1–S8 test plan): - S5 Payments (headline): upgrade-journey.spec.ts — checkout success → Razorpay redirect, checkout failure (500 / 503 billing_not_configured / 409 already_on_plan), the Change-plan modal (immediate / short_url / already_on_plan / 5xx support-fallback), invoice rendering with null/pending/unknown-status rows (the "$NaN / Invalid Date" regression), the Annual toggle, and the no-self-serve-cancel policy. - S3 Claim: claim-flow.spec.ts — preview, single-use 409, post-claim payment funnel + countdown, expired token, funnel→Razorpay CTA. - S4 Dashboard: dashboard-trust.spec.ts — render, empty state, the 429 retry-hint UI, and Overview-tile ⇄ Billing-panel deployment-count consistency (the S5-F4 drift). - S2 Auth: auth-guards.spec.ts — every /app/* route bounces unauthed to /login, and the checkout deep-link intent survives the login bounce. All API + Razorpay traffic is page.route()-mocked (VITE_NO_PROXY=1) so the suite is hermetic and deterministic — it creates nothing on any backend and needs no teardown. The Razorpay short_url is a fake and is itself intercepted; the browser never loads a real checkout page. CI gate: the `playwright` job is renamed to `playwright (mandatory UI gate)`, runs `npm run test:e2e:ci` (the exact local equivalent), and uploads the HTML report + traces as an artifact on failure. For the gate to BLOCK a PR, the operator must add this check to the branch-protection rule for `main`. Suite: 23 → 57 Playwright tests. tsc/build/vitest unaffected (662 pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dcbab30 commit 93f784b

7 files changed

Lines changed: 906 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,29 @@ jobs:
2121
- run: npm run build
2222
- run: npm test
2323

24+
# Playwright browser/UI gate — MANDATORY.
25+
#
26+
# This job drives a real browser through the user-facing flows a
27+
# dashboard change must never silently break: auth guards, the claim
28+
# flow, dashboard render, and — the headline — the payments/upgrade
29+
# journey (checkout success / failure / already-on-plan, invoice
30+
# rendering, the 429 retry hint). The API and Razorpay are
31+
# page.route()-mocked (hermetic — VITE_NO_PROXY=1, playwright.config.ts
32+
# MOCKED mode), so the suite is deterministic and creates nothing.
33+
#
34+
# HARD GATE: a UI regression fails this job. For it to BLOCK a PR, the
35+
# `playwright (mandatory UI gate)` check must be listed as a required
36+
# status check in the branch-protection rule for `main` (Settings →
37+
# Branches → main → Require status checks to pass). Adding the job here
38+
# makes the check available; the operator must tick it as required.
39+
#
40+
# `npm run test:e2e:ci` is the exact local equivalent of the run step —
41+
# run it before pushing a dashboard change.
42+
#
43+
# If you add a dashboard route or change a user-facing flow, add the
44+
# matching e2e/*.spec.ts coverage so this gate keeps protecting it.
2445
playwright:
46+
name: playwright (mandatory UI gate)
2547
runs-on: ubuntu-latest
2648
steps:
2749
- uses: actions/checkout@v5
@@ -33,4 +55,17 @@ jobs:
3355

3456
- run: npm ci
3557
- run: npx playwright install --with-deps chromium
36-
- run: VITE_NO_PROXY=1 npx playwright test --project=chromium
58+
# Hermetic mocked run — identical to `npm run test:e2e:ci` locally.
59+
- run: npm run test:e2e:ci
60+
61+
# Upload the HTML report + traces so a red gate is debuggable
62+
# without a local re-run. Runs even on failure.
63+
- name: Upload Playwright report
64+
if: ${{ !cancelled() }}
65+
uses: actions/upload-artifact@v4
66+
with:
67+
name: playwright-report
68+
path: |
69+
playwright-report/
70+
test-results/
71+
retention-days: 14

e2e/auth-guards.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* auth-guards.spec.ts — Chrome-MCP suite S2 (Authentication), automated.
2+
*
3+
* auth.spec.ts already covers login render / 401 reject / valid token /
4+
* OAuth button / signed-in landing. This spec extends S2 to the parts a
5+
* dashboard change is most likely to break silently:
6+
* - EVERY gated /app/* route bounces an unauthenticated visitor to
7+
* /login (a new route added without AuthGate is the classic leak);
8+
* - the deep-linked checkout intent survives the login bounce — the
9+
* user lands back at /app/checkout?plan=pro, not a generic dashboard
10+
* (the funnel-drop the Chrome-MCP S1-F3 finding flagged).
11+
*
12+
* Hermetic: page.route()-mocked. Creates nothing; no teardown.
13+
*/
14+
15+
import { expect, test } from '@playwright/test'
16+
import { installAPIFake } from './fixtures'
17+
18+
// Every authenticated surface. A route added to App.tsx without AuthGate
19+
// would let an unauthenticated visitor through — this list is the guard.
20+
const GATED_ROUTES = [
21+
'/app',
22+
'/app/resources',
23+
'/app/deployments',
24+
'/app/vault',
25+
'/app/team',
26+
'/app/billing',
27+
'/app/settings',
28+
'/app/checkout?plan=pro&frequency=monthly',
29+
'/app/admin/customers',
30+
]
31+
32+
test.describe('Auth guards — gated routes (S2.6)', () => {
33+
for (const route of GATED_ROUTES) {
34+
test(`unauthenticated visit to ${route} redirects to /login`, async ({ page }) => {
35+
await page.goto(route)
36+
await expect(page).toHaveURL(/\/login(\?.*)?$/)
37+
await expect(page.getByRole('heading', { name: /Sign in/i })).toBeVisible()
38+
})
39+
}
40+
})
41+
42+
test.describe('Checkout intent survives the login bounce (S1.4 / S5)', () => {
43+
test('deep-linked /app/checkout?plan=pro returns there after login', async ({ page }) => {
44+
await installAPIFake(page)
45+
// An unauthenticated user follows a marketing CTA to the Pro checkout.
46+
await page.goto('/app/checkout?plan=pro&frequency=monthly')
47+
// AuthGate bounces to /login, carrying the intent in router state.
48+
await expect(page).toHaveURL(/\/login(\?.*)?$/)
49+
50+
// Log in with a token. LoginPage reads loc.state.from and navigates
51+
// back to the original checkout deep link — the funnel is preserved.
52+
await page.getByTestId('toggle-token-form').click()
53+
await page.getByTestId('token-input').fill('ink_VALID')
54+
await page.getByTestId('login-submit').click()
55+
56+
// Lands back on the checkout page for the Pro plan — NOT a generic
57+
// /app dashboard. This is the S1-F3 funnel-drop guard.
58+
await expect(page).toHaveURL(/\/app\/checkout\?plan=pro/)
59+
})
60+
})

e2e/claim-flow.spec.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/* claim-flow.spec.ts — Chrome-MCP suite S3 (Claim flow), automated.
2+
*
3+
* The claim flow is the anonymous→owned funnel: an agent provisions a
4+
* 24h-TTL resource, the user opens /claim?t=<jwt>, sees a preview, enters
5+
* an email, and is dropped into the post-claim payment funnel. Before this
6+
* spec there was ZERO Playwright coverage of /claim — a regression in the
7+
* JWT decode, the preview render, the single-use 409 path, or the funnel
8+
* countdown would have shipped silently.
9+
*
10+
* All API calls are page.route()-mocked (CLAUDE.md convention 10) so the
11+
* suite is hermetic — it creates nothing on any backend and needs no
12+
* teardown. The claim JWT is a hand-built unsigned token: ClaimPage only
13+
* base64-decodes the payload client-side to render the preview; the real
14+
* signature check happens server-side on POST /claim, which we mock.
15+
*/
16+
17+
import { expect, test, type Route } from '@playwright/test'
18+
import { FAKE_RAZORPAY_SHORT_URL, FAKE_TEAM } from './fixtures'
19+
20+
// buildClaimJWT — assembles an unsigned 3-segment JWT whose payload carries
21+
// the resource-type + token arrays ClaimPage.decodeJWT() reads. The page
22+
// never verifies the signature, so a literal "sig" third segment is fine.
23+
function buildClaimJWT(payload: Record<string, unknown>): string {
24+
const b64 = (o: unknown) =>
25+
Buffer.from(JSON.stringify(o)).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
26+
return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.sig`
27+
}
28+
29+
const VALID_CLAIM_JWT = buildClaimJWT({
30+
rt: ['postgres', 'redis'],
31+
tok: ['11111111aaaa', '22222222bbbb'],
32+
exp: Math.floor(Date.now() / 1000) + 3600,
33+
})
34+
35+
test.describe('Claim flow (S3)', () => {
36+
test('S3.0 — missing token renders the "Missing claim link" guard', async ({ page }) => {
37+
await page.goto('/claim')
38+
await expect(page.getByRole('heading', { name: /missing claim link/i })).toBeVisible()
39+
})
40+
41+
test('S3.1 — claim preview shows resource types before claiming', async ({ page }) => {
42+
await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
43+
// The preview card lists each resource decoded from the JWT.
44+
const preview = page.getByTestId('claim-preview')
45+
await expect(preview).toBeVisible()
46+
await expect(preview.getByText('postgres')).toBeVisible()
47+
await expect(preview.getByText('redis')).toBeVisible()
48+
// Email entry form is present — claim hasn't happened yet.
49+
await expect(page.getByTestId('claim-email')).toBeVisible()
50+
})
51+
52+
test('S3.5 — malformed/expired token surfaces the invalid-link banner', async ({ page }) => {
53+
// A non-JWT string fails decodeJWT() → previewErr branch. Before §10.21
54+
// this rendered a blank email form looking like a normal claim.
55+
await page.goto('/claim?t=not-a-real-jwt')
56+
await expect(page.getByTestId('claim-invalid')).toBeVisible()
57+
await expect(page.getByTestId('claim-invalid-error')).toBeVisible()
58+
await expect(page.getByTestId('claim-invalid-pricing')).toBeVisible()
59+
})
60+
61+
test('S3.4 — successful claim drops the user into the payment funnel', async ({ page }) => {
62+
// POST /claim → session token. The page then mints a PAT and lists
63+
// resources to drive the countdown. Mock all three.
64+
await page.route('**/claim', (route: Route) => {
65+
if (route.request().method() !== 'POST') return route.continue()
66+
return route.fulfill({
67+
status: 200,
68+
contentType: 'application/json',
69+
body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }),
70+
})
71+
})
72+
await page.route('**/api/v1/auth/api-keys', (route: Route) => {
73+
if (route.request().method() !== 'POST') return route.continue()
74+
return route.fulfill({
75+
status: 201,
76+
contentType: 'application/json',
77+
body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }),
78+
})
79+
})
80+
await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => {
81+
if (route.request().method() !== 'GET') return route.continue()
82+
return route.fulfill({
83+
status: 200,
84+
contentType: 'application/json',
85+
body: JSON.stringify({
86+
ok: true,
87+
total: 1,
88+
items: [
89+
{
90+
id: '11111111-aaaa-bbbb-cccc-000000000001',
91+
token: '11111111-aaaa-bbbb-cccc-000000000001',
92+
resource_type: 'postgres',
93+
name: 'agent-db',
94+
env: 'production',
95+
tier: 'anonymous',
96+
status: 'active',
97+
storage_bytes: 0,
98+
storage_limit_bytes: 10_000_000,
99+
storage_exceeded: false,
100+
connections_in_use: 0,
101+
connections_limit: 2,
102+
created_at: new Date().toISOString(),
103+
team_id: FAKE_TEAM,
104+
// 24h TTL — drives the funnel countdown.
105+
expires_at: new Date(Date.now() + 23 * 3600_000).toISOString(),
106+
},
107+
],
108+
}),
109+
})
110+
})
111+
112+
await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
113+
await page.getByTestId('claim-email').fill('founder@example.com')
114+
await page.getByTestId('claim-submit').click()
115+
116+
// Post-claim funnel: countdown banner + both checkout CTAs.
117+
await expect(page.getByTestId('claim-funnel')).toBeVisible()
118+
await expect(page.getByTestId('claim-countdown')).toBeVisible()
119+
// The countdown shows a real HH:MM:SS, not the "—" no-data placeholder.
120+
await expect(page.getByTestId('claim-countdown-value')).not.toHaveText('—')
121+
await expect(page.getByTestId('claim-checkout-hobby')).toBeVisible()
122+
await expect(page.getByTestId('claim-checkout-pro')).toBeVisible()
123+
})
124+
125+
test('S3.3 — single-use claim: a 409 replay surfaces the conflict error', async ({ page }) => {
126+
// POST /claim returns 409 — the JWT was already consumed (atomic
127+
// single-use claim, CLAUDE.md convention 7).
128+
await page.route('**/claim', (route: Route) => {
129+
if (route.request().method() !== 'POST') return route.continue()
130+
return route.fulfill({
131+
status: 409,
132+
contentType: 'application/json',
133+
body: JSON.stringify({ ok: false, error: 'already_claimed', message: 'This claim link was already used.' }),
134+
})
135+
})
136+
137+
await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
138+
await page.getByTestId('claim-email').fill('founder@example.com')
139+
await page.getByTestId('claim-submit').click()
140+
141+
// The error stage surfaces the 409 message — no crash, no funnel.
142+
await expect(page.getByTestId('claim-error')).toBeVisible()
143+
await expect(page.getByTestId('claim-error')).toContainText(/already used/i)
144+
})
145+
146+
test('S3.6 — funnel "Keep my resources" CTA opens Razorpay checkout', async ({ page }) => {
147+
await page.route('**/claim', (route: Route) => {
148+
if (route.request().method() !== 'POST') return route.continue()
149+
return route.fulfill({
150+
status: 200,
151+
contentType: 'application/json',
152+
body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }),
153+
})
154+
})
155+
await page.route('**/api/v1/auth/api-keys', (route: Route) =>
156+
route.fulfill({
157+
status: 201,
158+
contentType: 'application/json',
159+
body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }),
160+
}),
161+
)
162+
await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) =>
163+
route.fulfill({
164+
status: 200,
165+
contentType: 'application/json',
166+
body: JSON.stringify({ ok: true, total: 0, items: [] }),
167+
}),
168+
)
169+
// The checkout call returns a Razorpay short_url. We intercept the
170+
// navigation to the mock URL so the test stays hermetic — it asserts
171+
// the redirect was attempted without ever loading rzp.io.
172+
let navigatedTo: string | null = null
173+
await page.route('**/api/v1/billing/checkout', (route: Route) => {
174+
if (route.request().method() !== 'POST') return route.continue()
175+
return route.fulfill({
176+
status: 200,
177+
contentType: 'application/json',
178+
body: JSON.stringify({ ok: true, short_url: FAKE_RAZORPAY_SHORT_URL }),
179+
})
180+
})
181+
await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => {
182+
navigatedTo = route.request().url()
183+
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>razorpay stub</body></html>' })
184+
})
185+
186+
await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
187+
await page.getByTestId('claim-email').fill('founder@example.com')
188+
await page.getByTestId('claim-submit').click()
189+
await expect(page.getByTestId('claim-funnel')).toBeVisible()
190+
191+
await page.getByTestId('claim-checkout-hobby').click()
192+
await expect.poll(() => navigatedTo).toContain('rzp.io')
193+
})
194+
})

0 commit comments

Comments
 (0)