Appearance
E2E: управление PHP-сессиями в Playwright-тестах
Последнее обновление: 2026-03-29 (DEV-213)
Проблема
PHP-бэкенд вызывает session_regenerate_id(true) при логине и смене пароля. Параметр true удаляет старый session-файл на сервере. Playwright E2E-тесты сохраняют session-куки в файлы (SELLER_STATE, ADMIN_STATE), которые переиспользуются параллельными воркерами. Если один тест вызывает regenerate — куки в файле указывают на мёртвую сессию, и все зависимые тесты скипаются.
Архитектура сессий
PHP (бэкенд)
- Множественные сессии поддерживаются (UF-16 — список активных сессий)
LoginActionсоздаёт НОВУЮ сессию, НЕ удаляя старыеsession_regenerate_id(true)→ удаляет файл старой сессии, создаёт новуюAuthMiddlewareпроверяет fingerprint:hash('sha256', IP + UserAgent). Несовпадение →session_destroy()+ 401
Playwright (фронтенд)
seed.setup.ts → отдельный API-контекст (pw.request.newContext)
auth.setup.ts → loginViaApi через page.request → сохраняет SELLER_STATE / ADMIN_STATE
functional tests → test.use({ storageState: SELLER_STATE }) → каждый тест получает свой browser contextКлючевое различие:
page.request.post('/api/auth/login')→ использует куки текущего контекста →regenerate()уничтожает сессию из файлаpw.request.newContext()+ login → отдельный cookie jar → НЕ затрагивает SELLER_STATE
Цепочка инвалидации SELLER_STATE
auth.setup → создаёт session A → сохраняет в SELLER_STATE
↓
change-password → PUT /vendor/me с password → regenerate(true) → session A МЁРТВ
→ cleanup: re-login → session B → сохраняет в SELLER_STATE
↓
sessions → page.request.post('/auth/login') → regenerate(true) → session B МЁРТВ
→ cleanup: re-login → session C → сохраняет в SELLER_STATE
↓
seller/create-product → читает SELLER_STATE (session C) → OKЕсли тест на другом воркере стартует между regenerate и cleanup — он читает мёртвую сессию → skip.
Механизм восстановления (gotoCabinet)
tests/e2e/helpers/ensure-auth.ts::gotoCabinet() перехватывает /api/auth/me:
| Статус | Действие |
|---|---|
| 200 | Продолжить |
| 429 | Retry с backoff (rate limit) — до 6 попыток × 5с |
| 401 | Сессия мертва → re-login как seller → сохранить SELLER_STATE → повторить навигацию |
typescript
if (/\/auth\/login/.test(page.url()) && authMeStatus === 401) {
await loginViaApi(null, page, getSellerUser())
await page.context().storageState({ path: SELLER_STATE })
await page.goto(path, { waitUntil: 'networkidle' })
}Правила для E2E-тестов
1. Тест вызывает page.request.post('/api/auth/login') → обязан re-save SELLER_STATE в cleanup
typescript
test('cleanup: восстановить SELLER_STATE', async ({ page }) => {
await loginViaApi(null, page, getSellerUser())
await page.context().storageState({ path: SELLER_STATE })
})Кто это делает сейчас:
change-password.spec.ts— cleanup testsessions.spec.ts— cleanup test
2. Для создания API-контекстов без влияния на SELLER_STATE → использовать pw.request.newContext()
typescript
// ✅ Безопасно — отдельный cookie jar
const ctx = await playwright.request.newContext({ baseURL, httpCredentials })
await ctx.post('/api/auth/login', { data: { email, password } })
// ... работа с API ...
await ctx.dispose()
// ❌ Опасно — уничтожает текущую сессию в browser context
await page.request.post('/api/auth/login', { data: { email, password } })3. Навигация в кабинет → через gotoCabinet(), НЕ raw page.goto()
typescript
// ✅ С retry на 429 и recovery на 401
await gotoCabinet(page, '/cabinet/products/new', 'seller create-product')
// ❌ Без защиты от rate limit и мёртвых сессий
await page.goto('/cabinet/products/new', { waitUntil: 'networkidle' })Текущее покрытие (CI job #1808, 2026-03-29)
- 101 passed, 3 skipped, 0 failed, 0 flaky
- Оставшиеся скипы:
admin/moderation(admin-сессия),cabinet/profile:97 + :141(serial cascade от race condition с change-password)