Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions .github/workflows/e2e-blog-loop.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions docs/19-e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>` must
contain an `<em>`. The assertion is lenient (`<em>` 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.
Loading
Loading