Перейти к содержимому

Next.js

SSR-фреймворки вроде Next.js делают серверные вызовы fetch, которые проходят через прокси без контекста браузера. Прокси определяет, какой сессии принадлежат эти запросы, по заголовку x-test-rcrd-id. playwrightProxy.before() Playwright уже устанавливает его на навигацию браузера, которая трегерит SSR, поэтому id доступен в next/headers — задача в том, чтобы прикрепить его к исходящим серверным запросам. (Тестам только в браузере всё это не нужно; прокси откатывается к глобально заданной сессии.)

Одна строка в вашем root layout тегирует каждый серверный fetch — Server Components, Route Handlers, на Node и Edge-runtime:

app/layout.tsx
import { registerProxyFetch } from 'test-proxy-recorder/nextjs';
registerProxyFetch(); // no-op in production unless TEST_PROXY_RECORDER_ENABLED=true

Он патчит глобальный fetch, чтобы копировать x-test-rcrd-id текущего запроса на исходящие запросы, чтобы прокси мог различать конкурентные сессии воспроизведения. Вызывайте его из root layout — не из instrumentation.ts, чей контекст отличается от того, который рендерит ваши роуты на Edge-runtime, поэтому патч там тихо никогда не срабатывает.

Если ваши серверные запросы идут через axios, зарегистрируйте каждый серверный инстанс один раз:

import { registerProxyAxios } from 'test-proxy-recorder/nextjs';
registerProxyAxios(axiosForServer);

Он добавляет request-перехватчик, который штампует id (не трогая глобальный fetch), поэтому он неуязвим к приведённой выше dev-серверной проблеме. No-op в продакшене / в браузере; идемпотентен на инстанс; никогда не перезаписывает id, заданный вызывающим.

Без патча, и работает под next dev тоже. Используйте для одиночного fetch или когда не хочется патчить глобальный fetch:

import { headers } from 'next/headers';
import { createHeadersWithRecordingId } from 'test-proxy-recorder/nextjs';
const res = await fetch('http://localhost:8100/api/data', {
headers: createHeadersWithRecordingId(await headers(), {
'Content-Type': 'application/json',
}),
});

proxy.ts (Next.js 16+, экспортируемый proxy) или middleware.ts (15 и ранее, экспортируемый middleware) с вызовом setNextProxyHeaders делает id доступным через next/headers, но не тегирует исходящие fetch — поэтому он не требуется, когда вы используете один из хелперов выше. Обращайтесь к нему только если у вас уже есть middleware (auth и пр.), и всё равно комбинируйте его с хелпером для тегирования:

// proxy.ts (Next.js 16+)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { setNextProxyHeaders } from 'test-proxy-recorder/nextjs';
export function proxy(request: NextRequest) {
const response = NextResponse.next();
setNextProxyHeaders(request, response); // exposes the id; pair with a helper above
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Полные сигнатуры хелперов test-proxy-recorder/nextjs см. в справочнике API. Полный, готовый к запуску Edge-проект находится в примере Edge-runtime.

Не отключайте кеширование ради тестов — рекордер работает с кешируемым/ISR-роутом. Но есть одно правило, определяющее весь дизайн: чтобы воспроизвести SSR-fetch, страница должна выполнить этот fetch в момент запроса. Роут, отдающий пререндеренный HTML или устаревший закешированный рендер, fetch не делает, поэтому прокси нечего отдавать, и проверка видит устаревший контент.

Детерминированным остаётся такой подход: кешировать SSR-fetch на уровне fetch через next.revalidate + next.tags, а затем инвалидировать по требованию перед проверкой:

// app/isr/page.tsx — без `export const dynamic`, без `export const revalidate`
const res = await fetch(`${BACKEND_URL}/todos`, {
next: { revalidate: 30, tags: ['isr-todos'] },
});
app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
revalidateTag('isr-todos', 'max'); // Next.js 16 требует 2-й аргумент-профиль
e2e/isr.spec.ts
await page.request.post('/api/revalidate'); // жёсткая очистка
await page.goto('/isr'); // одна навигация — детерминированно
await expect(page.getByTestId('todo-text')).toHaveCount(1);

revalidateTag для записи кеша fetch — это жёсткая очистка: следующее чтение становится промахом кеша, который блокируется и заново делает fetch через прокси. Очищать нужно до навигации воспроизведения, потому что кеш данных переживает фазы запись → воспроизведение одного процесса next start — иначе воспроизведение отдаст кеш фазы записи и никогда не попадёт в прокси (ложный успех).

Во время тестов пропатченный fetch читает headers(), поэтому страница рендерится динамически и реально выполняет fetch. В продакшене (рекордер выключен) headers() никто не читает, и страница остаётся статической ISR как обычно — динамический рендер ограничен тестами и является неотъемлемой частью записи SSR-fetch.

Ревалидация по требованию привилегированна (очищает кеш и форсирует регенерацию), поэтому защитите роут общим секретом — отказывайте по умолчанию, если он не задан, сравнивайте за константное время и прикрепляйте токен из теста через use.extraHTTPHeaders Playwright, чтобы spec никогда не работал с секретом напрямую.

Смотрите полный, готовый к запуску пример (часть примера Next.js 16):

  • app/isr/page.tsx — кешируемая страница (next.tags на уровне fetch)
  • app/api/revalidate/route.ts — как защитить revalidateTag: отказ по умолчанию + сравнение секрета за константное время
  • e2e/isr.spec.ts — инвалидируем, затем одна навигация; проверяем, что вызов ревалидации успешен
  • playwright.config.ts — загружает .env и прикрепляет секрет через extraHTTPHeaders

Запускайте сервисы из скриптов, а не из playwright.config.ts:

{
"scripts": {
"mock": "node mock-backend/server.mjs",
"proxy": "test-proxy-recorder http://localhost:3002 -p 8100 -d ./e2e/recordings",
"start:all": "concurrently \"pnpm mock\" \"pnpm proxy\" \"pnpm build && next start --port 3000\""
}
}

Полный, готовый к запуску проект находится в примере Next.js 16.