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
45 changes: 45 additions & 0 deletions .claude/skills/new-e2e-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,48 @@ cd examples/redis-minimal && npx playwright test <file>.spec.ts # single file,

Requires a reachable Redis at `REDIS_URL` (defaults to `redis://127.0.0.1:6379`) — start one
locally (e.g. `docker run -p 6379:6379 redis`) before running e2e tests outside CI.

## Isolated tests (own Redis + own server)

If a test needs to assert against Redis state independent of the shared `next start`/Redis used
by the rest of this suite — e.g. proving something that happens exactly once at server boot,
before any HTTP request, such as `registerInitialCache` populating Redis from build-time
artifacts — it does **not** belong in `e2e/*.spec.ts` under the shared `playwright.config.ts`. By
the time any of those specs run, the shared server has been up for a while and its Redis has
already been mutated by earlier HTTP-driven cache writes, so "before any request" can't be proven
there.

See `e2e/isolated/` and `playwright.isolated.config.ts` instead: specs there self-manage a Docker
Redis container and a `next start` child process via `e2e/isolated/helpers/isolated-server.ts`
(`startEphemeralRedis`/`waitForRedisReady`/`waitForRedisPopulated`/`waitForRedisKeyDeleted`/
`startIsolatedNextServer`/`stopIsolatedNextServer`), run via `pnpm test:e2e:isolated` (requires
Docker). Key lessons from building this suite:
- Don't synchronize on "the shared-tags hash has *any* entries" — routes are written under
`registerInitialCache`'s own internal concurrency limit, so an early partial write can pass an
`HLEN > 0` check while routes your test cares about are still being written. Wait for the
*specific* cache paths the test asserts on (`waitForRedisPopulated`'s `expectedCachePaths`
argument) rather than a generic non-empty check.
- Next's own `revalidateTag()`/`revalidatePath()` don't guarantee the underlying `CacheHandler`
purge completes synchronously with the HTTP response that triggered it — asserting on Redis
immediately after such a response is flaky (confirmed empirically). Poll with
`waitForRedisKeyDeleted` (or similar) instead of asserting once.

### Two styles of isolated test — pick deliberately

- **Boot `next start`** (`startIsolatedNextServer`) — needed to prove something about the *whole
pipeline*: `registerInitialCache`'s disk-to-Redis population, `setOnlyIfNotExists`, or that a
real HTTP route (`/api/revalidate`) actually mutates Redis. Slower (real build + boot), but the
only way to exercise the real `CacheHandler`'s kind-based tag computation
(`APP_PAGE`/`PAGES`/`FETCH`) against real Next.js-generated values.
- **Direct handler construction** (no `next start` at all) — for handler-level correctness that
doesn't depend on Next.js's runtime: import `createRedisHandler` from
`@fortedigital/nextjs-cache-handler/redis-strings` directly (the same public subpath export
`examples/redis-minimal/cache-handler.mjs` uses in production — not a private API) and call
`.set()`/`.revalidateTag()`/`.prepare()` on it against the ephemeral Redis client directly. Gives
full deterministic control over arbitrary tags/`lifespan`/`revalidate` shapes and entry counts —
essential for a *matrix* of scenarios the fixed example-app routes can't cleanly provide (e.g.
every `revalidate` value, a `lifespan: null` permanent-key case that's unreachable via any real
route under `next start`) — and it's fast, since there's no Next.js process to boot. See
`redis-handler-tags-and-ttl.spec.ts` and `redis-handler-revalidate-tag.spec.ts` for the pattern:
one shared ephemeral Redis per file (`beforeAll`/`afterAll`), a distinct `keyPrefix` per test to
keep them fully isolated from each other without paying for a separate container each time.
54 changes: 54 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
branches: [master]
workflow_dispatch:

permissions:
contents: read
checks: write

jobs:
redis-minimal-e2e:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -35,8 +39,58 @@ jobs:
${{ runner.os }}-turbo-integration-${{ hashFiles('pnpm-lock.yaml') }}-
${{ runner.os }}-turbo-integration-
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright browsers
working-directory: examples/redis-minimal
run: pnpm exec playwright install chromium --with-deps
- name: Run e2e tests
run: pnpm test:e2e
- name: E2E Test Report
uses: dorny/test-reporter@v1
if: always()
with:
name: E2E Test Report
path: examples/redis-minimal/test-results/junit-e2e.xml
reporter: java-junit

redis-minimal-e2e-isolated:
runs-on: ubuntu-latest
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: pnpm
- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-integration-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-integration-${{ hashFiles('pnpm-lock.yaml') }}-
${{ runner.os }}-turbo-integration-
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright browsers
working-directory: examples/redis-minimal
run: pnpm exec playwright install chromium --with-deps
- name: Run isolated registerInitialCache e2e tests
run: pnpm test:e2e:isolated
- name: Isolated E2E Test Report
uses: dorny/test-reporter@v1
if: always()
with:
name: Isolated E2E Test Report
path: examples/redis-minimal/test-results/junit-e2e-isolated.xml
reporter: java-junit
13 changes: 12 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ on:
branches: [ "master" ]
workflow_dispatch:

permissions:
contents: read
checks: write

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/nextjs-cache-handler

steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand All @@ -25,3 +29,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: npm run build --if-present
- run: npm test
- name: Unit Test Report
uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Test Report
path: packages/nextjs-cache-handler/test-results/junit.xml
reporter: jest-junit
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ dist
*.swp
*.swo


examples/redis-minimal/test-results/
packages/nextjs-cache-handler/test-results/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Each handler is a factory function returning an object matching the `Handler` in

- Unit tests are colocated with source as `*.test.ts` under `packages/nextjs-cache-handler/src/` (jest + ts-jest, `testEnvironment: "node"`).
- E2E tests live in `examples/redis-minimal/e2e` (Playwright) and exercise real caching behavior (fetch tag revalidation, ISR, `unstable_cache`, static params, etc.) against a built Next.js app backed by Redis — run via `pnpm test:e2e` from the root, which builds first (`test:e2e` depends on `^build`/`build` per `turbo.json`).
- A separate isolated suite under `examples/redis-minimal/e2e/isolated` (its own `playwright.isolated.config.ts`, run via `pnpm test:e2e:isolated`, **requires Docker**) proves `registerInitialCache` populates Redis from build-time artifacts on server boot. Each spec self-manages its own ephemeral Redis container and `next start` process — independent of the shared suite's server/Redis — so it can assert Redis state before any HTTP request is made.
- Use the `new-example` skill (`.claude/skills/new-example/SKILL.md`) to scaffold a new caching example page in `examples/redis-minimal`, and the `new-e2e-test` skill (`.claude/skills/new-e2e-test/SKILL.md`) to add its Playwright coverage. These are commonly used together — a new example is only complete once it has e2e coverage proving the cache behavior it demonstrates, and writing that coverage needs page-specific facts (path, tags, `cacheLife` profile, expected reload behavior) that only whoever built the example page knows.

## Legacy project docs
Expand Down
18 changes: 18 additions & 0 deletions examples/redis-minimal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,24 @@ REDIS_URL=redis://localhost:6379 pnpm test:e2e

From repo root, Turborepo runs `build` first (cached when unchanged) then Playwright e2e. Playwright starts `next start` on port 3000 (`reuseExistingServer` when not in CI).

### Isolated `registerInitialCache` e2e tests

`e2e/isolated/` is a separate Playwright suite (its own config, `playwright.isolated.config.ts`)
that proves `registerInitialCache` actually populates Redis from build-time artifacts on server
startup. It doesn't use the shared `webServer`/Redis above — each spec spins up its own ephemeral
Redis container (via the `docker` CLI) and its own `next start` process on dedicated ports, so it
can assert Redis state that can only be explained by `registerInitialCache` having run, before any
HTTP request is made.

**Requires Docker** to be running locally (the tests spawn `docker run redis:7-alpine`
themselves - no need to start a container manually). Requires a prior `pnpm build` (reuses the
same `.next` output as the main suite).

```bash
pnpm build
pnpm test:e2e:isolated
```

## Technologies

- Next.js 16
Expand Down
Loading
Loading