diff --git a/.github/workflows/baseline-checks.yml b/.github/workflows/baseline-checks.yml index 047b269..4599e32 100644 --- a/.github/workflows/baseline-checks.yml +++ b/.github/workflows/baseline-checks.yml @@ -336,6 +336,167 @@ jobs: await github.rest.issues.createComment({ owner, repo, issue_number, body }); } + playwright-hosted-data-flow: + name: Playwright Hosted Data Flow + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + needs: [build-web] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check hosted E2E configuration + id: gate + env: + HOSTED_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + HOSTED_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} + run: | + if [ -z "$HOSTED_BASE_URL" ] || [ -z "$HOSTED_BROWSER_TOKEN" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + { + echo "## Playwright hosted data-flow" + echo "Skipped because VRDEX_HOSTED_E2E_BASE_URL or VRDEX_HOSTED_E2E_BROWSER_TOKEN is not configured." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm + if: steps.gate.outputs.enabled == 'true' + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + if: steps.gate.outputs.enabled == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + if: steps.gate.outputs.enabled == 'true' + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + if: steps.gate.outputs.enabled == 'true' + working-directory: apps/web + run: pnpm exec playwright install --with-deps chromium + + - name: Run hosted mutation-backed profile submission flow + if: steps.gate.outputs.enabled == 'true' + id: hosted + env: + PLAYWRIGHT_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + PLAYWRIGHT_RECORD_VIDEO: "true" + PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} + VRDEX_E2E_RUN_ID: pr-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ github.run_attempt }} + run: pnpm test:e2e:hosted + + - name: Write hosted data-flow summary + if: always() && steps.gate.outputs.enabled == 'true' + env: + HOSTED_OUTCOME: ${{ steps.hosted.outcome }} + HOSTED_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + mkdir -p apps/web/playwright-artifacts + cat > apps/web/playwright-artifacts/hosted-data-flow-summary.md < comment.user?.type === "Bot" && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + playwright-public-preview: name: Playwright Public Preview if: github.event_name == 'pull_request' diff --git a/.github/workflows/deployed-health.yml b/.github/workflows/deployed-health.yml new file mode 100644 index 0000000..63e6d5c --- /dev/null +++ b/.github/workflows/deployed-health.yml @@ -0,0 +1,230 @@ +name: Deployed Health Checks + +on: + deployment_status: + push: + branches: + - main + workflow_dispatch: + inputs: + target: + description: Which deployed health check to run + required: true + default: all + type: choice + options: + - all + - staging-mutation + - production-smoke + base_url: + description: Optional URL override for the selected target + required: false + type: string + schedule: + - cron: "17 11 * * *" + +permissions: + contents: read + +concurrency: + group: deployed-health-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + hosted-data-flow: + name: Hosted Data Flow Health + if: github.event_name == 'push' || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && (inputs.target == 'all' || inputs.target == 'staging-mutation')) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check hosted data-flow configuration + id: gate + env: + HOSTED_BASE_URL_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.target == 'staging-mutation' && inputs.base_url || '' }} + HOSTED_BASE_URL_VAR: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + HOSTED_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} + run: | + set -euo pipefail + + HOSTED_BASE_URL="${HOSTED_BASE_URL_OVERRIDE:-${HOSTED_BASE_URL_VAR:-}}" + + if [ -z "$HOSTED_BASE_URL" ] || [ -z "$HOSTED_BROWSER_TOKEN" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + { + echo "## Hosted data-flow health" + echo "Skipped because VRDEX_HOSTED_E2E_BASE_URL or VRDEX_HOSTED_E2E_BROWSER_TOKEN is not configured." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "base_url=$HOSTED_BASE_URL" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm + if: steps.gate.outputs.enabled == 'true' + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + if: steps.gate.outputs.enabled == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + if: steps.gate.outputs.enabled == 'true' + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + if: steps.gate.outputs.enabled == 'true' + working-directory: apps/web + run: pnpm exec playwright install --with-deps chromium + + - name: Run hosted mutation-backed profile submission flow + if: steps.gate.outputs.enabled == 'true' + id: hosted + env: + PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.base_url }} + PLAYWRIGHT_RECORD_VIDEO: "true" + PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} + VRDEX_E2E_RUN_ID: deployed-${{ github.run_id }}-${{ github.run_attempt }} + run: pnpm test:e2e:hosted + + - name: Write hosted data-flow summary + if: always() && steps.gate.outputs.enabled == 'true' + env: + HOSTED_OUTCOME: ${{ steps.hosted.outcome }} + HOSTED_BASE_URL: ${{ steps.gate.outputs.base_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + mkdir -p apps/web/playwright-artifacts + cat > apps/web/playwright-artifacts/hosted-data-flow-health-summary.md <> "$GITHUB_OUTPUT" + { + echo "## Production smoke health" + echo "Skipped because VRDEX_PRODUCTION_SMOKE_BASE_URL is not configured." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "base_url=$PRODUCTION_BASE_URL" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm + if: steps.gate.outputs.enabled == 'true' + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + if: steps.gate.outputs.enabled == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + if: steps.gate.outputs.enabled == 'true' + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + if: steps.gate.outputs.enabled == 'true' + working-directory: apps/web + run: pnpm exec playwright install --with-deps chromium + + - name: Run production read-only route smoke + if: steps.gate.outputs.enabled == 'true' + id: smoke + env: + PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.base_url }} + PLAYWRIGHT_RECORD_VIDEO: "true" + PLAYWRIGHT_SKIP_WEBSERVERS: "true" + run: pnpm test:e2e:hosted:smoke + + - name: Write production smoke summary + if: always() && steps.gate.outputs.enabled == 'true' + env: + SMOKE_OUTCOME: ${{ steps.smoke.outcome }} + PRODUCTION_BASE_URL: ${{ steps.gate.outputs.base_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + mkdir -p apps/web/playwright-artifacts + cat > apps/web/playwright-artifacts/production-smoke-health-summary.md < { - const e2eToken = process.env.VRDEX_E2E_BROWSER_TOKEN ?? "local-playwright-token"; - const runSuffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}`.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + const e2eToken = e2eBrowserToken(); + const runId = e2eRunId(testInfo); + const runSuffix = runId.replace(/^playwright-?/, "").slice(0, 48); const displayName = `Playwright Flow ${runSuffix}`; let createdSlug: string | undefined; @@ -14,6 +34,11 @@ test("profile submission writes through to public profile and discovery @flow", value: e2eToken, url: baseURL ?? "http://127.0.0.1:3002", }, + { + name: "vrdex_e2e_run_id", + value: runId, + url: baseURL ?? "http://127.0.0.1:3002", + }, ]); try { @@ -42,10 +67,10 @@ test("profile submission writes through to public profile and discovery @flow", await expect(page.getByText(displayName, { exact: true }).first()).toBeVisible(); await captureRouteScreenshot(page, testInfo, "profile-submission-flow-discovery"); } finally { - if (createdSlug) { + if (createdSlug || runId) { const cleanupResponse = await request.delete("/api/e2e/profile-submissions", { headers: { "x-vrdex-e2e-token": e2eToken }, - data: { slug: createdSlug }, + data: createdSlug ? { slug: createdSlug, runId } : { runId }, }); await expect(cleanupResponse).toBeOK(); @@ -54,7 +79,7 @@ test("profile submission writes through to public profile and discovery @flow", }); test("E2E profile helper stays gated without the browser token @flow", async ({ page, request }) => { - const e2eToken = process.env.VRDEX_E2E_BROWSER_TOKEN ?? "local-playwright-token"; + const e2eToken = e2eBrowserToken(); const payload = { runId: "playwright-negative-gate", profileType: "person", diff --git a/apps/web/package.json b/apps/web/package.json index 670c625..20edac9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,8 @@ "start": "next start", "lint": "eslint", "test:e2e": "playwright test --grep-invert \"@visual|@flow|@snapshot\"", + "test:e2e:hosted": "playwright test --grep @flow --project=desktop-chromium", + "test:e2e:hosted:smoke": "playwright test --grep-invert \"@visual|@flow|@snapshot\"", "test:e2e:snapshots": "playwright test --grep @snapshot", "test:e2e:snapshots:update": "playwright test --grep @snapshot --update-snapshots", "test:e2e:visual": "playwright test --grep @visual", diff --git a/apps/web/playwright.config.mjs b/apps/web/playwright.config.mjs index d8b2053..27615b0 100644 --- a/apps/web/playwright.config.mjs +++ b/apps/web/playwright.config.mjs @@ -6,19 +6,23 @@ const configDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(configDir, "..", ".."); const parsedPort = Number(process.env.PLAYWRIGHT_TEST_PORT); const port = Number.isFinite(parsedPort) ? parsedPort : 3002; -const baseURL = `http://127.0.0.1:${port}`; +const hostedBaseURL = process.env.PLAYWRIGHT_BASE_URL?.trim().replace(/\/+$/, ""); +const baseURL = hostedBaseURL || `http://127.0.0.1:${port}`; const convexUrl = process.env.PLAYWRIGHT_CONVEX_URL ?? "http://127.0.0.1:3210"; const convexPort = Number(new URL(convexUrl).port) || 3210; const reuseNextServer = process.env.PLAYWRIGHT_REUSE_SERVER === "true"; const reuseConvexServer = process.env.PLAYWRIGHT_REUSE_CONVEX === "true"; -const skipConvexServer = process.env.PLAYWRIGHT_SKIP_CONVEX_DEV === "true"; +const skipWebServers = process.env.PLAYWRIGHT_SKIP_WEBSERVERS === "true" || Boolean(hostedBaseURL); +const skipConvexServer = skipWebServers || process.env.PLAYWRIGHT_SKIP_CONVEX_DEV === "true"; const recordVideo = process.env.PLAYWRIGHT_RECORD_VIDEO === "true"; const allowFixtureSearchFallthrough = process.env.VRDEX_ALLOW_PLAYWRIGHT_FIXTURE_SEARCH_FALLTHROUGH === "true" || process.env.VRDEX_ENABLE_E2E_HELPERS === "true"; -process.env.CONVEX_URL = convexUrl; -process.env.NEXT_PUBLIC_CONVEX_URL = convexUrl; +if (!hostedBaseURL) { + process.env.CONVEX_URL = convexUrl; + process.env.NEXT_PUBLIC_CONVEX_URL = convexUrl; +} const sharedEnv = { ...process.env, @@ -62,14 +66,18 @@ export default defineConfig({ env: sharedEnv, }, ]), - { - command: `node ../../scripts/sync-convex-local-env.mjs && node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port ${port}`, - cwd: configDir, - url: baseURL, - reuseExistingServer: reuseNextServer, - timeout: 300_000, - env: sharedEnv, - }, + ...(skipWebServers + ? [] + : [ + { + command: `node ../../scripts/sync-convex-local-env.mjs && node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port ${port}`, + cwd: configDir, + url: baseURL, + reuseExistingServer: reuseNextServer, + timeout: 300_000, + env: sharedEnv, + }, + ]), ], projects: [ { diff --git a/apps/web/src/app/api/e2e/profile-submissions/route.ts b/apps/web/src/app/api/e2e/profile-submissions/route.ts index cd5d596..072ed61 100644 --- a/apps/web/src/app/api/e2e/profile-submissions/route.ts +++ b/apps/web/src/app/api/e2e/profile-submissions/route.ts @@ -13,8 +13,9 @@ function requireE2eRequest(request: NextRequest) { const browserToken = process.env.VRDEX_E2E_BROWSER_TOKEN?.trim(); const convexSecret = process.env.VRDEX_E2E_CONVEX_SECRET?.trim(); const requestToken = request.headers.get("x-vrdex-e2e-token") ?? request.cookies.get("vrdex_e2e_token")?.value; + const productionBlocked = process.env.VERCEL_ENV === "production" && process.env.VRDEX_ALLOW_PRODUCTION_E2E_HELPERS !== "true"; - if (process.env.VRDEX_ENABLE_E2E_HELPERS !== "true" || !browserToken || !convexSecret || requestToken !== browserToken) { + if (productionBlocked || process.env.VRDEX_ENABLE_E2E_HELPERS !== "true" || !browserToken || !convexSecret || requestToken !== browserToken) { return null; } @@ -76,9 +77,16 @@ export async function DELETE(request: NextRequest) { } const body = rawBody as Record; - const result = await convexClient().mutation(api.e2e.cleanupProfileBySlug, { - slug: String(body.slug ?? ""), - }); + const slug = typeof body.slug === "string" ? body.slug.trim() : ""; + const runId = typeof body.runId === "string" ? body.runId.trim() : ""; + + if (!slug && !runId) { + return e2eError("Cleanup requires a slug or runId.", 400); + } + + const result = slug + ? await convexClient().mutation(api.e2e.cleanupProfileBySlug, { slug }) + : await convexClient().mutation(api.e2e.cleanupProfilesByRunId, { runId }); return NextResponse.json(result); } diff --git a/apps/web/src/app/submit/profile-submission-form.tsx b/apps/web/src/app/submit/profile-submission-form.tsx index 3d6a903..b935bf8 100644 --- a/apps/web/src/app/submit/profile-submission-form.tsx +++ b/apps/web/src/app/submit/profile-submission-form.tsx @@ -292,7 +292,20 @@ function ConnectedSubmissionForm() { } function E2eSubmissionForm() { - const [runId] = useState(() => `playwright-${crypto.randomUUID()}`); + const [runId] = useState(() => { + if (typeof document === "undefined") { + return `playwright-${crypto.randomUUID()}`; + } + + const cookieName = "vrdex_e2e_run_id="; + const cookieRunId = document.cookie + .split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(cookieName)) + ?.slice(cookieName.length); + + return cookieRunId ? decodeURIComponent(cookieRunId) : `playwright-${crypto.randomUUID()}`; + }); return ( ) { + if (!profile.sourceAttribution?.submitter.tokenIdentifier.startsWith("e2e:")) { + throw new Error("Only E2E-created profiles can be cleaned up by this helper."); + } + + const [searchDocuments, auditEvents] = await Promise.all([ + ctx.db.query("searchDocuments").withIndex("by_profileId", (query) => query.eq("profileId", profile._id)).collect(), + ctx.db.query("profileAuditEvents").withIndex("by_profileId_createdAt", (query) => query.eq("profileId", profile._id)).collect(), + ]); + + await Promise.all([ + ...searchDocuments.map((document) => ctx.db.delete(document._id)), + ...auditEvents.map((event) => ctx.db.delete(event._id)), + ctx.db.delete(profile._id), + ]); +} + export const submitProfile = mutation({ args: { runId: v.string(), @@ -118,21 +136,34 @@ export const cleanupProfileBySlug = mutation({ return { deleted: false }; } - if (!profile.sourceAttribution?.submitter.tokenIdentifier.startsWith("e2e:")) { - throw new Error("Only E2E-created profiles can be cleaned up by this helper."); + await deleteE2eProfile(ctx, profile); + + return { deleted: true }; + }, +}); + +export const cleanupProfilesByRunId = mutation({ + args: { + runId: v.string(), + }, + handler: async (ctx, args) => { + requireE2eHelper(); + + const runId = args.runId.trim().slice(0, 120); + + if (!runId) { + throw new Error("E2E cleanup requires a runId."); } - const [searchDocuments, auditEvents] = await Promise.all([ - ctx.db.query("searchDocuments").withIndex("by_profileId", (query) => query.eq("profileId", profile._id)).collect(), - ctx.db.query("profileAuditEvents").withIndex("by_profileId_createdAt", (query) => query.eq("profileId", profile._id)).collect(), - ]); + const sourceToken = `e2e:${runId.slice(0, 80)}`; + const profiles = await ctx.db + .query("profiles") + .withIndex("by_sourceSubmitterTokenIdentifier", (query) => query.eq("sourceAttribution.submitter.tokenIdentifier", sourceToken)) + .collect(); + const matchingProfiles = profiles.filter((profile) => profile.sourceAttribution?.submitter.subject === runId); - await Promise.all([ - ...searchDocuments.map((document) => ctx.db.delete(document._id)), - ...auditEvents.map((event) => ctx.db.delete(event._id)), - ctx.db.delete(profile._id), - ]); + await Promise.all(matchingProfiles.map((profile) => deleteE2eProfile(ctx, profile))); - return { deleted: true }; + return { deleted: matchingProfiles.length }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 6a397c5..a50ef74 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -333,6 +333,9 @@ export default defineSchema({ "publicSurfacingState", "publicationState", ]) + .index("by_sourceSubmitterTokenIdentifier", [ + "sourceAttribution.submitter.tokenIdentifier", + ]) .index("by_claimState_profileType", ["claimState", "profileType"]) .index("by_creationSource_claimState", ["creationSource", "claimState"]) .index("by_profileType_sortName", ["profileType", "sortName"]), diff --git a/docs/testing/playwright-visual-preview.md b/docs/testing/playwright-visual-preview.md index 68e86c5..9508c1b 100644 --- a/docs/testing/playwright-visual-preview.md +++ b/docs/testing/playwright-visual-preview.md @@ -11,6 +11,8 @@ See `docs/testing/playwright-image-diffing.md` for the committed-baseline image - Compare public route screenshots against baselines: `pnpm test:e2e:snapshots` - Update public route screenshot baselines: `pnpm test:e2e:snapshots:update` - Reuse already-running local services: set `PLAYWRIGHT_REUSE_SERVER=true` and `PLAYWRIGHT_REUSE_CONVEX=true` +- Run the mutation-backed flow against a hosted dev/staging target: set `PLAYWRIGHT_BASE_URL` and `VRDEX_E2E_BROWSER_TOKEN`, then run `pnpm test:e2e:hosted` +- Run read-only smoke against a hosted production target: set `PLAYWRIGHT_BASE_URL`, then run `pnpm test:e2e:hosted:smoke` PowerShell data-flow run with video: @@ -24,7 +26,31 @@ POSIX shell data-flow run with video: VRDEX_ENABLE_E2E_HELPERS=true VRDEX_E2E_BROWSER_TOKEN=local-playwright-token VRDEX_E2E_CONVEX_SECRET=local-convex-e2e-secret PLAYWRIGHT_RECORD_VIDEO=true pnpm --filter web exec playwright test --grep @flow --project=desktop-chromium ``` -The visual suite starts a local Convex backend and Next dev server by default. Profile screenshots use deterministic Next-server fixtures when `VRDEX_ENABLE_PLAYWRIGHT_FIXTURES=true`, while `/server-status` still exercises the real local Convex health query. Fixture profiles are disabled when `NODE_ENV=production`. +PowerShell hosted dev/staging data-flow run: + +```powershell +$env:PLAYWRIGHT_BASE_URL="https://dev.example.test"; $env:PLAYWRIGHT_SKIP_WEBSERVERS="true"; $env:VRDEX_E2E_BROWSER_TOKEN=""; $env:VRDEX_E2E_RUN_ID="manual-$(Get-Date -Format yyyyMMddHHmmss)"; pnpm test:e2e:hosted +``` + +POSIX shell hosted dev/staging data-flow run: + +```sh +PLAYWRIGHT_BASE_URL=https://dev.example.test PLAYWRIGHT_SKIP_WEBSERVERS=true VRDEX_E2E_BROWSER_TOKEN= VRDEX_E2E_RUN_ID="manual-$(date +%Y%m%d%H%M%S)" pnpm test:e2e:hosted +``` + +PowerShell hosted production smoke run: + +```powershell +$env:PLAYWRIGHT_BASE_URL="https://vrdex.net"; $env:PLAYWRIGHT_SKIP_WEBSERVERS="true"; pnpm test:e2e:hosted:smoke +``` + +POSIX shell hosted production smoke run: + +```sh +PLAYWRIGHT_BASE_URL=https://vrdex.net PLAYWRIGHT_SKIP_WEBSERVERS=true pnpm test:e2e:hosted:smoke +``` + +The visual suite starts a local Convex backend and Next dev server by default. Setting `PLAYWRIGHT_BASE_URL` switches Playwright to hosted mode and disables local web servers. Profile screenshots use deterministic Next-server fixtures when `VRDEX_ENABLE_PLAYWRIGHT_FIXTURES=true`, while `/server-status` still exercises the real local Convex health query. Fixture profiles are disabled when `NODE_ENV=production`. ## Captured routes @@ -68,6 +94,18 @@ The helper route is disabled unless all of these are true: Do not enable these helpers in production. They are for local, CI, and disposable preview/dev deployments. +Hosted dev/staging targets must be configured outside this repository before running `pnpm test:e2e:hosted`: + +- Next/Vercel env: `VRDEX_ENABLE_E2E_HELPERS=true` +- Next/Vercel env: `VRDEX_E2E_BROWSER_TOKEN=` +- Next/Vercel env: `VRDEX_E2E_CONVEX_SECRET=` +- Convex env: `VRDEX_ENABLE_E2E_HELPERS=true` +- Convex env: `VRDEX_E2E_CONVEX_SECRET=` + +`VERCEL_ENV=production` blocks the E2E route unless `VRDEX_ALLOW_PRODUCTION_E2E_HELPERS=true` is explicitly set. Keep that override unset for VRDex production. + +Each data-flow run uses a unique `VRDEX_E2E_RUN_ID` prefix and creates only `e2e:`-attributed profiles. Cleanup deletes by slug on the happy path and can fall back to deleting profiles for the run ID if the slug was not captured. + ## CI behavior The `Playwright Public Preview` job is required on pull requests. It: @@ -81,3 +119,17 @@ This blocks PRs when public route rendering or screenshot capture fails. Pixel r The `Playwright Image Diff` job is also required on pull requests. It runs the `@snapshot` suite against committed PNG baselines under `apps/web/e2e/__screenshots__`, uploads expected/actual/diff artifacts on failure, and comments with only the added or modified committed baseline images. The `Playwright Data Flow` job is also required on pull requests. It runs the `@flow` test against local Convex and the local Next dev server with `PLAYWRIGHT_RECORD_VIDEO=true`, then uploads screenshots, traces, and videos as the `playwright-data-flow` artifact and posts a PR comment with the artifact link. + +The optional `Playwright Hosted Data Flow` job runs on pull requests only when both repository settings are present: + +- repository variable `VRDEX_HOSTED_E2E_BASE_URL` +- repository secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` + +When configured, the job runs `pnpm test:e2e:hosted` with `PLAYWRIGHT_BASE_URL`, `PLAYWRIGHT_SKIP_WEBSERVERS=true`, `PLAYWRIGHT_RECORD_VIDEO=true`, and a GitHub Actions run-scoped `VRDEX_E2E_RUN_ID`. + +The `Deployed Health Checks` workflow runs after merges to `main`, after successful GitHub deployment status events for production deployments, on a daily schedule, and through manual dispatch. It has two independent checks: + +- `Hosted Data Flow Health` uses `VRDEX_HOSTED_E2E_BASE_URL` and `VRDEX_HOSTED_E2E_BROWSER_TOKEN` to run the mutation-backed hosted flow against a dev/staging target. +- `Production Smoke Health` uses the production deployment status URL when the workflow was triggered by a successful production deployment, otherwise `VRDEX_PRODUCTION_SMOKE_BASE_URL`, to run read-only public route smoke against production. + +Manual dispatch can run `all`, `staging-mutation`, or `production-smoke`. The optional `base_url` override applies only when dispatching a single selected target. The deployed health workflow uploads artifacts and fails the workflow on test failure, but it does not create GitHub issues automatically. diff --git a/infra/terraform/ses/README.md b/infra/terraform/ses/README.md index c64bc75..36029ae 100644 --- a/infra/terraform/ses/README.md +++ b/infra/terraform/ses/README.md @@ -25,9 +25,9 @@ Terraform state for this stack is stored in the S3 backend declared in `versions - bucket: `vrdex-terraform-state` - key: `ses/terraform.tfstate` - region: `us-east-1` -- lock table: `vrdex-terraform-locks` +- locking: S3 native lockfile (`use_lockfile = true`) -The backend bucket has versioning, default SSE-S3 encryption, blocked public access, and a policy that denies non-TLS requests. The DynamoDB table uses on-demand billing with `LockID` as the partition key. +The backend bucket has versioning, default SSE-S3 encryption, blocked public access, and a policy that denies non-TLS requests. The previous DynamoDB lock table `vrdex-terraform-locks` is no longer used by this backend and can be considered for removal only after confirming no other Terraform stack still references it. Convex env values after apply: diff --git a/infra/terraform/ses/versions.tf b/infra/terraform/ses/versions.tf index 9780958..615fc26 100644 --- a/infra/terraform/ses/versions.tf +++ b/infra/terraform/ses/versions.tf @@ -1,12 +1,12 @@ terraform { - required_version = ">= 1.6.0" + required_version = ">= 1.10.0" backend "s3" { - bucket = "vrdex-terraform-state" - key = "ses/terraform.tfstate" - region = "us-east-1" - dynamodb_table = "vrdex-terraform-locks" - encrypt = true + bucket = "vrdex-terraform-state" + key = "ses/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true } required_providers { diff --git a/package.json b/package.json index 36e4907..18bddeb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "check:web:vercel-env": "pnpm --filter web check:vercel-env", "lint:web": "pnpm --filter web lint", "test:e2e": "pnpm --filter web test:e2e", + "test:e2e:hosted": "pnpm --filter web test:e2e:hosted", + "test:e2e:hosted:smoke": "pnpm --filter web test:e2e:hosted:smoke", "test:e2e:snapshots": "pnpm --filter web test:e2e:snapshots", "test:e2e:snapshots:update": "pnpm --filter web test:e2e:snapshots:update", "test:e2e:visual": "pnpm --filter web test:e2e:visual", diff --git a/scripts/run-convex-local.mjs b/scripts/run-convex-local.mjs index 0993192..f3496be 100644 --- a/scripts/run-convex-local.mjs +++ b/scripts/run-convex-local.mjs @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,13 +10,56 @@ const convexTmp = path.join(repoRoot, ".convex-tmp"); const repoEnvLocalPath = path.join(repoRoot, ".env.local"); const webEnvLocalPath = path.join(repoRoot, "apps", "web", ".env.local"); const args = process.argv.slice(2); +const usesLocalDeployment = args.includes("--local"); +const isLocalDevCommand = args[0] === "dev" && usesLocalDeployment; +const localDeploymentName = process.env.CONVEX_LOCAL_DEPLOYMENT_NAME || "anonymous-agent"; +const localCloudPort = process.env.CONVEX_LOCAL_CLOUD_PORT || "3210"; +const localSitePort = process.env.CONVEX_LOCAL_SITE_PORT || "3211"; +const localConvexUrl = process.env.CONVEX_LOCAL_URL || `http://127.0.0.1:${localCloudPort}`; +const shouldRestoreRepoEnvLocal = usesLocalDeployment && (!process.stdin.isTTY || process.env.CI === "true"); +const repoEnvLocalSnapshot = shouldRestoreRepoEnvLocal + ? existsSync(repoEnvLocalPath) + ? readFileSync(repoEnvLocalPath, "utf8") + : null + : undefined; + +function hasArg(name) { + return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); +} + +function convexCliArgs() { + if (!isLocalDevCommand) { + return args; + } + + const localFlags = []; + + if (!hasArg("--local-cloud-port") && !hasArg("--local-site-port")) { + localFlags.push("--local-cloud-port", localCloudPort, "--local-site-port", localSitePort); + } + + if (!hasArg("--local-force-upgrade") && (!process.stdin.isTTY || process.env.CI === "true")) { + localFlags.push("--local-force-upgrade"); + } + + return [args[0], ...localFlags, ...args.slice(1)]; +} + +const convexArgs = convexCliArgs(); function syncPublicConvexUrl() { - if (!existsSync(repoEnvLocalPath)) { + if (usesLocalDeployment) { + writeNextPublicConvexUrl(localConvexUrl); return; } - const file = readFileSync(repoEnvLocalPath, "utf8"); + const sourceEnvPath = repoEnvLocalPath; + + if (!existsSync(sourceEnvPath)) { + return; + } + + const file = readFileSync(sourceEnvPath, "utf8"); const lines = file.split(/\r?\n/); const convexUrlLine = lines.find((line) => line.startsWith("CONVEX_URL=")); @@ -34,6 +77,10 @@ function syncPublicConvexUrl() { return; } + writeNextPublicConvexUrl(convexUrl); +} + +function writeNextPublicConvexUrl(convexUrl) { const nextPublicLine = `NEXT_PUBLIC_CONVEX_URL=${convexUrl}`; if (existsSync(webEnvLocalPath)) { @@ -60,11 +107,33 @@ function syncPublicConvexUrl() { writeFileSync(webEnvLocalPath, `${nextPublicLine}\n`); } +function restoreRepoEnvLocal() { + if (!shouldRestoreRepoEnvLocal) { + return; + } + + if (repoEnvLocalSnapshot === null) { + rmSync(repoEnvLocalPath, { force: true }); + return; + } + + writeFileSync(repoEnvLocalPath, repoEnvLocalSnapshot); +} + function convexEnv() { return { ...process.env, CONVEX_AGENT_MODE: "anonymous", CONVEX_TMPDIR: convexTmp, + ...(usesLocalDeployment + ? { + CONVEX_DEPLOYMENT: `local:${localDeploymentName}`, + CONVEX_DEPLOY_KEY: "", + CONVEX_SELF_HOSTED_ADMIN_KEY: "", + CONVEX_SELF_HOSTED_URL: "", + CONVEX_URL: localConvexUrl, + } + : {}), TMPDIR: convexTmp, TEMP: convexTmp, TMP: convexTmp, @@ -100,7 +169,7 @@ const convexBin = path.join( process.platform === "win32" ? "convex.cmd" : "convex", ); -const child = spawn(convexBin, args, { +const child = spawn(convexBin, convexArgs, { cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32", @@ -117,6 +186,7 @@ try { child.kill(); } + restoreRepoEnvLocal(); process.exit(1); } @@ -135,8 +205,11 @@ try { const message = error instanceof Error ? error.message : String(error); try { - publicUrlWatcher = watch(repoRoot, (_eventType, filename) => { - if (filename !== ".env.local") { + const watchDir = repoRoot; + const watchedFileName = path.basename(repoEnvLocalPath); + + publicUrlWatcher = watch(watchDir, (_eventType, filename) => { + if (filename !== watchedFileName) { return; } @@ -176,6 +249,7 @@ process.on("SIGTERM", () => { child.on("error", (error) => { const code = error.code ? ` [${error.code}]` : ""; console.error(`Failed to spawn Convex CLI (${convexBin})${code}: ${error.message}`); + restoreRepoEnvLocal(); process.exit(1); }); @@ -189,6 +263,8 @@ child.on("exit", (code, signal) => { console.error(`Failed to finalize NEXT_PUBLIC_CONVEX_URL mirror: ${message}`); } + restoreRepoEnvLocal(); + if (signal) { if (process.platform !== "win32") { process.kill(process.pid, signal); diff --git a/scripts/sync-convex-local-env.mjs b/scripts/sync-convex-local-env.mjs index 107a634..e89dd8e 100644 --- a/scripts/sync-convex-local-env.mjs +++ b/scripts/sync-convex-local-env.mjs @@ -8,6 +8,9 @@ const repoRoot = path.resolve(scriptDir, ".."); const convexHome = path.join(repoRoot, ".convex-home"); const convexTmp = path.join(repoRoot, ".convex-tmp"); const localConvexEnvNames = ["VRDEX_ENABLE_E2E_HELPERS", "VRDEX_E2E_CONVEX_SECRET"]; +const localDeploymentName = process.env.CONVEX_LOCAL_DEPLOYMENT_NAME || "anonymous-agent"; +const localCloudPort = process.env.CONVEX_LOCAL_CLOUD_PORT || "3210"; +const localConvexUrl = process.env.CONVEX_LOCAL_URL || `http://127.0.0.1:${localCloudPort}`; const convexBin = path.join( repoRoot, "node_modules", @@ -19,6 +22,11 @@ function convexEnv() { return { ...process.env, CONVEX_AGENT_MODE: "anonymous", + CONVEX_DEPLOYMENT: `local:${localDeploymentName}`, + CONVEX_DEPLOY_KEY: "", + CONVEX_SELF_HOSTED_ADMIN_KEY: "", + CONVEX_SELF_HOSTED_URL: "", + CONVEX_URL: localConvexUrl, CONVEX_TMPDIR: convexTmp, TMPDIR: convexTmp, TEMP: convexTmp,