diff --git a/.github/workflows/e2e-blog-loop.yml b/.github/workflows/e2e-blog-loop.yml new file mode 100644 index 00000000..2e10e5d3 --- /dev/null +++ b/.github/workflows/e2e-blog-loop.yml @@ -0,0 +1,168 @@ +name: E2E Blog Loop (canary) + +# Full "write a blog post" canary. Sibling to e2e-smoke.yml — runs the +# tools/e2e/tests/full-blog-loop.spec.ts journey end-to-end (login → +# author → publish → log out → assert public render → assert SEO). +# +# Advisory initially (continue-on-error: true). Promotion to a +# required check happens once the journey lands three consecutive +# green PRs without manual reruns — the gate flip is a one-line +# workflow edit and a branch-protection update. +# +# Triggered on PRs touching the apps or shared packages, since those +# are the moving parts of the publish loop. Changes scoped purely to +# docs, ops scripts, or CLI surfaces don't run this workflow. + +on: + pull_request: + branches: [main] + paths: + - 'apps/**' + - 'packages/**' + - 'tools/e2e/**' + - '.github/workflows/e2e-blog-loop.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + blog-loop: + name: e2e-blog-loop + runs-on: ubuntu-latest + timeout-minutes: 30 + # Advisory: the job runs to completion and reports; a failure + # does not gate the PR until the canary is promoted. Once we see + # three consecutive green runs on unrelated PRs, drop this line + # and require the check in branch protection. + continue-on-error: true + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: gonext + POSTGRES_PASSWORD: gonext_dev_only + POSTGRES_DB: gonext_dev + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U gonext -d gonext_dev" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.work' + cache: true + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + + - name: Install JS deps + run: pnpm install --frozen-lockfile + + - name: Install e2e deps + # tools/e2e is outside the pnpm workspace (see #241) so we + # install it independently rather than relying on a + # workspace filter. + working-directory: tools/e2e + run: pnpm install + + - name: Install Playwright browsers + working-directory: tools/e2e + run: pnpm exec playwright install --with-deps chromium + + - name: Install psql client + # `freshDatabase()` uses psql to TRUNCATE state between + # tests. Without it the helper falls back to the dev-only + # REST reset endpoint, which is fine but less direct. + run: sudo apt-get update && sudo apt-get install -y postgresql-client + + - name: Build the stack + # `make up` requires docker compose; on the GitHub-hosted + # runner we boot the API + admin + web processes directly + # against the Postgres/Redis services declared above. This + # is the minimal slice the canary needs. + run: | + make build-go + (cd apps/api && ./api &) >/tmp/api.log 2>&1 + (cd apps/admin && pnpm dev &) >/tmp/admin.log 2>&1 + (cd apps/web && pnpm dev &) >/tmp/web.log 2>&1 + env: + DATABASE_URL: postgres://gonext:gonext_dev_only@localhost:5432/gonext_dev?sslmode=disable + REDIS_URL: redis://localhost:6379 + GONEXT_DEV_RESET: '1' + + - name: Wait for the stack + run: | + for i in {1..30}; do + if curl -sf http://localhost:8080/healthz && curl -sf http://localhost:3000; then + echo "stack up" + exit 0 + fi + sleep 2 + done + echo "stack did not come up in time" + tail -n +1 /tmp/api.log /tmp/admin.log /tmp/web.log || true + exit 1 + + - name: Run blog loop + working-directory: tools/e2e + env: + CI: 'true' + E2E_FRESH_INSTALL: '1' + E2E_ALLOW_DESTRUCTIVE: '1' + E2E_BASE_URL: http://localhost:3000 + E2E_API_BASE_URL: http://localhost:8080 + E2E_PG_HOST: localhost + E2E_PG_PORT: '5432' + E2E_PG_USER: gonext + E2E_PG_PASSWORD: gonext_dev_only + E2E_PG_DATABASE: gonext_dev + run: pnpm exec playwright test tests/full-blog-loop.spec.ts --project=chromium + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-blog-loop + path: tools/e2e/playwright-report + retention-days: 14 + + - name: Upload stack logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: stack-logs-blog-loop + path: | + /tmp/api.log + /tmp/admin.log + /tmp/web.log + retention-days: 7 diff --git a/Makefile b/Makefile index 4a478c85..5083fc7d 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,15 @@ e2e-smoke: ## Run the fresh-install happy-path smoke against a running stack. @# stray invocation can't nuke a real one. @cd tools/e2e && E2E_ALLOW_DESTRUCTIVE=1 pnpm run e2e:smoke +.PHONY: e2e-blog-loop +e2e-blog-loop: ## Run the full "write a blog post" canary against a running stack. + @echo "==> Running e2e blog loop (tools/e2e/tests/full-blog-loop.spec.ts)" + @# Same destructive guard as e2e-smoke: the spec depends on + @# globalSetup running gonext init, which TRUNCATEs the e2e + @# database. The E2E_ALLOW_DESTRUCTIVE flag is the failsafe. + @cd tools/e2e && E2E_FRESH_INSTALL=1 E2E_ALLOW_DESTRUCTIVE=1 \ + pnpm exec playwright test tests/full-blog-loop.spec.ts --project=chromium + # --------------------------------------------------------------------------- # Lint diff --git a/docs/19-e2e-testing.md b/docs/19-e2e-testing.md index ce43a391..e85152ee 100644 --- a/docs/19-e2e-testing.md +++ b/docs/19-e2e-testing.md @@ -111,3 +111,65 @@ red smoke does not block merges. Promotion to a required check happens once the journey lands three consecutive green PRs without manual reruns; the gate flip is a one-line workflow edit and a branch-protection update. + +## 19.6 The full blog-loop canary + +A second, longer-running journey lives in +`tools/e2e/tests/full-blog-loop.spec.ts`. It is the **canary**: the +single test that, if green, proves the publish loop works end to +end. It is intentionally separate from `install-and-publish.spec.ts` +so the two CI checks fail or pass independently — a regression in +the smoke does not pull the canary off-line, and vice versa. + +Differences from the smoke: + +- The canary captures the published slug from the success + notification, the status banner, *or* a fallback anchor scrape, + rather than from a single selector. This makes it more resilient + to UI churn around the publish flow. +- The canary asserts the brand's italic-accent rule on the public + h1: when the editor stores an emphasis, the rendered `

` must + contain an ``. The assertion is lenient (`` is optional + if the editor didn't produce one) but strict on the public + render side when it is produced. +- The canary inserts three list items rather than two, which + exercises the list block's Enter-driven item splitting one extra + time and catches off-by-one bugs in the list serializer. +- The canary logs out via `context.clearCookies()` rather than the + UI logout flow, decoupling the public-render assertion from any + churn in the logout affordance. + +### Running locally + +```bash +make up # bring the stack up +make e2e-blog-loop # runs the canary against it +``` + +The `make e2e-blog-loop` target sets `E2E_FRESH_INSTALL=1` *and* +`E2E_ALLOW_DESTRUCTIVE=1` for you. It targets only the +chromium project so a single local run gives a fast verdict; +flip the `--project=` flag if you want to sweep WebKit + Firefox. + +### CI + +`.github/workflows/e2e-blog-loop.yml` runs on every PR touching +`apps/**` or `packages/**`. Like the smoke, it is **advisory** on +landing (`continue-on-error: true`). Promotion rules are the same: +three consecutive greens without manual reruns and the gate flips +to required. + +### Why both? + +Both specs exercise the same conceptual loop, but they serve +different roles in CI: + +| Spec | Role | +| --------------------------------- | ----------------------------------------- | +| `install-and-publish.spec.ts` | Architectural skeleton — the scaffold that proves the harness wiring works. | +| `full-blog-loop.spec.ts` | Canary — the single signal we watch to know the platform works as a CMS. | + +If the smoke breaks but the canary stays green, the harness or +fixtures have regressed but the product is fine. If the canary +breaks, the product has regressed and we know exactly which step +of the publish loop is failing. diff --git a/tools/e2e/tests/full-blog-loop.spec.ts b/tools/e2e/tests/full-blog-loop.spec.ts new file mode 100644 index 00000000..8aff204a --- /dev/null +++ b/tools/e2e/tests/full-blog-loop.spec.ts @@ -0,0 +1,398 @@ +/** + * Full blog loop — the canary for "GoNext WORKS as a CMS". + * + * Sibling to `install-and-publish.spec.ts` (the PR #424 skeleton). + * That spec was authored before routes were mounted — this one is + * the *real* exercise of the publish loop. Both can live side by + * side: install-and-publish is the architectural skeleton, and + * full-blog-loop is the canary the platform team watches. + * + * Why a second file rather than editing the original? Two reasons: + * + * 1. Independence. The original landed in #424 as a scaffold and + * grew its own callers (docs, CI artefacts). Renaming it would + * break those references; rewriting it would lose the original + * git blame on the scaffolding decisions. A new spec keeps + * both intact. + * + * 2. CI separation. The original is gated by `pnpm e2e:smoke` + * and `.github/workflows/e2e-smoke.yml`. This canary gets its + * own `make e2e-blog-loop` and `.github/workflows/e2e-blog-loop.yml`, + * so a regression in one path doesn't pull the other off-line + * while we triage. + * + * Journey (one ordered test; `test.step` blocks for trace clarity): + * + * 1. Log in via `/login`. Assert dashboard URL + sidebar visible. + * 2. Navigate to `/posts/new`. Assert the editor mounts. + * 3. Type the title "Living systems, *living* posts." into the + * title field. Assert the italic-accent renders. + * 4. Insert three blocks in the canvas (paragraph, heading, list). + * 5. Open the Document tab, set status to Publish, click Publish. + * 6. Capture the published slug from the success notification or + * the resulting URL. + * 7. Log out — `context.clearCookies()` is faster than the UI flow. + * 8. Visit `/` on the public site. Assert the title + * appears in an `

` and all three blocks render. + * 9. Assert canonical ``, `og:title`, and `og:description` + * meta tags are present and match. + * + * If any of these fail, a fresh self-hosted GoNext install can't + * deliver the one experience the project exists to deliver. That's + * the contract this spec defends. + */ + +import { test, expect } from '../fixtures/server'; +import { DEFAULT_INIT_ARGS, loginAs } from '../lib/test-helpers'; + +// Title is intentionally written with an inline emphasis token so we +// can exercise the brand's "italic accent" Headline primitive. The +// `` wraps just the word `living` (case-insensitive match) so +// the asserted DOM mirrors how an author would actually mark the +// emphasis. +const POST_TITLE_PLAIN = 'Living systems, living posts.'; +const POST_TITLE_EMPHASIS = 'living'; +const PARAGRAPH_BODY = 'This is a test post written by Playwright.'; +const HEADING_TEXT = 'A heading block'; +const LIST_ITEMS = ['First item', 'Second item', 'Third item'] as const; + +test.describe('full blog loop (canary)', () => { + // The whole journey is one ordered test. Each step depends on + // every previous step succeeding; splitting them up would force + // us to maintain cross-test state, which is the opposite of what + // we want from a canary. `test.step()` gives us collapsible + // sub-blocks in the trace report. + test('publish a post end-to-end and verify it renders publicly', async ({ + page, + context, + serverRequest, + baseURL, + }) => { + test.setTimeout(180_000); + + // Captured between steps. Initialised here so TypeScript treats + // the binding as definitely assigned by the public-render step. + let slug = ''; + + await test.step('step 1 — log in via /login', async () => { + await page.goto(`${baseURL}/login`); + + // The login form uses shadcn Label + Input controls + // (apps/admin/src/app/(public)/login/page.tsx). `getByLabel` + // works against the explicit `