Баги, которые действительно важны в offline-first, не сидят внутри отдельного экрана - они живут на границах. Граница это момент перехода: приложение уходит из online в offline, с одной версии на другую, из разрыва обратно в reconnect. На стабильном экране всё зелёное; ломается именно на стыке, когда меняется контекст, а код молча предполагает старый. Поэтому проверять надо переходы, а не наличие функций.
Естественная ошибка - поверить галочке. Lighthouse проверяет installability и что страницу контролирует воркер, это полезный аудит, но он не доказывает корректность offline-домена. Зелёный значок не скажет вам, что заметка, сохранённая офлайн, переживёт reload, что reconnect отправит outbox ровно один раз, а не дважды, и что при конфликте пользователь не потеряет свой текст. Аудит меряет форму, а нам нужна проверка поведения.
Отсюда список того, что реально надо бросить в приложение. Не happy path, а враждебная среда: медленная сеть, отказ DNS, timeout уже после того, как сервер закоммитил операцию, ответы 401, 409, 429, ошибка quota, вытеснение хранилища (eviction), повреждённые данные и закрытие вкладки между локальным commit и отправкой. Именно в этих состояниях наивный код теряет или дублирует данные, которых обычный клик мышью не воспроизводит.
Тестировать это надо на самом низком уровне, который ловит поведение. Юнит-тест для чистой логики дешевле и стабильнее e2e; e2e держат для немногих сквозных путей, которые нельзя проверить иначе. Разложение по уровням задаёт, что и где проверяется - от route matching в юнитах до manifest и иконок на реальных браузерах.
| Уровень | Что проверять |
|---|---|
| Unit | Route matching, классификация retry, merge policy, сериализаторы |
| Integration | IDB-транзакции и миграции, компакция outbox, cache-плагин |
| E2E | Первая загрузка, вторая офлайн-загрузка, отложенная мутация, reconnect, конфликт |
| Lifecycle | Waiting update с двумя вкладками, reload, старые чанки, rollback |
| Platform | Manifest, иконки, display на Chromium, Safari/iOS, Firefox по support matrix |
Сквозные переходы становятся проверяемыми только на уровне e2e, потому что уйти в офлайн и вернуться умеет лишь настоящий контекст браузера. В Playwright это context.setOffline: сценарий поднимает страницу, глушит сеть, перезагружает, убеждается, что виден офлайн-режим, создаёт заметку, видит статус ожидания синхронизации, возвращает сеть и проверяет, что заметка помечена синхронизированной. Один такой тест закрывает всю петлю - от первой загрузки до reconnect.
Когда тест падает - или, хуже, пользователь сообщает о призраке, которого нет в коде, - отладка начинается с одного вопроса: кто дал этот ответ? Кандидатов ровно четыре: воркер, Cache Storage, HTTP-кеш браузера или сеть. Спутать их - и есть причина, по которой офлайн-баги ощущаются как магия: вы правите сеть, а ответ приходил с диска.
Ответ дают DevTools, вкладка Application. Панель Service Workers показывает регистрацию, источник скрипта, статус и клиентов; галочку Update on reload включают только на время разработки. Панели Cache Storage и IndexedDB показывают ключи, версии и пользовательский namespace - там сразу видно, приватный это кеш или общий. В Network смотрят колонку Initiator, значение Size со словом from ServiceWorker, заголовки ответа и request mode; временный Bypass for network отсекает воркер и показывает, что придёт из сети напрямую.
Есть одно тонкое состояние, которое путает чаще всего: вкладка без controller и вкладка после hard reload - это разные вещи. Отсюда классический призрак: у вас всё работает, потому что вашу вкладку контролирует старый воркер, а свежий посетитель получает новый воркер и другой путь кода. Воспроизводится это проверкой вкладки без контроллера и после hard reload. Итог простой: тесты доказывают, что переходы корректны, а отладка отвечает, кто из четырёх слоёв соврал.
// Playwright: контур сценария
await page.goto('/notes');
await expect(page.locator('[data-ready]')).toBeVisible();
await context.setOffline(true);
await page.reload();
await expect(page.getByText('Offline')).toBeVisible();
await page.getByRole('button', { name: 'Новая заметка' }).click();
await page.getByLabel('Текст').fill('Durable draft');
await page.getByRole('button', { name: 'Сохранить' }).click();
await expect(page.getByText('Ожидает синхронизации')).toBeVisible();
await context.setOffline(false);
await expect(page.getByText('Синхронизировано')).toBeVisible();