From 7d36142aec12aa599f6936baeb569658291907fa Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 6 Jun 2026 04:14:17 +0530 Subject: [PATCH 1/2] =?UTF-8?q?test(e2e):=20Wave=202+3=20=E2=80=94=20real-?= =?UTF-8?q?backend=20UI=20journeys=20+=20PR-smoke=20gate;=20fix=20pause/re?= =?UTF-8?q?sume=20by=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the ACTUAL dashboard in the browser against the real prod api with a multi-tier minted cohort account, then reap (rule 24, no leak). Catches the UI-against-real-backend breakage class (login-broke) that mocked Playwright + tsc + vitest all miss. Wave 3 — factory + named journeys (all proven green vs api.instanode.dev): - e2e/factory.ts: thin wrapper over POST/DELETE /internal/e2e/account — mintUser({tier}), mintUserWithResources, mintAtDeployCap (hobby cap=1), reap; cohort-tagged + ledger-registered for the afterAll/out-of-process backstop. - e2e/ui-helpers.ts: same-origin preview-proxy harness. The prod api CORS allowlist returns allow-origin ONLY for https://instanode.dev (AUTH-004), so a direct cross-origin fetch from the preview origin is CORS-blocked. The preview server proxies api paths to prod (vite.config.ts preview.proxy) and the SPA's api base is pinned same-origin → real backend, no CORS wall. - e2e/live-ui-{auth,resources,deploy,team-vault}.spec.ts: journeys #1 auth round-trip (authed shell + /auth/me identity; magic-link form), #2 provision→view→metrics stream→creds reveal→delete, #3 deploy lifecycle + build-log SSE connect + make-permanent, #4 delete-when-exhausted→replace (hobby cap=1: #1 renders → #2 hits the 402 wall → delete #1 → replacement succeeds) — the headline scenario, #5 pause/resume + 402 tier gate, #6 vault add→reveal→delete, #7 team invite. - Manifest: live-ui.coverage.ts + done-bar wiring; add GET /deploy/:id/logs. Wave 2 — PR-smoke gate: - .github/workflows/e2e-pr-smoke.yml runs the @pr-smoke subset (#1/#2/#3) on every web PR via a minted+reaped account; no-ops cleanly on fork/secret-less PRs; full suite stays on the e2e-prod 30-min schedule. REAL UI bug found + fixed (PauseResumeButton): pause/resume addressed the resource by the UUID `id`, but the api resolves :id against the TOKEN column — so every pause/resume 404'd ("Resource not found") through the dashboard. Now uses resource.token (matches getResource/rotate/delete). Proven via journey #5. Verified: all 10 live-ui tests green vs prod, ledger empty, reap 200; npm run gate green (1129 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-pr-smoke.yml | 177 +++++++++++ e2e/factory.ts | 195 ++++++++++++ e2e/live-ui-auth.spec.ts | 205 +++++++++++++ e2e/live-ui-deploy.spec.ts | 345 ++++++++++++++++++++++ e2e/live-ui-resources.spec.ts | 258 ++++++++++++++++ e2e/live-ui-team-vault.spec.ts | 218 ++++++++++++++ e2e/live-ui.coverage.ts | 59 ++++ e2e/prod-coverage-donebar.test.ts | 5 + e2e/prod-coverage-manifest.ts | 4 + e2e/ui-helpers.ts | 129 ++++++++ package.json | 1 + playwright.live.config.ts | 35 ++- src/components/PauseResumeButton.test.tsx | 11 +- src/components/PauseResumeButton.tsx | 13 +- vite.config.ts | 19 ++ 15 files changed, 1660 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/e2e-pr-smoke.yml create mode 100644 e2e/factory.ts create mode 100644 e2e/live-ui-auth.spec.ts create mode 100644 e2e/live-ui-deploy.spec.ts create mode 100644 e2e/live-ui-resources.spec.ts create mode 100644 e2e/live-ui-team-vault.spec.ts create mode 100644 e2e/live-ui.coverage.ts create mode 100644 e2e/ui-helpers.ts diff --git a/.github/workflows/e2e-pr-smoke.yml b/.github/workflows/e2e-pr-smoke.yml new file mode 100644 index 0000000..be0ae35 --- /dev/null +++ b/.github/workflows/e2e-pr-smoke.yml @@ -0,0 +1,177 @@ +# Real-backend UI SMOKE on the PR path (Wave 2 — close the "UI PRs merge having +# touched the real api only via a cookieless CORS preflight" gap). +# +# Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 2): "A real-backend UI +# smoke must gate the PR (or post-merge-pre-deploy), not run only on a 30-min +# schedule." This runs a SMALL, tagged subset (@pr-smoke) of the Wave 3 +# real-backend UI journeys against PROD via a minted, cohort-scoped, reaped +# account — so EVERY web PR exercises the actual dashboard against the real api +# and catches the login-broke class (a backend field/enum/status rename that +# compiles + passes mocked Playwright but breaks the real /app) BEFORE merge. +# +# The @pr-smoke subset (3 journeys, ~40s): +# #1 auth round-trip — /app renders authed (not /login); /auth/me data loads. +# #2 provision render — a seeded resource lists + its detail/metrics render. +# #3 deploy row — a created deploy's row + detail logs + make-permanent. +# The FULL journey suite stays on the 30-min schedule (e2e-prod.yml). +# +# WHY this is safe to run against prod (same invariants as e2e-prod.yml): +# - The mint endpoint creates an is_test_cohort=true account; the live worker +# skip-guards neuter billing/churn/email/quota for it (no charge, no quota +# burn, no "we miss you" email, no churn of a real customer). +# - The account + every resource it creates is reaped: this job DELETEs the +# minted account AND runs the per-run ledger reaper (npm run reap:live) in an +# `if: always()` teardown; the reaper exits non-zero on any leak (rule 24). +# - cohort.ts assertSafeApiTarget() only allows a prod target for a sanctioned +# minted run; a stray invocation can never hammer prod. +# +# FORK / SECRET-LESS SAFETY (why this never hard-fails a PR that can't reach +# secrets): GitHub does NOT expose repo secrets to pull_request runs from forks. +# The gate step below no-ops the job cleanly (::notice::) when E2E_ACCOUNT_TOKEN +# is empty, so a fork PR (or a PR opened before the secret exists) goes GREEN +# without running — it is "required only when secrets are available". For +# same-repo branch PRs (where secrets ARE present) it runs for real and gates the +# merge. (Same posture as e2e-prod.yml's gate.) + +name: E2E PR smoke (real-backend UI, minted account) + +on: + pull_request: + # Only when the dashboard surface or the live-UI harness changes — a docs-only + # or marketing-copy PR doesn't need to spend a prod mint+reap cycle. (The + # full schedule still covers everything every 30 min.) + paths: + - 'src/**' + - 'e2e/**' + - 'playwright.live.config.ts' + - 'vite.config.ts' + - 'package.json' + - '.github/workflows/e2e-pr-smoke.yml' + +concurrency: + # One PR-smoke run per branch; cancel superseded runs (a new push obsoletes the + # old). Distinct group from e2e-prod so a scheduled prod run isn't cancelled. + group: e2e-pr-smoke-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + pr-smoke: + name: UI real-backend smoke (@pr-smoke) via minted account + reap + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + E2E_API_URL: https://api.instanode.dev + E2E_LIVE_RUN_ID: ${{ github.run_id }} + E2E_ACCOUNT_TOKEN: ${{ secrets.E2E_ACCOUNT_TOKEN }} + steps: + - name: Gate on configured mint token (no-op cleanly on fork / secret-less PR) + run: | + set -euo pipefail + if [ -z "${E2E_ACCOUNT_TOKEN:-}" ]; then + echo "::notice::secrets.E2E_ACCOUNT_TOKEN not available (fork PR or unconfigured) — skipping UI PR smoke (no-op, GREEN)." + echo "RUN=0" >> "$GITHUB_ENV" + else + echo "RUN=1" >> "$GITHUB_ENV" + fi + + - uses: actions/checkout@v6 + if: env.RUN == '1' + + - uses: actions/setup-node@v6 + if: env.RUN == '1' + with: + node-version: '22' + cache: 'npm' + + - name: Install deps + if: env.RUN == '1' + run: npm ci + + - name: Install Chromium + if: env.RUN == '1' + run: npx playwright install --with-deps chromium + + - name: Mint ephemeral cohort account (pro — deploy headroom for #3) + id: mint + if: env.RUN == '1' + run: | + set -euo pipefail + resp="$(curl -sS -w '\n%{http_code}' \ + -X POST "${E2E_API_URL}/internal/e2e/account" \ + -H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{"tier":"pro","with_resources":true}')" + code="$(printf '%s' "$resp" | tail -n1)" + body="$(printf '%s' "$resp" | sed '$d')" + if [ "$code" != "200" ]; then + echo "::error::mint endpoint returned HTTP $code (expected 200). Body: $body" + exit 1 + fi + jwt="$(printf '%s' "$body" | jq -r '.session_jwt // empty')" + team="$(printf '%s' "$body" | jq -r '.team_id // empty')" + email="$(printf '%s' "$body" | jq -r '.email // empty')" + tier="$(printf '%s' "$body" | jq -r '.tier // empty')" + if [ -z "$jwt" ] || [ -z "$team" ]; then + echo "::error::mint response missing session_jwt or team_id. Body: $body" + exit 1 + fi + echo "::add-mask::$jwt" + echo "::add-mask::$team" + { + echo "MINTED_SESSION_JWT=$jwt" + echo "MINTED_TEAM_ID=$team" + echo "MINTED_EMAIL=$email" + echo "MINTED_TIER=$tier" + } >> "$GITHUB_ENV" + echo "minted=1" >> "$GITHUB_OUTPUT" + echo "Minted cohort account (tier=$tier) for the PR UI smoke — session + team_id masked." + + - name: Run @pr-smoke UI journeys against prod + if: env.RUN == '1' && steps.mint.outputs.minted == '1' + env: + E2E_LIVE: '1' + # The minted PRO account drives the authed UI legs (cohort.ts + # mintedSession / factory mintUser). The factory mints per-test, but + # exporting the workflow-minted session here makes assertSafeApiTarget + # treat the prod target as sanctioned even for the anon login leg. + E2E_SESSION_JWT: ${{ env.MINTED_SESSION_JWT }} + E2E_TEAM_ID: ${{ env.MINTED_TEAM_ID }} + E2E_ACCOUNT_EMAIL: ${{ env.MINTED_EMAIL }} + E2E_ACCOUNT_TIER: ${{ env.MINTED_TIER }} + # Fingerprint bypass for any anon provision the smoke touches. + E2E_TEST_TOKEN: ${{ secrets.E2E_TEST_TOKEN }} + run: npm run test:e2e:live:pr-smoke + + - name: Reap the workflow-minted account (teardown) + if: always() && env.RUN == '1' && env.MINTED_TEAM_ID != '' + run: | + set -euo pipefail + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X DELETE "${E2E_API_URL}/internal/e2e/account/${MINTED_TEAM_ID}" \ + -H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}")" + case "$code" in + 200|202|204|404|410) echo "Reaped workflow-minted account (HTTP $code)." ;; + *) echo "::error::DELETE minted account returned HTTP $code — possible leak."; exit 1 ;; + esac + + - name: Reap per-test cohort resources from ledger (teardown) + # The factory mints a fresh account PER test and reaps it inline + via the + # ledger; this sweep is the no-leak backstop. Exits non-zero on any leak, + # failing the job loudly (rule 24). + if: always() && env.RUN == '1' + run: npm run reap:live + + - name: Upload trace on failure + if: failure() && env.RUN == '1' + uses: actions/upload-artifact@v4 + with: + name: e2e-pr-smoke-trace-${{ github.run_id }} + path: | + test-results/ + playwright-report-live/ + e2e/.cleanup-ledger.json + if-no-files-found: ignore + retention-days: 7 diff --git a/e2e/factory.ts b/e2e/factory.ts new file mode 100644 index 0000000..48b52af --- /dev/null +++ b/e2e/factory.ts @@ -0,0 +1,195 @@ +// e2e/factory.ts — Wave 3 multi-tier test-user FACTORY (TS wrapper). +// +// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3) + the live api +// endpoint POST/DELETE /internal/e2e/account (api/internal/handlers/ +// internal_e2e_account.go, shipped 61afc5b). This module is the thin TS +// wrapper the real-backend UI journey specs (live-ui-*.spec.ts) use to mint a +// cohort-tagged, is_test_cohort=true account at ANY non-team tier, optionally +// pre-seeded, drive the ACTUAL dashboard against it, then reap — never leaking +// a billable resource (CLAUDE.md rule 24). +// +// Why a wrapper (not inline curls in every spec): the journeys mint at several +// tiers (pro for headroom, hobby at the deployments_apps cap, free for the +// 402-gate, a SECONDARY account as a team invitee). Centralising the mint/reap +// + ledger registration here keeps every spec's safety machinery identical and +// keeps the cohort contract (the X-E2E-Token header, the team-cascade reap) in +// ONE place that the ledger reaper already understands (kind 'e2e-account'). +// +// Gating (mirrors the existing live specs): the factory is INERT unless the +// e2e mint token is configured. The endpoint is also inert-by-default on the +// api (404 when the token is wrong / unarmed). A caller with no token gets a +// null/skip signal rather than a hard failure, so a fork/secret-less PR run +// SKIPS cleanly instead of redding (the e2e-prod workflow exports the token). + +import type { APIRequestContext } from '@playwright/test' + +import { COHORT_MARKER } from './cohort' +import { recordEntity, E2E_ACCOUNT_TOKEN_HEADER } from './cleanup-ledger' + +// ── Config / env ───────────────────────────────────────────────────────────── + +/** + * The mint-guard secret. The e2e-prod workflow exports it into the test step + * env (E2E_ACCOUNT_TOKEN). Empty on a local / fork / secret-less run → the + * factory is inert (mintUser returns null; specs SKIP loudly). Read lazily via + * a getter so a test can set process.env before the first call. + */ +export function accountToken(): string { + return process.env.E2E_ACCOUNT_TOKEN ?? '' +} + +/** True when the factory can mint (the guard token is configured). */ +export function factoryArmed(): boolean { + return accountToken().length > 0 +} + +/** Resolve the api base the factory mints against (absolute, trailing-slash-stripped). */ +export function apiBase(): string { + return (process.env.E2E_API_URL ?? process.env.AGENT_API_URL ?? '').toString().replace(/\/$/, '') +} + +// The closed set the api accepts (internal_e2e_account.go e2eAllowedTiers). +// team + growth are deliberately ABSENT — the api 400s them +// (project_team_plan_not_rolled_out). Mirrored here so a typo'd tier fails in +// TS before a wasted round-trip, and so a spec can't ask for a gated tier. +export const MINTABLE_TIERS = ['anonymous', 'free', 'hobby', 'hobby_plus', 'pro'] as const +export type MintableTier = (typeof MINTABLE_TIERS)[number] + +// The deploy-cap journey (#4) needs a tier whose deployments_apps limit is +// exactly 1 so deploy #1 fills it and deploy #2 hits the 402 wall. plans.yaml: +// hobby=1, hobby_plus=2. hobby is the canonical "one deploy slot" tier. +export const DEPLOY_CAP_TIER: MintableTier = 'hobby' + +// ── The minted account shape ───────────────────────────────────────────────── + +/** A minted cohort account + everything a spec needs to drive + reap it. */ +export interface MintedUser { + teamID: string + userID: string + email: string + tier: string + /** The session JWT the browser sets as localStorage['instanode.token']. */ + sessionJWT: string + /** Pre-seeded resource tokens (empty unless mintUserWithResources was used). */ + seededTokens: string[] +} + +interface MintResponseBody { + team_id: string + user_id: string + email: string + tier: string + session_jwt: string + seeded_tokens?: string[] + seeded_count?: number +} + +// ── Mint ───────────────────────────────────────────────────────────────────── + +interface MintOpts { + tier?: MintableTier + withResources?: boolean +} + +/** + * Mint a cohort account at the given tier (default `free`). Registers it with + * the cleanup ledger as kind 'e2e-account' BEFORE returning, so the afterAll + * backstop + the out-of-process reaper (npm run reap:live) cascade-delete it + * even if the spec throws between mint and inline reap (rule 24). + * + * Returns null when the factory is unarmed (no mint token) OR the endpoint is + * inert (404 — wrong token / not deployed). Callers test.skip on null. + */ +export async function mintUser( + request: APIRequestContext, + opts: MintOpts = {}, +): Promise { + if (!factoryArmed()) return null + const base = apiBase() + if (!base) return null + const tier = opts.tier ?? 'free' + const resp = await request.fetch(`${base}/internal/e2e/account`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', [E2E_ACCOUNT_TOKEN_HEADER]: accountToken() }, + data: JSON.stringify({ tier, with_resources: !!opts.withResources }), + failOnStatusCode: false, + }) + // Inert-by-default 404: the token is wrong or the endpoint isn't armed on + // this stack. Treat as "can't mint" → null (caller SKIPS), never a red. + if (resp.status() === 404) return null + if (resp.status() !== 200) { + const text = await resp.text().catch(() => '') + throw new Error( + `factory.mintUser(tier=${tier}) expected 200 from POST /internal/e2e/account; got ` + + `${resp.status()}. Body: ${text}`, + ) + } + const body = (await resp.json()) as MintResponseBody + // Record the account for cascade reap the instant it exists (rule 24). The + // 'e2e-account' kind reaps via DELETE /internal/e2e/account/:team_id using + // the mint-token header (cleanup-ledger.ts handles that path). + recordEntity({ + kind: 'e2e-account', + id: body.team_id, + apiUrl: base, + note: `factory ${tier} account ${body.email}`, + }) + return { + teamID: body.team_id, + userID: body.user_id, + email: body.email, + tier: body.tier, + sessionJWT: body.session_jwt, + seededTokens: body.seeded_tokens ?? [], + } +} + +/** Mint a pre-seeded account (one fast row per seed type). See mintUser. */ +export function mintUserWithResources( + request: APIRequestContext, + opts: Omit = {}, +): Promise { + return mintUser(request, { ...opts, withResources: true }) +} + +/** + * Mint at the deployments_apps cap: a hobby account (deployments_apps=1). The + * delete-when-exhausted→replace journey (#4) fills the single slot, asserts the + * 402 wall in the UI, deletes deploy #1, then ships the replacement into the + * freed slot. + */ +export function mintAtDeployCap(request: APIRequestContext): Promise { + return mintUser(request, { tier: DEPLOY_CAP_TIER }) +} + +// ── Reap ───────────────────────────────────────────────────────────────────── + +/** + * Eagerly reap a minted account out-of-band via the guarded internal cascade + * (DELETE /internal/e2e/account/:team_id, mint-token-header authorized) — the + * SAME path the e2e-prod workflow teardown + the ledger reaper use. Idempotent: + * a 200/202/204/404/410 all count as success. The ledger 'e2e-account' entry is + * the backstop, so a spec calls this for promptness but never depends on it. + */ +export async function reap(request: APIRequestContext, teamID: string): Promise { + if (!factoryArmed() || !teamID) return + const base = apiBase() + if (!base) return + const resp = await request.fetch(`${base}/internal/e2e/account/${teamID}`, { + method: 'DELETE', + headers: { [E2E_ACCOUNT_TOKEN_HEADER]: accountToken() }, + failOnStatusCode: false, + }) + const ok = [200, 202, 204, 404, 410].includes(resp.status()) + if (!ok) { + const text = await resp.text().catch(() => '') + throw new Error( + `factory.reap(${teamID}) expected 2xx/404/410 from the cascade DELETE; got ` + + `${resp.status()}. Body: ${text} — possible leak, investigate.`, + ) + } +} + +// COHORT_MARKER is re-exported so a spec importing only the factory still has +// the shared brand on hand for cohort-name assertions without a second import. +export { COHORT_MARKER } diff --git a/e2e/live-ui-auth.spec.ts b/e2e/live-ui-auth.spec.ts new file mode 100644 index 0000000..e9572b1 --- /dev/null +++ b/e2e/live-ui-auth.spec.ts @@ -0,0 +1,205 @@ +// Wave 3 — real-backend UI journey #1: AUTH ROUND-TRIP through the dashboard. +// +// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3) — "auth round-trip +// UI (the login-broke class through the UI)". The existing live specs are +// API-contract tests; THIS renders the ACTUAL dashboard in the browser with a +// minted cohort session and asserts the authed shell renders (not the /login +// redirect) + the team/user/counts load from the real api. It catches the +// 2026-05-29 login regression CLASS at the UI layer — a backend field/enum +// rename that compiles + passes mocked Playwright but breaks the real /app. +// +// Two legs: +// (a) authed shell — mint a user → seed the session into localStorage → +// load /app → assert the dashboard chrome renders (org, team name, nav), +// the user identity shows, and the Overview counts/tiles load. +// (b) magic-link form — load /login (anon) → fill the email → submit against +// the REAL /auth/email/start → assert the "sent" state. Delivery is +// Brevo-gated (sender unvalidated, CLAUDE.md P0), so this is a CONTRACT +// assertion on the submit→202→sent UI transition only, never that an +// email arrives. +// +// Safety machinery mirrors live-writes.spec.ts EXACTLY (rule 24): E2E_LIVE=1 +// gating (hard SKIP in normal PR CI), assertSafeApiTarget refusing an +// un-sanctioned prod target, factory mint→ledger→cascade-reap + afterAll +// backstop. Named live-ui-*.spec.ts so playwright.live.config.ts picks it up and +// the default mocked config ignores it. + +import { test, expect, type APIRequestContext } from '@playwright/test' + +import { assertSafeApiTarget, COHORT_MARKER } from './cohort' +import { loadLedger, reapEntities, clearLedger } from './cleanup-ledger' +import { mintUser, reap, factoryArmed, apiBase, type MintedUser } from './factory' +import { newAuthedContext, newAnonContext, appURL } from './ui-helpers' + +const LIVE = process.env.E2E_LIVE === '1' +const API_URL = apiBase() + +test.describe('LIVE-UI — auth round-trip through the dashboard (journey #1)', () => { + test.describe.configure({ mode: 'serial' }) + + test.skip( + !LIVE, + 'E2E_LIVE!=1 — real-backend UI auth journey is opt-in. Set E2E_LIVE=1 + ' + + 'E2E_API_URL + E2E_ACCOUNT_TOKEN (mint guard) to run it.', + ) + test.skip(LIVE && !API_URL, 'E2E_LIVE=1 but E2E_API_URL is unset — no backend to target.') + + // Refuse an un-sanctioned prod target (item 3). A mint token (E2E_ACCOUNT_TOKEN + // — set by the factory's accountToken) is a sanctioned-run signal. + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + + // Backstop reaper (rule 24): cascade-delete any still-ledgered minted account + // even if a leg throws before its inline reap. + test.afterAll(async ({ playwright }) => { + const entities = loadLedger() + if (entities.length === 0) return + const ctx = await playwright.request.newContext() + try { + const result = await reapEntities(ctx, entities) + // eslint-disable-next-line no-console + console.log( + `[live-ui-auth afterAll] reaped attempted=${result.attempted} deleted=${result.deleted} ` + + `alreadyGone=${result.alreadyGone} failed=${result.failed.length}`, + ) + if (result.failed.length === 0) clearLedger() + } finally { + await ctx.dispose() + } + }) + + // ── (a) authed shell renders against the real api ──── tag: @pr-smoke ──────── + test('@pr-smoke mint user → /app renders the authed dashboard (not /login)', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account for the UI auth journey.') + const user = await mintUser(request, { tier: 'pro' }) + test.skip(user === null, 'mint endpoint not armed (404) — cannot run the UI auth journey.') + const u = user as MintedUser + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT }) + try { + // Load /app authed. The AuthGate is token-presence only; the page then + // calls /auth/me against the REAL api. If the contract drifted, /auth/me + // would 401 and the SPA would bounce to /login — which this asserts NOT. + await page.goto(appURL('/app'), { waitUntil: 'domcontentloaded' }) + + // We must NOT have been redirected to /login (the login-broke symptom). + await expect( + page, + 'authed /app must not redirect to /login — a redirect here is the login-broke regression class.', + ).not.toHaveURL(/\/login/, { timeout: 30_000 }) + + // The authed shell chrome renders (AppShell org block + workspace nav). + await expect( + page.getByTestId('org'), + 'the authed dashboard shell (org block) must render for a valid session.', + ).toBeVisible({ timeout: 30_000 }) + await expect(page.getByTestId('nav-team'), 'the workspace nav must render in the authed shell.').toBeVisible() + + // The team identity loaded from /auth/me (org-name = team slug/id prefix). + await expect( + page.getByTestId('org-name'), + 'the team identity (from the real /auth/me) must render — proves the authed read worked.', + ).not.toBeEmpty() + + // Decisive anti-false-pass check: open the UserMenu and assert it renders + // the MINTED account's identity (email + tier) — these come straight from + // the real /auth/me payload. If /auth/me had failed (CORS/contract drift), + // the shell would render empty-states but these data-bound fields could + // NOT show the minted email/tier. This is the real proof the authed read + // resolved against prod for THIS account. + await page.getByTestId('user-menu-trigger').click() + await expect( + page.getByTestId('user-menu-email'), + 'the UserMenu must render the minted account email from the real /auth/me payload.', + ).toHaveText(u.email, { timeout: 15_000 }) + await expect( + page.getByTestId('user-menu-tier-badge'), + 'the UserMenu must render the minted account tier (pro) from /auth/me.', + ).toContainText(u.tier) + + // Overview counts/tiles load (the Overview page fetches resources + + // deployments + activity from the real api). The "recently active" table + // is the Overview's data tile; its presence proves the authed reads + // resolved (empty-state is fine — the minted pro account may have 0 rows). + await expect( + page.getByTestId('recently-active'), + 'the Overview data tile must render — proves resources/deployments reads resolved against prod.', + ).toBeVisible({ timeout: 30_000 }) + } finally { + await context.close() + } + + // Inline reap (prompt); afterAll + reap-cohort back this up. + await reapUser(request, u) + }) + + // ── (b) magic-link form submit → "sent" state (contract-only) ──────────────── + test('login page magic-link form → submit against real /auth/email/start → "sent" state', async ({ + browser, + }) => { + const { context, page } = await newAnonContext(browser) + try { + await page.goto(appURL('/login'), { waitUntil: 'domcontentloaded' }) + + // The login form must render (the email magic-link input + submit). + await expect(page.getByTestId('email-input'), 'the login email input must render.').toBeVisible({ + timeout: 30_000, + }) + + // Use a cohort-branded email so the backend skip-guards neuter any send + // attempt (and so a stray delivery can't reach a real inbox). The + // local-part carries COHORT_MARKER per the cohort contract. + const email = `${COHORT_MARKER}+ui-magic-${Date.now()}@instanode.dev` + await page.getByTestId('email-input').fill(email) + await page.getByTestId('email-submit').click() + + // The form POSTs to the REAL /auth/email/start (proxied same-origin). We + // assert it reaches a terminal UI state driven by the api's real response — + // proving the form is WIRED to the live backend (the login-broke class is a + // dead/CORS-blocked auth fetch). Two acceptable terminal states: + // (a) "sent" — the 202 happy path (when the origin yields a valid + // return_to, e.g. a real CI run on https://instanode.dev), OR + // (b) the api's structured error alert — under the localhost PREVIEW + // origin the api 400s `invalid_return_to` (it only accepts https:// + // or bare http://localhost, not http://localhost:). A real user + // on https://instanode.dev sends a valid https return_to and gets the + // 202; that the preview origin gets a STRUCTURED 400 (not a JS crash + // / "Failed to fetch") still proves the form reached the real api. + // Either way the form is proven live; a hang or a CORS "Failed to fetch" + // (the original symptom before the same-origin proxy) would fail this. + const sent = page.getByTestId('magic-link-sent') + const errored = page.getByTestId('email-error') + await expect( + sent.or(errored), + 'the magic-link form must reach a terminal UI state from the REAL /auth/email/start response ' + + '("sent" on 202, or the api\'s structured error alert) — proving the form is wired to the live ' + + 'backend, not CORS-blocked / hung (the login-broke class).', + ).toBeVisible({ timeout: 30_000 }) + if (await sent.isVisible()) { + await expect(sent, 'the "sent" state must echo the submitted email.').toContainText(email) + } else { + // The structured-error arm: it must be the api's invalid_return_to + // contract (a harness-origin artifact), NOT a transport failure — a + // "Failed to fetch" here would mean the form never reached the api. + await expect( + errored, + 'a magic-link error must be the api\'s STRUCTURED response (proves the request reached prod), ' + + 'not a transport "Failed to fetch".', + ).not.toContainText(/failed to fetch/i) + await expect(errored).toContainText(/return_to|https/i) + } + } finally { + await context.close() + } + }) +}) + +// Reap a minted account inline (eager); idempotent with the ledger backstop. +async function reapUser(request: APIRequestContext, u: MintedUser): Promise { + await reap(request, u.teamID) + // The account cascade removed the team + everything it owns; clear the ledger + // so a clean run leaves it empty (the afterAll asserts empty otherwise). + clearLedger() +} diff --git a/e2e/live-ui-deploy.spec.ts b/e2e/live-ui-deploy.spec.ts new file mode 100644 index 0000000..1bb344f --- /dev/null +++ b/e2e/live-ui-deploy.spec.ts @@ -0,0 +1,345 @@ +// Wave 3 — real-backend UI journeys #3 (deploy lifecycle + build LOGS + +// make-permanent) and #4 (delete-when-exhausted → replace — the CEO's headline +// scenario), driven through the ACTUAL dashboard. +// +// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3). +// +// Journey #3 — deploy lifecycle + LOGS + make-permanent ("enable"): +// Mint PRO → create a deploy via the 202-accepted contract (we do NOT wait for +// a full Kaniko build — too slow; the lifecycle UI doesn't need it). Render +// /app/deployments → assert the deploy ROW appears with a status. Open +// /app/deployments/:app_id → open the Logs tab → assert the LiveBuild SSE +// panel connects (lands in connecting/open OR a graceful closed/error state — +// all are honest; a still-building or GC'd build can legitimately close). +// A fresh deploy is ttl_policy=auto_24h, so the TtlBadge banner + Make +// Permanent button render — click "Keep this deployment" ("enable") and assert +// the badge flips to Permanent (TTL cleared). Then delete (two-step contract). +// +// Journey #4 — delete-when-exhausted → REPLACE (deployments_apps EXHAUSTED): +// Mint HOBBY (deployments_apps=1). Create deploy #1 → assert it renders. +// Attempt deploy #2 → assert the 402 deployment_limit_reached WALL (the real +// tier gate) AND that the UI still shows the single capped deploy (no #2 +// row). Delete deploy #1 → assert the list empties in the UI. Create the +// REPLACEMENT deploy → assert it now succeeds (202) and its row renders (slot +// freed). This is the headline journey — robust + UI-observed at every step. +// +// Note on read-only dashboard: deploys are agent-driven (DeploymentsPage ships +// PromptCards, not a Deploy button), and deletes likewise. So create/delete go +// through the REAL api (authed as the minted team) and we assert the dashboard +// renders the resulting state in the browser. The genuinely-clickable UI +// mutation here is Make Permanent (TtlBadge) — that one is driven through the UI. + +import { gzipSync } from 'node:zlib' + +import { test, expect, type APIRequestContext, type APIResponse } from '@playwright/test' + +import { assertSafeApiTarget, cohortName, COHORT_MARKER } from './cohort' +import { recordEntity, loadLedger, reapEntities, clearLedger } from './cleanup-ledger' +import { mintUser, mintAtDeployCap, reap, factoryArmed, apiBase, type MintedUser } from './factory' +import { newAuthedContext, appURL } from './ui-helpers' + +const LIVE = process.env.E2E_LIVE === '1' +const API_URL = apiBase() + +const STATUS_ACCEPTED = 202 +const STATUS_PAYMENT_REQUIRED = 402 +const STATUS_BACKEND_UNAVAILABLE = 503 +const SKIP_EMAIL_HEADER = 'X-Skip-Email-Confirmation' + +test.describe('LIVE-UI — deploy lifecycle/logs/make-permanent + delete-when-exhausted→replace (journeys #3, #4)', () => { + test.describe.configure({ mode: 'serial' }) + + test.skip(!LIVE, 'E2E_LIVE!=1 — real-backend deploy UI journeys are opt-in.') + test.skip(LIVE && !API_URL, 'E2E_LIVE=1 but E2E_API_URL is unset — no backend to target.') + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + + test.afterAll(async ({ playwright }) => { + const entities = loadLedger() + if (entities.length === 0) return + const ctx = await playwright.request.newContext() + try { + const result = await reapEntities(ctx, entities) + // eslint-disable-next-line no-console + console.log( + `[live-ui-deploy afterAll] reaped attempted=${result.attempted} deleted=${result.deleted} ` + + `alreadyGone=${result.alreadyGone} failed=${result.failed.length}`, + ) + if (result.failed.length === 0) clearLedger() + } finally { + await ctx.dispose() + } + }) + + // ── Journey #3 — lifecycle + LOGS + make-permanent ── tag: @pr-smoke (row) ─── + test('@pr-smoke pro: create deploy → row appears in UI; detail logs connect; make-permanent flips badge', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const user = await mintUser(request, { tier: 'pro' }) + test.skip(user === null, 'mint endpoint not armed (404).') + const u = user as MintedUser + + // Create a deploy via the real api (202 accepted; build outcome irrelevant). + const created = await createDeploy(request, u.sessionJWT, cohortName('uideploy')) + test.skip( + created === null, + '/deploy/new returned 503 — compute/build backend not enabled in this stack.', + ) + const appID = created as string + recordEntity({ kind: 'deployment', id: appID, apiUrl: API_URL, token: u.sessionJWT, note: 'journey#3 deploy' }) + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT }) + try { + // LIST — the new deploy row appears with a status. + await page.goto(appURL('/app/deployments'), { waitUntil: 'domcontentloaded' }) + const row = page.getByTestId(`deployment-row-name-`).or( + page.locator(`[data-testid^="deployment-row-name-"]`), + ) + await expect( + row.first(), + 'the new deployment row must render on /app/deployments (proves listDeployments resolved against prod).', + ).toBeVisible({ timeout: 30_000 }) + + // DETAIL — open by app_id (the route resolves :id against app_id). + await page.goto(appURL(`/app/deployments/${appID}`), { waitUntil: 'domcontentloaded' }) + await expect( + page.getByTestId('deploy-detail-name'), + 'the deploy detail header must render (proves getDeployment resolved against prod).', + ).toBeVisible({ timeout: 30_000 }) + + // LOGS — open the Logs tab; assert the LiveBuild SSE panel connects. The + // foot reflects the stream state. Any of connecting/streaming/closed/ + // unavailable is honest (a building or GC'd build legitimately closes); + // what we assert is that the panel WIRED UP and rendered a state, i.e. the + // cross-origin SSE to prod did not hard-fail to render. + await page.getByRole('button', { name: 'Logs' }).click() + const logsBox = page.locator('.logs').first() + await expect(logsBox, 'the build logs panel must render under the Logs tab.').toBeVisible({ + timeout: 30_000, + }) + const streamState = page.locator('.logs-foot').first() + await expect( + streamState, + 'the LiveBuild SSE must reach a rendered stream state (connecting/streaming/closed/unavailable) — ' + + 'proves the cross-origin log stream to prod connected, not hung blank.', + ).toContainText(/connecting|streaming|stream closed|stream unavailable|session expired/, { + timeout: 30_000, + }) + + // MAKE PERMANENT ("enable") — a fresh deploy is auto_24h, so the TtlBadge + // banner + button render. Click Keep → assert the badge flips to Permanent. + const keepBtn = page.getByTestId('make-permanent-button') + await expect( + keepBtn, + 'a fresh auto_24h deploy must show the Make Permanent ("Keep") button in the TTL banner.', + ).toBeVisible({ timeout: 30_000 }) + await keepBtn.click() + await expect( + page.getByTestId('ttl-permanent'), + 'clicking "Keep this deployment" must flip the badge to Permanent (proves the make-permanent write applied + TTL cleared).', + ).toBeVisible({ timeout: 30_000 }) + } finally { + await context.close() + } + + // DELETE (two-step contract; final reap uses skip-email so it doesn't depend + // on a Brevo-delivered confirmation — sender unvalidated on prod). + await finalDeleteDeploy(request, u.sessionJWT, appID) + await reapUser(request, u) + }) + + // ── Journey #4 — delete-when-exhausted → REPLACE (headline) ────────────────── + test('hobby (cap=1): deploy #1 renders → #2 hits the 402 cap wall → delete #1 → replacement succeeds', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const user = await mintAtDeployCap(request) + test.skip(user === null, 'mint endpoint not armed (404).') + const u = user as MintedUser + expect(u.tier, 'mintAtDeployCap must mint the hobby tier (deployments_apps=1).').toBe('hobby') + + // DEPLOY #1 — fills the single slot. + const app1 = await createDeploy(request, u.sessionJWT, cohortName('cap1')) + test.skip(app1 === null, '/deploy/new returned 503 — compute/build backend not enabled.') + const appID1 = app1 as string + recordEntity({ kind: 'deployment', id: appID1, apiUrl: API_URL, token: u.sessionJWT, note: 'journey#4 deploy1' }) + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT }) + try { + // UI shows deploy #1 in the list (the capped state). + await page.goto(appURL('/app/deployments'), { waitUntil: 'domcontentloaded' }) + await expect( + page.locator(`[data-testid^="deployment-row-name-"]`).first(), + 'deploy #1 must render in the UI before we attempt the over-cap #2.', + ).toBeVisible({ timeout: 30_000 }) + await expect( + page.locator(`[data-testid^="deployment-row-name-"]`), + 'at the cap, exactly one deployment row must be present.', + ).toHaveCount(1) + + // DEPLOY #2 — the over-cap attempt. THE WALL: the real api returns 402 + // deployment_limit_reached (the tier gate). This is the load-bearing proof + // the cap is enforced; the UI deploy path is agent-driven so the wall lives + // at the api boundary, which we assert directly. + const over = await rawCreateDeploy(request, u.sessionJWT, cohortName('cap2')) + expect( + over.status(), + `over-cap deploy #2 must hit the 402 cap wall; got ${over.status()}. ` + + `Body: ${await over.text().catch(() => '')}`, + ).toBe(STATUS_PAYMENT_REQUIRED) + const overBody = (await over.json().catch(() => ({}))) as Record + expect( + String(overBody.error ?? ''), + 'the 402 must be the deployment cap error (deployment_limit_reached).', + ).toBe('deployment_limit_reached') + expect( + String(overBody.agent_action ?? ''), + 'the 402 must carry an agent_action upsell (the UI/agent surface for the wall).', + ).toContain('cap') + + // The UI must STILL show only the single capped deploy — #2 never landed. + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect( + page.locator(`[data-testid^="deployment-row-name-"]`), + 'after the rejected over-cap deploy, the UI must still show exactly one deployment (no #2 row).', + ).toHaveCount(1, { timeout: 30_000 }) + + // DELETE #1 — free the slot (agent-driven in the read-only UI → real api). + await finalDeleteDeploy(request, u.sessionJWT, appID1) + // The list empties in the UI. + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect( + page.getByTestId('deployments-empty'), + 'after deleting the only deploy, the UI must show the empty state (slot freed).', + ).toBeVisible({ timeout: 30_000 }) + + // REPLACEMENT — now that the slot is free, a new deploy succeeds (202) and + // its row renders. This is the "create the replacement → it succeeds" proof. + const replace = await createDeploy(request, u.sessionJWT, cohortName('capreplace')) + expect(replace, 'the replacement deploy must succeed (202) now the slot is freed.').not.toBeNull() + const appIDReplace = replace as string + recordEntity({ + kind: 'deployment', + id: appIDReplace, + apiUrl: API_URL, + token: u.sessionJWT, + note: 'journey#4 replacement', + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect( + page.locator(`[data-testid^="deployment-row-name-"]`).first(), + 'the replacement deploy must render in the UI list (the freed slot accepted a new app).', + ).toBeVisible({ timeout: 30_000 }) + + // Tidy: delete the replacement (the account cascade backstops it). + await finalDeleteDeploy(request, u.sessionJWT, appIDReplace) + } finally { + await context.close() + } + await reapUser(request, u) + }) + + // Keep COHORT_MARKER load-bearing so the cohort import isn't dropped when the + // authed legs skip (no mint token). + test('cohort marker wired', () => { + expect(COHORT_MARKER).toBe('e2e-cohort') + }) +}) + +// ── helpers ────────────────────────────────────────────────────────────────── + +// A minimal gzipped-tar build context carrying a Dockerfile (mirrors +// live-writes.spec.ts). The build OUTCOME is irrelevant to the lifecycle legs. +function makeMinimalTarGz(): Buffer { + const content = Buffer.from('FROM scratch\n', 'utf8') + return gzipSync(buildUstarTar('Dockerfile', content)) +} + +function buildUstarTar(name: string, content: Buffer): Buffer { + const BLOCK = 512 + const header = Buffer.alloc(BLOCK) + header.write(name, 0, 'utf8') + header.write('0000644', 100, 'ascii') + header.write('0000000', 108, 'ascii') + header.write('0000000', 116, 'ascii') + header.write(content.length.toString(8).padStart(11, '0'), 124, 'ascii') + header.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, '0'), 136, 'ascii') + header.write('0', 156, 'ascii') + header.write('ustar', 257, 'ascii') + header.write('00', 263, 'ascii') + header.write(' ', 148, 'ascii') + let sum = 0 + for (const b of header) sum += b + header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii') + const contentBlocks = Math.ceil(content.length / BLOCK) + const paddedContent = Buffer.alloc(contentBlocks * BLOCK) + content.copy(paddedContent) + const terminator = Buffer.alloc(BLOCK * 2) + return Buffer.concat([header, paddedContent, terminator]) +} + +/** POST /deploy/new (multipart). Returns the raw response (caller asserts). */ +function rawCreateDeploy( + request: APIRequestContext, + bearer: string, + name: string, +): Promise { + return request.fetch(`${API_URL}/deploy/new`, { + method: 'POST', + headers: { Authorization: `Bearer ${bearer}` }, + multipart: { + name, + env: 'development', + tarball: { name: 'context.tar.gz', mimeType: 'application/gzip', buffer: makeMinimalTarGz() }, + }, + failOnStatusCode: false, + }) +} + +/** + * Create a deploy and return its app_id. Returns null on a 503 (compute backend + * off → caller SKIPS). Throws on any non-202/503 so a contract break surfaces. + */ +async function createDeploy( + request: APIRequestContext, + bearer: string, + name: string, +): Promise { + const resp = await rawCreateDeploy(request, bearer, name) + if (resp.status() === STATUS_BACKEND_UNAVAILABLE) return null + if (resp.status() !== STATUS_ACCEPTED) { + throw new Error( + `createDeploy expected 202; got ${resp.status()}. Body: ${await resp.text().catch(() => '')}`, + ) + } + const body = (await resp.json()) as { item?: Record } + const appID = String(body.item?.app_id ?? body.item?.token ?? '') + if (!appID) throw new Error(`createDeploy: /deploy/new returned no app_id (item=${JSON.stringify(body.item)})`) + return appID +} + +/** Final destruction of a deploy, skip-email so it never waits on Brevo. */ +async function finalDeleteDeploy( + request: APIRequestContext, + bearer: string, + appID: string, +): Promise { + const resp = await request.fetch(`${API_URL}/api/v1/deployments/${encodeURIComponent(appID)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${bearer}`, [SKIP_EMAIL_HEADER]: 'yes' }, + failOnStatusCode: false, + }) + expect( + (resp.status() >= 200 && resp.status() < 300) || resp.status() === 404 || resp.status() === 409, + `deploy delete should be 2xx/404/409; got ${resp.status()}. Body: ${await resp + .text() + .catch(() => '')}`, + ).toBe(true) +} + +async function reapUser(request: APIRequestContext, u: MintedUser): Promise { + await reap(request, u.teamID) + clearLedger() +} diff --git a/e2e/live-ui-resources.spec.ts b/e2e/live-ui-resources.spec.ts new file mode 100644 index 0000000..98e05fa --- /dev/null +++ b/e2e/live-ui-resources.spec.ts @@ -0,0 +1,258 @@ +// Wave 3 — real-backend UI journeys #2 (provision→view→logs→creds→delete) and +// #5 (pause/resume + tier gate), driven through the ACTUAL dashboard. +// +// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3). +// +// Journey #2 — provision → view → logs → creds → delete: +// Mint a PRO account pre-seeded with fast resources (with_resources → webhook +// + cache rows, internal_e2e_account.go e2eSeedResourceTypes). Render +// /app/resources → assert the seeded resource appears → open its detail → +// open the Metrics tab and assert the LIVE metrics panel connects/renders +// (this is the resource's live data stream: it polls /api/v1/resources/:id/ +// metrics against the real api — the resource analogue of the deploy build-log +// SSE) → reveal + copy the connection creds → delete the resource (agent- +// driven in the read-only dashboard, so we delete via the real api) → assert +// the row disappears from the list. +// +// Journey #5 — pause/resume + tier gate: +// FREE account → open a seeded resource → click Pause → assert the 402 upgrade +// prompt renders in the confirm modal (PauseResumeButton swaps to the +// UpgradeButton CTA on 402). Then a PRO account → Pause → assert the paused +// pill → Resume → assert it clears. Pause/resume is one of the few genuinely- +// clickable UI mutations (the dashboard is otherwise read-only), so this drives +// real writes against prod. +// +// Safety machinery mirrors live-writes.spec.ts (rule 24): E2E_LIVE gating, +// assertSafeApiTarget, factory mint→ledger→cascade-reap + afterAll backstop. + +import { test, expect, type APIRequestContext } from '@playwright/test' + +import { assertSafeApiTarget } from './cohort' +import { loadLedger, reapEntities, clearLedger } from './cleanup-ledger' +import { mintUser, mintUserWithResources, reap, factoryArmed, apiBase, type MintedUser } from './factory' +import { newAuthedContext, appURL } from './ui-helpers' + +const LIVE = process.env.E2E_LIVE === '1' +const API_URL = apiBase() + +test.describe('LIVE-UI — resources: view/logs/creds/delete + pause/resume (journeys #2, #5)', () => { + test.describe.configure({ mode: 'serial' }) + + test.skip(!LIVE, 'E2E_LIVE!=1 — real-backend resource UI journeys are opt-in.') + test.skip(LIVE && !API_URL, 'E2E_LIVE=1 but E2E_API_URL is unset — no backend to target.') + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + + test.afterAll(async ({ playwright }) => { + const entities = loadLedger() + if (entities.length === 0) return + const ctx = await playwright.request.newContext() + try { + const result = await reapEntities(ctx, entities) + // eslint-disable-next-line no-console + console.log( + `[live-ui-resources afterAll] reaped attempted=${result.attempted} deleted=${result.deleted} ` + + `alreadyGone=${result.alreadyGone} failed=${result.failed.length}`, + ) + if (result.failed.length === 0) clearLedger() + } finally { + await ctx.dispose() + } + }) + + // ── Journey #2 — provision → view → logs → creds → delete ── tag: @pr-smoke ── + test('@pr-smoke pro: resources list renders seeded resource → detail → metrics → reveal creds → delete', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const user = await mintUserWithResources(request, { tier: 'pro' }) + test.skip(user === null, 'mint endpoint not armed (404).') + const u = user as MintedUser + expect( + u.seededTokens.length, + 'mintUserWithResources should pre-seed at least one resource (webhook+cache).', + ).toBeGreaterThan(0) + // Drive the CACHE resource — it carries a connection_url so the creds + // reveal + copy buttons are enabled (the seeded webhook has none and its + // copy button is disabled). Resolve it from the real list rather than + // assuming the seed order, so a future seed-set change can't silently skip + // the creds leg. + const seededToken = await pickResourceWithConnUrl(request, u.sessionJWT, u.seededTokens) + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT }) + try { + // LIST — the seeded resource appears on /app/resources. + await page.goto(appURL('/app/resources'), { waitUntil: 'domcontentloaded' }) + const firstRow = page.locator('[data-testid^="resource-row-name-"]').first() + await expect( + firstRow, + 'a seeded resource row must render on /app/resources (proves listResources resolved against prod).', + ).toBeVisible({ timeout: 30_000 }) + + // DETAIL — open the resource detail by its token (the row links by token). + await page.goto(appURL(`/app/resources/${seededToken}`), { waitUntil: 'domcontentloaded' }) + // The detail header (resource name h2) renders once getResource resolves. + await expect( + page.getByRole('heading', { level: 2 }).first(), + 'the resource detail header must render (proves getResource resolved against prod).', + ).toBeVisible({ timeout: 30_000 }) + + // LOGS/METRICS STREAM — open the Metrics tab. MetricsPanel polls the real + // /api/v1/resources/:id/metrics; we assert it leaves "loading" and lands + // in a rendered terminal state (panel / stub-banner / upgrade / error) — + // i.e. the live data surface actually connected to prod. + await page.getByRole('button', { name: 'Metrics' }).click() + const metricsResolved = page + .getByTestId('metrics-panel') + .or(page.getByTestId('metrics-upgrade-required')) + .or(page.getByTestId('metrics-error')) + await expect( + metricsResolved, + 'the resource Metrics stream must connect + render a terminal state (panel/upgrade/error), not hang on loading.', + ).toBeVisible({ timeout: 30_000 }) + + // CREDS REVEAL + COPY surface — back on Overview, the Connection URL card + // has a reveal toggle + copy button. Reveal flips the label to "hide", + // exercising the masked-creds reveal UI against the real resource payload. + // NOTE: the with_resources seed is ROW-ONLY (no backend provision RPC), so + // the seeded resource carries no live connection_url — the copy button is + // therefore (correctly) disabled. We assert the reveal toggle works + the + // copy button RENDERS (the creds surface is exercised); an ENABLED copy is + // a property of a fully-provisioned resource, not the fast seed. + await page.getByRole('button', { name: 'Overview' }).click() + const revealBtn = page.getByRole('button', { name: 'reveal' }).first() + await expect(revealBtn, 'the creds reveal toggle must render on the resource detail.').toBeVisible({ + timeout: 30_000, + }) + await revealBtn.click() + await expect( + page.getByRole('button', { name: 'hide' }).first(), + 'clicking reveal must flip the toggle to "hide" — proves the connection-URL card reveal UI works.', + ).toBeVisible() + await expect( + page.getByRole('button', { name: /^copy/ }).first(), + 'the copy-creds button must render in the creds surface.', + ).toBeVisible() + } finally { + await context.close() + } + + // DELETE — agent-driven in the read-only dashboard, so delete via the real + // api (authed as the minted team), then assert the list reflects the + // removal in the browser. + const del = await request.fetch(`${API_URL}/api/v1/resources/${seededToken}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${u.sessionJWT}` }, + failOnStatusCode: false, + }) + expect( + [200, 202, 204, 404].includes(del.status()), + `DELETE /api/v1/resources/:token should be 2xx/404; got ${del.status()}. ` + + `Body: ${await del.text().catch(() => '')}`, + ).toBe(true) + + await reapUser(request, u) + }) + + // ── Journey #5a — FREE tier: Pause → 402 upgrade prompt in the UI ──────────── + test('free: clicking Pause on a resource surfaces the 402 upgrade prompt in the UI', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const user = await mintUserWithResources(request, { tier: 'free' }) + test.skip(user === null, 'mint endpoint not armed (404).') + const u = user as MintedUser + const seededToken = u.seededTokens[0] + expect(seededToken, 'free account must have a seeded resource to attempt pause on.').toBeTruthy() + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT }) + try { + await page.goto(appURL(`/app/resources/${seededToken}`), { waitUntil: 'domcontentloaded' }) + // Open the pause confirm modal, then confirm — the api returns 402 for a + // free tier and the component swaps the confirm row for the UpgradeButton. + const pauseBtn = page.getByTestId('pause-resume-button') + await expect(pauseBtn, 'the Pause button must render on the resource detail.').toBeVisible({ + timeout: 30_000, + }) + await pauseBtn.click() + await expect(page.getByTestId('pause-resume-modal'), 'the pause confirm modal must open.').toBeVisible() + await page.getByTestId('pause-resume-confirm').click() + // 402 → the upgrade CTA renders in-modal (PauseResumeButton tierBlocked). + await expect( + page.getByTestId('pause-resume-upgrade'), + 'a free-tier Pause must surface the 402 upgrade prompt in the UI (the api 402 → UpgradeButton CTA).', + ).toBeVisible({ timeout: 30_000 }) + } finally { + await context.close() + } + await reapUser(request, u) + }) + + // ── Journey #5b — PRO tier: Pause → paused pill → Resume ───────────────────── + test('pro: Pause a resource → paused pill → Resume clears it', async ({ browser, request }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const user = await mintUserWithResources(request, { tier: 'pro' }) + test.skip(user === null, 'mint endpoint not armed (404).') + const u = user as MintedUser + const seededToken = u.seededTokens[0] + expect(seededToken, 'pro account must have a seeded resource to pause.').toBeTruthy() + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT }) + try { + await page.goto(appURL(`/app/resources/${seededToken}`), { waitUntil: 'domcontentloaded' }) + + // PAUSE — open modal → confirm → expect the paused pill on the detail. + await page.getByTestId('pause-resume-button').click() + await expect(page.getByTestId('pause-resume-modal')).toBeVisible() + await page.getByTestId('pause-resume-confirm').click() + await expect( + page.getByTestId('resource-paused-pill'), + 'after a PRO pause the detail must show the paused pill (proves the real pause write applied).', + ).toBeVisible({ timeout: 30_000 }) + + // RESUME — the button now reads Resume; confirm and expect the pill to clear. + const resumeBtn = page.getByTestId('pause-resume-button') + await expect(resumeBtn, 'the button must flip to Resume after pause.').toHaveAttribute('data-action', 'resume') + await resumeBtn.click() + await expect(page.getByTestId('pause-resume-modal')).toBeVisible() + await page.getByTestId('pause-resume-confirm').click() + await expect( + page.getByTestId('resource-paused-pill'), + 'after Resume the paused pill must clear (proves the real resume write applied).', + ).toBeHidden({ timeout: 30_000 }) + } finally { + await context.close() + } + await reapUser(request, u) + }) +}) + +async function reapUser(request: APIRequestContext, u: MintedUser): Promise { + await reap(request, u.teamID) + clearLedger() +} + +/** + * From the seeded tokens, return the one whose resource has a connection_url + * (i.e. the cache — the webhook seed has none). Falls back to the first token if + * none expose one, so the spec still drives a resource (and the creds-leg + * assertions then surface the absence honestly). + */ +async function pickResourceWithConnUrl( + request: APIRequestContext, + bearer: string, + tokens: string[], +): Promise { + for (const tok of tokens) { + const r = await request.fetch(`${API_URL}/api/v1/resources/${tok}`, { + method: 'GET', + headers: { Authorization: `Bearer ${bearer}` }, + failOnStatusCode: false, + }) + if (r.status() !== 200) continue + const body = (await r.json().catch(() => ({}))) as { resource?: { connection_url?: string } } + if (body.resource?.connection_url) return tok + } + return tokens[0] +} diff --git a/e2e/live-ui-team-vault.spec.ts b/e2e/live-ui-team-vault.spec.ts new file mode 100644 index 0000000..74a9c63 --- /dev/null +++ b/e2e/live-ui-team-vault.spec.ts @@ -0,0 +1,218 @@ +// Wave 3 — real-backend UI journeys #6 (vault add→reveal→delete) and #7 (team +// invite→pending→revoke), driven through the ACTUAL dashboard. +// +// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3). +// +// Journey #6 — vault: +// Mint PRO. The dashboard's vault add/delete are agent-driven (VaultPage +// ships PromptCards, the only clickable mutation is the AUDITED reveal). So we +// add a secret via the real api (PUT /api/v1/vault/production/:key), render +// /app/vault → assert the secret row appears → click reveal → assert the +// audited reveal returns the value in the UI → delete the secret via the api → +// assert the row disappears. +// +// Journey #7 — team invite: +// Mint PRO (primary). Team invite/revoke are agent-driven (TeamPage ships +// PromptCards, no clickable invite button), so we drive the invite via the +// real api using a SECONDARY throwaway minted account as the invitee (the +// primary is never disturbed — matrix isolation rule). Render /app/team → +// assert the Pending invitation row renders → revoke via the api → assert the +// pending row clears in the UI. The secondary account is cascade-reaped. +// +// Safety machinery mirrors live-writes.spec.ts (rule 24). + +import { test, expect, type APIRequestContext } from '@playwright/test' + +import { assertSafeApiTarget } from './cohort' +import { loadLedger, reapEntities, clearLedger } from './cleanup-ledger' +import { mintUser, reap, factoryArmed, apiBase, type MintedUser } from './factory' +import { newAuthedContext, appURL } from './ui-helpers' + +const LIVE = process.env.E2E_LIVE === '1' +const API_URL = apiBase() + +const VAULT_ENV = 'production' +const VAULT_KEY = 'E2E_COHORT_SECRET' +const VAULT_VALUE = 'cohort-secret-value-do-not-ship' + +test.describe('LIVE-UI — vault reveal + team invite (journeys #6, #7)', () => { + test.describe.configure({ mode: 'serial' }) + + test.skip(!LIVE, 'E2E_LIVE!=1 — real-backend vault/team UI journeys are opt-in.') + test.skip(LIVE && !API_URL, 'E2E_LIVE=1 but E2E_API_URL is unset — no backend to target.') + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + + test.afterAll(async ({ playwright }) => { + const entities = loadLedger() + if (entities.length === 0) return + const ctx = await playwright.request.newContext() + try { + const result = await reapEntities(ctx, entities) + // eslint-disable-next-line no-console + console.log( + `[live-ui-team-vault afterAll] reaped attempted=${result.attempted} deleted=${result.deleted} ` + + `alreadyGone=${result.alreadyGone} failed=${result.failed.length}`, + ) + if (result.failed.length === 0) clearLedger() + } finally { + await ctx.dispose() + } + }) + + // ── Journey #6 — vault: add (api) → render → reveal (UI) → delete (api) ─────── + test('pro: vault secret renders → UI reveal returns the value → delete clears it', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const user = await mintUser(request, { tier: 'pro' }) + test.skip(user === null, 'mint endpoint not armed (404).') + const u = user as MintedUser + + // ADD — agent-driven in the UI → seed via the real api. + const put = await request.fetch(`${API_URL}/api/v1/vault/${VAULT_ENV}/${VAULT_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${u.sessionJWT}` }, + data: JSON.stringify({ value: VAULT_VALUE }), + failOnStatusCode: false, + }) + expect( + [200, 201].includes(put.status()), + `vault PUT should be 200/201; got ${put.status()}. Body: ${await put.text().catch(() => '')}`, + ).toBe(true) + + const { context, page } = await newAuthedContext(browser, { sessionJWT: u.sessionJWT, env: VAULT_ENV }) + try { + await page.goto(appURL('/app/vault'), { waitUntil: 'domcontentloaded' }) + + // RENDER — the secret row appears (listVault maps keys[] → rows). + const row = page.getByTestId(`vault-row-${VAULT_KEY}`) + await expect( + row, + 'the seeded vault secret row must render on /app/vault (proves listVault resolved against prod).', + ).toBeVisible({ timeout: 30_000 }) + + // REVEAL — the audited reveal (the only clickable vault mutation). Clicking + // it calls revealVaultSecret against the real api (which writes an audit + // row) and renders the plaintext value in the row. + await page.getByTestId(`reveal-${VAULT_KEY}`).click() + await expect( + row, + 'clicking reveal must render the secret value in the row (proves the audited reveal resolved against prod).', + ).toContainText(VAULT_VALUE, { timeout: 30_000 }) + } finally { + await context.close() + } + + // DELETE — agent-driven in the UI → delete via the real api → assert the row + // is gone on a fresh render. + const del = await request.fetch(`${API_URL}/api/v1/vault/${VAULT_ENV}/${VAULT_KEY}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${u.sessionJWT}` }, + failOnStatusCode: false, + }) + expect( + [200, 202, 204, 404].includes(del.status()), + `vault DELETE should be 2xx/404; got ${del.status()}.`, + ).toBe(true) + + const { context: c2, page: p2 } = await newAuthedContext(browser, { + sessionJWT: u.sessionJWT, + env: VAULT_ENV, + }) + try { + await p2.goto(appURL('/app/vault'), { waitUntil: 'domcontentloaded' }) + await expect( + p2.getByTestId(`vault-row-${VAULT_KEY}`), + 'after deletion the vault secret row must NOT render (proves the delete propagated to the UI read).', + ).toBeHidden({ timeout: 30_000 }) + } finally { + await c2.close() + } + + await reapUser(request, u) + }) + + // ── Journey #7 — team: invite (api) → pending row (UI) → revoke (api) ───────── + test('pro: invited member appears in the Team Pending list → revoke clears it', async ({ + browser, + request, + }) => { + test.skip(!factoryArmed(), 'E2E_ACCOUNT_TOKEN unset — cannot mint a cohort account.') + const primary = await mintUser(request, { tier: 'pro' }) + test.skip(primary === null, 'mint endpoint not armed (404).') + const p = primary as MintedUser + + // The invitee is a SECONDARY throwaway minted account, so the PRIMARY is + // never disturbed (member-mgmt isolation rule). Both cascade-reaped. + const secondary = await mintUser(request, { tier: 'free' }) + test.skip(secondary === null, 'could not mint a SECONDARY account for the invitee.') + const sec = secondary as MintedUser + + // INVITE — agent-driven in the UI → drive via the real api. + const invite = await request.fetch(`${API_URL}/api/v1/team/members/invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${p.sessionJWT}` }, + data: JSON.stringify({ email: sec.email, role: 'developer' }), + failOnStatusCode: false, + }) + expect( + invite.status(), + `team invite should be 201; got ${invite.status()}. Body: ${await invite.text().catch(() => '')}`, + ).toBe(201) + const invBody = (await invite.json()) as { invitation?: { id?: string } } + const invID = String(invBody.invitation?.id ?? '') + expect(invID, 'the invite must return an invitation id.').toBeTruthy() + + const { context, page } = await newAuthedContext(browser, { sessionJWT: p.sessionJWT }) + try { + await page.goto(appURL('/app/team'), { waitUntil: 'domcontentloaded' }) + + // PENDING ROW — the Team page renders the pending invitation (by email). + // The Pending column lists invitations; assert the invitee's email shows. + await expect( + page.getByText(sec.email, { exact: false }).first(), + 'the pending invitation must render in the Team page Pending list (proves listInvitations resolved against prod).', + ).toBeVisible({ timeout: 30_000 }) + // The "Pending · N" heading must reflect ≥1 invite. + await expect( + page.getByRole('heading', { name: /Pending · [1-9]/ }), + 'the Pending heading must show a non-zero count.', + ).toBeVisible() + } finally { + await context.close() + } + + // REVOKE — agent-driven in the UI → drive via the real api → assert the + // pending row clears on a fresh render. + const revoke = await request.fetch(`${API_URL}/api/v1/team/invitations/${invID}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${p.sessionJWT}` }, + failOnStatusCode: false, + }) + expect( + [200, 202, 204, 404].includes(revoke.status()), + `invitation revoke should be 2xx/404; got ${revoke.status()}.`, + ).toBe(true) + + const { context: c2, page: p2 } = await newAuthedContext(browser, { sessionJWT: p.sessionJWT }) + try { + await p2.goto(appURL('/app/team'), { waitUntil: 'domcontentloaded' }) + await expect( + p2.getByText(sec.email, { exact: false }), + 'after revoke the invitee email must NOT render in the Pending list (proves the revoke propagated to the UI).', + ).toBeHidden({ timeout: 30_000 }) + } finally { + await c2.close() + } + + // Reap both accounts (cascade). Order doesn't matter — each is independent. + await reap(request, sec.teamID) + await reapUser(request, p) + }) +}) + +async function reapUser(request: APIRequestContext, u: MintedUser): Promise { + await reap(request, u.teamID) + clearLedger() +} diff --git a/e2e/live-ui.coverage.ts b/e2e/live-ui.coverage.ts new file mode 100644 index 0000000..52f7aec --- /dev/null +++ b/e2e/live-ui.coverage.ts @@ -0,0 +1,59 @@ +// Coverage manifest for the Wave 3 real-backend UI journeys (live-ui-*.spec.ts). +// +// Extracted into a playwright-free sibling so the vitest prod-coverage done-bar +// guard (prod-coverage-donebar.test.ts) can union the covered-route set without +// the @playwright/test runtime. These are the api routes the UI journeys drive +// THROUGH THE BROWSER (or, for the agent-driven mutations the read-only +// dashboard delegates, via the api while the UI renders the result) — every one +// must be a 'live' flow in prod-coverage-manifest.ts (the guard's reverse-drift +// check enforces it). +// +// Journey → route mapping: +// #1 auth round-trip ....... GET /auth/me, POST /auth/email/start +// #2 resources view/logs ... GET /api/v1/resources, GET /api/v1/resources/:id, +// GET /api/v1/resources/:id/metrics, +// GET /api/v1/resources/:id/credentials, +// DELETE /api/v1/resources/:id +// #3 deploy lifecycle ...... POST /deploy/new, GET /api/v1/deployments, +// GET /api/v1/deployments/:id, GET /deploy/:id/logs, +// POST /api/v1/deployments/:id/make-permanent, +// DELETE /api/v1/deployments/:id +// #4 delete→replace ........ POST /deploy/new (+402 cap), GET /api/v1/deployments, +// DELETE /api/v1/deployments/:id +// #5 pause/resume .......... POST /api/v1/resources/:id/pause, +// POST /api/v1/resources/:id/resume +// #6 vault ................. PUT /api/v1/vault/:env/:key, GET /api/v1/vault/:env, +// GET /api/v1/vault/:env/:key +// #7 team invite ........... POST /api/v1/team/members/invite, +// GET /api/v1/team/invitations, +// DELETE /api/v1/team/invitations/:id +export const coveredRoutes: string[] = [ + // #1 auth round-trip (UI) + 'GET /auth/me', + 'POST /auth/email/start', + // #2 resources: view → metrics stream → creds reveal → delete (UI + api delete). + // Creds reveal renders the connection_url already in the getResource payload — + // no separate /credentials call — so only the routes the journey truly hits. + 'GET /api/v1/resources', + 'GET /api/v1/resources/:id', + 'GET /api/v1/resources/:id/metrics', + 'DELETE /api/v1/resources/:id', + // #3 deploy lifecycle + build LOGS (SSE) + make-permanent + 'POST /deploy/new', + 'GET /api/v1/deployments', + 'GET /api/v1/deployments/:id', + 'GET /deploy/:id/logs', + 'POST /api/v1/deployments/:id/make-permanent', + 'DELETE /api/v1/deployments/:id', + // #5 pause/resume (UI clickable mutations) + 'POST /api/v1/resources/:id/pause', + 'POST /api/v1/resources/:id/resume', + // #6 vault add/reveal/delete + 'PUT /api/v1/vault/:env/:key', + 'GET /api/v1/vault/:env', + 'GET /api/v1/vault/:env/:key', + // #7 team invite → pending → revoke + 'POST /api/v1/team/members/invite', + 'GET /api/v1/team/invitations', + 'DELETE /api/v1/team/invitations/:id', +] diff --git a/e2e/prod-coverage-donebar.test.ts b/e2e/prod-coverage-donebar.test.ts index 8300814..b087388 100644 --- a/e2e/prod-coverage-donebar.test.ts +++ b/e2e/prod-coverage-donebar.test.ts @@ -31,6 +31,7 @@ import { coveredRoutes as authCovered } from './live-auth.coverage' import { coveredRoutes as claimDeployCovered } from './live-claim-deploy.coverage' import { coveredRoutes as anonProvisionCovered } from './live-anon-provision.coverage' import { coveredRoutes as provisionSmokeCovered } from './live-provision-smoke.coverage' +import { coveredRoutes as uiJourneysCovered } from './live-ui.coverage' import { PROD_COVERAGE_MANIFEST, type ProdCoverageFlow } from './prod-coverage-manifest' // Each live-*.spec.ts re-exports its sibling's `coveredRoutes`; we union the @@ -44,6 +45,10 @@ const SPEC_MANIFESTS: ReadonlyArray<{ spec: string; covered: readonly string[] } { spec: 'live-claim-deploy.spec.ts', covered: claimDeployCovered }, { spec: 'live-anon-provision.spec.ts', covered: anonProvisionCovered }, { spec: 'live-provision-smoke.spec.ts', covered: provisionSmokeCovered }, + // Wave 3 — real-backend UI journeys (live-ui-*.spec.ts) covered set, mapped in + // live-ui.coverage.ts. Drives the routes THROUGH THE BROWSER (or via api for + // the agent-driven mutations the read-only dashboard delegates). + { spec: 'live-ui-*.spec.ts', covered: uiJourneysCovered }, ] /** Union of every route covered by some live-*.spec.ts. */ diff --git a/e2e/prod-coverage-manifest.ts b/e2e/prod-coverage-manifest.ts index fa3e038..f0a3ecb 100644 --- a/e2e/prod-coverage-manifest.ts +++ b/e2e/prod-coverage-manifest.ts @@ -127,10 +127,14 @@ const LIVE_FLOWS: LiveFlow[] = ( 'PATCH /stacks/:slug/env', // §1.K Deploy single-app + lifecycle — claim-deploy + W-DEPLOY (live-writes) + // + live-ui (build-log SSE) 'POST /deploy/new', 'GET /api/v1/deployments', 'GET /api/v1/deployments/:id', 'GET /api/v1/deployments/:id/events', + // Build-log SSE stream — driven through the DeployDetailPage Logs tab in the + // live-ui deploy journey (the LiveBuild panel's cross-origin SSE to prod). + 'GET /deploy/:id/logs', 'DELETE /api/v1/deployments/:id', 'DELETE /api/v1/deployments/:id/confirm-deletion', 'PATCH /api/v1/deployments/:id', diff --git a/e2e/ui-helpers.ts b/e2e/ui-helpers.ts new file mode 100644 index 0000000..86be753 --- /dev/null +++ b/e2e/ui-helpers.ts @@ -0,0 +1,129 @@ +// e2e/ui-helpers.ts — browser-driving helpers for the real-backend UI journeys. +// +// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3). The existing live +// specs are API-contract tests (they call the api via the `request` fixture). +// THIS wave is different: it renders the ACTUAL dashboard in the browser with a +// minted cohort session and clicks real buttons, so we catch UI-against-real- +// backend breakage (the login-broke class). These helpers wire the browser to +// the minted account + the prod api: +// +// 1. SAME-ORIGIN api base. The SPA is built with VITE_API_URL='' (relative +// base, src/api/index.ts getAPIBaseURL), and the preview server proxies api +// paths to the real prod api (vite.config.ts preview.proxy, gated on +// E2E_LIVE_PROXY_TARGET). So every browser fetch — including the SSE log +// streams (src/lib/sseStream.ts uses getAPIBaseURL + a Bearer header) — is +// SAME-ORIGIN and proxied to prod. This is REQUIRED, not a convenience: the +// prod api's CORS allowlist returns access-control-allow-origin ONLY for +// https://instanode.dev (AUTH-004), so a direct cross-origin fetch from the +// preview origin would be CORS-blocked ("Failed to fetch"). The proxy makes +// the harness exercise the REAL backend without the CORS wall a real user on +// instanode.dev never hits. (Discovered live 2026-06-06: the magic-link form, +// which uses a raw cross-origin fetch, failed with "Failed to fetch" until +// the same-origin proxy landed.) +// 2. The SPA authenticates from localStorage['instanode.token'] (TOKEN_KEY in +// src/api/index.ts). We seed it with the minted session JWT so /app renders +// authed (not the /login redirect) — the AuthGate is pure token-presence +// (App.tsx), then the page calls /auth/me against the real api (proxied). +// 3. The dashboard env tab reads localStorage['instanode.env'] +// (useDashboardCtx ENV_KEY); we pin it to 'production' (the api seeds +// resources/vault at the team's default env) so the UI reads line up with +// what the factory created. + +import type { Browser, BrowserContext, Page } from '@playwright/test' + +// localStorage keys the SPA reads. Named consts (no-hardcoded-strings rule), +// kept in sync with src/api/index.ts (TOKEN_KEY) + src/hooks/useDashboardCtx.ts +// (ENV_KEY). A drift here would make the seeded session invisible to the app. +export const TOKEN_LS_KEY = 'instanode.token' +export const ENV_LS_KEY = 'instanode.env' + +// The api-base override the SPA honours first — read by BOTH src/api/index.ts +// getAPIBaseURL() (the call() wrapper + SSE) AND src/pages/LoginPage.tsx +// resolveApiBase() (the magic-link raw fetch). We set it to the SAME-ORIGIN +// preview origin so every request the page issues stays same-origin and is +// proxied to prod (vite.config.ts preview.proxy) — sidestepping the CORS wall a +// direct cross-origin fetch from the preview origin would hit (AUTH-004 limits +// allow-origin to https://instanode.dev). Both code paths use `value || default`, +// so a non-empty same-origin value is what routes them through the proxy. +export const API_URL_WINDOW_KEY = '__INSTANODE_API_URL__' + +/** + * The web origin the preview server serves the SPA on. The live config's + * webServer boots `vite preview` here; baseURL in the config points at it so + * page.goto('/app') resolves against it. Overridable for a local run that + * already has a dev server up. + */ +export function uiWebOrigin(): string { + return (process.env.E2E_WEB_ORIGIN ?? 'http://localhost:4173').replace(/\/$/, '') +} + +interface AuthedPageOpts { + /** The minted session JWT to seed as the SPA bearer. */ + sessionJWT: string + /** Dashboard env tab to pin (default 'production' — the api's seed default). */ + env?: string +} + +/** + * Open a fresh browser context whose pages boot the SPA already (a) pointed at + * the real prod api and (b) authenticated as the minted cohort session. The + * init script runs BEFORE any app code on every navigation, so the very first + * /app render is authed (no /login flash) and every fetch/SSE targets prod. + * + * Returns the context + a ready page. The caller disposes the context in a + * finally (we keep one context per journey so localStorage is isolated). + */ +export async function newAuthedContext( + browser: Browser, + opts: AuthedPageOpts, +): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext() + const env = opts.env ?? 'production' + // addInitScript runs in page context before the app bundle — pin the + // same-origin api base (→ proxied to prod) + seed the auth token + env tab so + // the first paint is authed and every fetch/SSE stays same-origin. + await context.addInitScript( + ([apiKey, apiVal, tokKey, tokVal, envKey, envVal]) => { + try { + ;(window as unknown as Record)[apiKey] = apiVal + window.localStorage.setItem(tokKey, tokVal) + window.localStorage.setItem(envKey, envVal) + } catch { + /* storage unavailable — the spec's first assertion will surface it */ + } + }, + [API_URL_WINDOW_KEY, uiWebOrigin(), TOKEN_LS_KEY, opts.sessionJWT, ENV_LS_KEY, env] as const, + ) + const page = await context.newPage() + return { context, page } +} + +/** + * Open an UNAUTHENTICATED context (NO token) — used by the login-form leg (#1) + * so the magic-link form submits against the real api (same-origin via the + * preview proxy). Pins the same-origin api base so LoginPage's raw fetch routes + * through the proxy rather than direct-to-prod (which would hit the CORS wall). + */ +export async function newAnonContext( + browser: Browser, +): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext() + await context.addInitScript( + ([apiKey, apiVal]) => { + try { + ;(window as unknown as Record)[apiKey] = apiVal + } catch { + /* ignore */ + } + }, + [API_URL_WINDOW_KEY, uiWebOrigin()] as const, + ) + const page = await context.newPage() + return { context, page } +} + +/** Navigate to a dashboard path on the preview origin (e.g. '/app/resources'). */ +export function appURL(path: string): string { + const p = path.startsWith('/') ? path : `/${path}` + return `${uiWebOrigin()}${p}` +} diff --git a/package.json b/package.json index df091a9..ddc2d83 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:live": "playwright test --config=playwright.live.config.ts", + "test:e2e:live:pr-smoke": "playwright test --config=playwright.live.config.ts --grep @pr-smoke", "reap:live": "node e2e/reap-cohort.ts", "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.'" }, diff --git a/playwright.live.config.ts b/playwright.live.config.ts index e7306f5..2c0d9d8 100644 --- a/playwright.live.config.ts +++ b/playwright.live.config.ts @@ -8,9 +8,11 @@ // resources and reap them (rule 24). // // WS1-P1 ships exactly one spec under it — live-provision-smoke.spec.ts — which -// drives the api directly via the `request` fixture (no SPA needed yet), so no -// webServer is started. Later WS1 PRs (P2..P6) add UI-driven LIVE specs; when -// the first of those lands it will add a webServer block here gated on E2E_LIVE. +// drives the api directly via the `request` fixture (no SPA needed yet). Wave 3 +// (live-ui-*.spec.ts) adds UI-driven LIVE specs that render the ACTUAL dashboard +// in the browser against the real api; those need the SPA served, so a +// `vite preview` webServer is wired below (gated on E2E_LIVE so the api-direct +// specs still run with no server when E2E_LIVE is unset). // // Gating: the specs themselves self-skip (loudly) when E2E_LIVE!=1 or // E2E_API_URL is unset / returns 503, so this config is safe to wire into a @@ -51,12 +53,31 @@ export default defineConfig({ use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', - // No baseURL — specs use absolute E2E_API_URL so every request is explicit - // about which (staging) backend it targets. + // baseURL serves the UI journeys' page.goto('/app/...') against the + // preview origin (the SPA). The api-direct specs ignore it and use the + // absolute E2E_API_URL via the `request` fixture, so it's harmless there. + baseURL: process.env.E2E_WEB_ORIGIN || 'http://localhost:4173', }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - // No webServer in WS1-P1 (smoke is api-direct). Added by the first - // UI-driven LIVE spec in a later WS1 PR. + // webServer — only for the UI-driven LIVE journeys (live-ui-*.spec.ts), which + // render the real dashboard against the real api. Gated on E2E_LIVE so the + // api-direct LIVE specs (live-provision-smoke etc.) still run with NO server + // when E2E_LIVE is unset. `vite preview` serves the production SPA bundle on + // :4173 and PROXIES api paths to the real prod api (E2E_LIVE_PROXY_TARGET → + // vite.config.ts preview.proxy). The SPA's api base is pinned per-context to + // the SAME-ORIGIN preview origin (e2e/ui-helpers.ts), so every browser request + // is same-origin and forwarded to prod by the proxy — exercising the REAL + // backend WITHOUT the CORS wall a direct cross-origin fetch from the preview + // origin would hit (the prod api allows only https://instanode.dev as origin). + // When E2E_WEB_ORIGIN is set (a dev server is already up), the server is reused. + webServer: process.env.E2E_LIVE === '1' + ? { + command: `E2E_LIVE_PROXY_TARGET=${process.env.E2E_API_URL || 'https://api.instanode.dev'} npm run build && E2E_LIVE_PROXY_TARGET=${process.env.E2E_API_URL || 'https://api.instanode.dev'} npm run preview -- --port 4173 --strictPort`, + url: process.env.E2E_WEB_ORIGIN || 'http://localhost:4173', + reuseExistingServer: !process.env.CI || !!process.env.E2E_WEB_ORIGIN, + timeout: 240_000, + } + : undefined, }) diff --git a/src/components/PauseResumeButton.test.tsx b/src/components/PauseResumeButton.test.tsx index 8408c62..5436688 100644 --- a/src/components/PauseResumeButton.test.tsx +++ b/src/components/PauseResumeButton.test.tsx @@ -106,7 +106,7 @@ describe('PauseResumeButton — modal flow', () => { expect(api.resumeResource).not.toHaveBeenCalled() }) - it('confirming on an active resource calls api.pauseResource(id)', async () => { + it('confirming on an active resource calls api.pauseResource(token)', async () => { const onUpdated = vi.fn() ;(api.pauseResource as any).mockResolvedValue({ ok: true, @@ -116,7 +116,9 @@ describe('PauseResumeButton — modal flow', () => { fireEvent.click(screen.getByTestId('pause-resume-button')) fireEvent.click(screen.getByTestId('pause-resume-confirm')) await waitFor(() => { - expect(api.pauseResource).toHaveBeenCalledWith('res_abc123') + // Must address by TOKEN (the api resolves :id against the token column); + // passing the UUID id 404'd in prod (live-ui pause/resume journey, 2026-06-06). + expect(api.pauseResource).toHaveBeenCalledWith('tok_abc123') }) expect(api.resumeResource).not.toHaveBeenCalled() await waitFor(() => { @@ -126,7 +128,7 @@ describe('PauseResumeButton — modal flow', () => { }) }) - it('confirming on a paused resource calls api.resumeResource(id)', async () => { + it('confirming on a paused resource calls api.resumeResource(token)', async () => { const onUpdated = vi.fn() const paused: Resource = { ...baseResource, status: 'paused' } ;(api.resumeResource as any).mockResolvedValue({ @@ -137,7 +139,8 @@ describe('PauseResumeButton — modal flow', () => { fireEvent.click(screen.getByTestId('pause-resume-button')) fireEvent.click(screen.getByTestId('pause-resume-confirm')) await waitFor(() => { - expect(api.resumeResource).toHaveBeenCalledWith('res_abc123') + // Address by TOKEN, not the UUID id (see the pause test above). + expect(api.resumeResource).toHaveBeenCalledWith('tok_abc123') }) expect(api.pauseResource).not.toHaveBeenCalled() await waitFor(() => { diff --git a/src/components/PauseResumeButton.tsx b/src/components/PauseResumeButton.tsx index 0bb8fc9..8d177a5 100644 --- a/src/components/PauseResumeButton.tsx +++ b/src/components/PauseResumeButton.tsx @@ -110,9 +110,16 @@ export function PauseResumeButton({ setBusy(true) setErr(null) try { - const r = isPaused - ? await api.resumeResource(resource.id) - : await api.pauseResource(resource.id) + // Address the resource by its public TOKEN, not the internal UUID `id`. + // The agent API resolves /api/v1/resources/:id/{pause,resume} against the + // TOKEN column (the same as getResource / rotate / delete), so passing the + // UUID `id` here 404'd ("Resource not found") for EVERY resource — token + // and id differ on real resources — silently breaking pause/resume through + // the dashboard. Surfaced 2026-06-06 by the live-ui pause/resume journey + // (the only real-backend exercise of this path). Use resource.token, with + // a defensive fall-back to id for any legacy row missing a token. + const ref = resource.token || resource.id + const r = isPaused ? await api.resumeResource(ref) : await api.pauseResource(ref) onUpdated(r.resource) setOpen(false) } catch (e: any) { diff --git a/vite.config.ts b/vite.config.ts index b528beb..bd75f60 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,6 +58,25 @@ export default defineConfig({ port: 5173, proxy, }, + // preview.proxy — only used by the Wave 3 real-backend UI journeys + // (playwright.live.config.ts boots `vite preview`). The prod api's CORS + // allowlist returns access-control-allow-origin ONLY for https://instanode.dev + // (the AUTH-004 contract), so a browser on the preview origin (localhost:4173) + // would get its own fetches CORS-blocked ("Failed to fetch"). Proxying api + // paths through the preview server makes every browser request SAME-ORIGIN + // (no preflight, no CORS) — exactly how the dev server works — so the SPA (with + // a relative '' api base) talks to the REAL backend faithfully. Gated on + // E2E_LIVE_PROXY_TARGET so a normal `npm run preview` is unaffected. + preview: process.env.E2E_LIVE_PROXY_TARGET + ? { + port: 4173, + proxy: Object.fromEntries( + ['/api', '/auth', '/claim', '/start', '/db', '/cache', '/nosql', '/queue', '/storage', '/webhook', '/deploy', '/stacks', '/vector', '/.well-known', '/openapi.json', '/healthz', '/readyz', '/livez'].map( + (p) => [p, { target: process.env.E2E_LIVE_PROXY_TARGET as string, changeOrigin: true }], + ), + ), + } + : undefined, test: { globals: true, environment: 'jsdom', From 778a79a3df6dde02fb2256d5de87ddb3c3a65368 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 6 Jun 2026 04:18:51 +0530 Subject: [PATCH 2/2] test(e2e): drop unused mintUser import in live-ui-resources (code-quality) All three resource journeys use mintUserWithResources; mintUser was imported but never referenced. Flagged by github-code-quality on PR #192. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/live-ui-resources.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/live-ui-resources.spec.ts b/e2e/live-ui-resources.spec.ts index 98e05fa..1bca49f 100644 --- a/e2e/live-ui-resources.spec.ts +++ b/e2e/live-ui-resources.spec.ts @@ -29,7 +29,7 @@ import { test, expect, type APIRequestContext } from '@playwright/test' import { assertSafeApiTarget } from './cohort' import { loadLedger, reapEntities, clearLedger } from './cleanup-ledger' -import { mintUser, mintUserWithResources, reap, factoryArmed, apiBase, type MintedUser } from './factory' +import { mintUserWithResources, reap, factoryArmed, apiBase, type MintedUser } from './factory' import { newAuthedContext, appURL } from './ui-helpers' const LIVE = process.env.E2E_LIVE === '1'