From 09a88abf0e485876913189a550d203d5acd5e9b8 Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Mon, 25 May 2026 19:41:28 +0200 Subject: [PATCH 1/4] chore(e2e): start real blog loop test branch Co-Authored-By: Claude Opus 4.7 Signed-off-by: Mohamed Tayeb Mokni From 3dc6fe0be1d8e56c8f36492f1854c19889c360fb Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Mon, 25 May 2026 19:43:31 +0200 Subject: [PATCH 2/4] =?UTF-8?q?chore(e2e):=20checkpoint=20=E2=80=94=20expl?= =?UTF-8?q?ored=20existing=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings: - tools/e2e harness exists with fixtures/server.ts + lib/test-helpers.ts (loginAs, freshDatabase, seedAdminUser, tryGonextInit). - install-and-publish.spec.ts (PR #424) covers similar territory but was the canary aspirational spec. We add a sibling full-blog-loop spec that adds: italic-accent render assertion, document-tab status toggle, slug capture via notification or URL, cookie-only logout, 3 blocks (paragraph + heading + 3-item list). - Login form (apps/admin/src/app/(public)/login/page.tsx) is a stub pending wire-up; spec is resilient to label/placeholder shape. - /posts/new + editor route not yet present; spec navigates to the CTA href to mirror PostListClient.tsx ('/posts/new'). - Public site lives at apps/web/src/app/[...slug]/page.tsx — handles the slug render path the spec asserts against. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Mohamed Tayeb Mokni From 8734c7057762404d677741032efeb446cd85d445 Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Mon, 25 May 2026 19:46:00 +0200 Subject: [PATCH 3/4] test(e2e): full blog loop canary spec + make target + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canary proves GoNext works as a CMS: a fresh install can log in, author a post with three block types, publish it, log out, view it on the public site, and serve correct SEO meta. Key differences from install-and-publish.spec.ts (PR #424): - Italic-accent assertion on the title's in the public h1. - Document-tab status toggle before publish. - Slug captured from notification, banner, or anchor fallback. - Three list items (vs two), exercising list-item splitting. - Cookie wipe logout via context.clearCookies(). Wires up: - make e2e-blog-loop — local one-shot runner. - .github/workflows/e2e-blog-loop.yml — PR-scoped, advisory (it runs but does not gate merges until three consecutive greens land). Co-Authored-By: Claude Opus 4.7 Signed-off-by: Mohamed Tayeb Mokni --- .github/workflows/e2e-blog-loop.yml | 168 +++++++++++ Makefile | 9 + tools/e2e/tests/full-blog-loop.spec.ts | 398 +++++++++++++++++++++++++ 3 files changed, 575 insertions(+) create mode 100644 .github/workflows/e2e-blog-loop.yml create mode 100644 tools/e2e/tests/full-blog-loop.spec.ts 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/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 `