Перейти к содержанию

Проверка загрузки файла

Подготовленный снимок показывает известное состояние каталога, но не рассказывает, что происходит при скачивании данных. Ссылка может выглядеть правильно и выдавать пустой файл, страницу ошибки либо JSON от другого набора курсов. Чтобы проверить такую задачу, нужно проследить действие до завершённого файла и разобрать содержимое.

Добавим ссылку «Скачать каталог JSON». Исходники находятся в lesson-18/start и expected архива продолжения. Каждый каталог состояния содержит полный проект. При подготовке скачивание и тесты не выполнялись; имя, данные и ответы ниже являются ожидаемыми условиями будущего сценария.

Что именно скачивается

Сервер получает один фиксированный маршрут /downloads/catalog.json. В ответе находится объект с номером схемы и четырьмя прежними курсами. Общее число уроков остаётся пятьдесят. Файл описывает весь каталог, а не результат текущего фильтра. Если читатель выбрал «Публикация», выгрузка всё равно содержит все четыре записи: это нужно определить заранее, чтобы проверка не зависела от предположения о назначении ссылки.

{
  "schemaVersion": 1,
  "courses": [
    {"id":"c001","title":"Основы HTML","topic":"frontend","lessons":12,"url":"/courses/html/"},
    {"id":"c002","title":"Современный JavaScript","topic":"frontend","lessons":20,"url":"/courses/javascript/"},
    {"id":"c003","title":"HTTP и API","topic":"publishing","lessons":8,"url":"/courses/http/"},
    {"id":"c004","title":"Статический сайт из Markdown","topic":"publishing","lessons":10,"url":"/courses/markdown/"}
  ]
}

Этот листинг показывает данные договорённости. Реальный ответ будущего запуска формирует server.mjs из неизменяемого массива courses. Полный файл задаёт Content-Type: application/json, Content-Disposition: attachment; filename="catalog.json" и длину в байтах. Заголовок disposition предлагает браузеру скачивание с понятным именем. В HTML используется обычная ссылка с атрибутом download.

У маршрута нет параметра имени файла и доступа к произвольному пути. Он не читает файлы по пользовательскому URL, поэтому обход каталога здесь не реализован даже как учебная возможность. Если позже добавить экспорт другой выборки, его входные значения и допустимые результаты понадобится определить отдельно. Проверка одного фиксированного JSON не подтверждает безопасность произвольного файлового сервера.

Сначала ожидание события, затем действие

Скачивание начинается в ответ на нажатие. Поэтому ожидание события нужно создать перед click(), не дожидаясь его немедленно. Если ожидание установить после нажатия, быстрое событие может уже пройти, и сценарий будет ждать другую загрузку.

const pending = page.waitForEvent('download');
await page.getByRole('link', {
  name: 'Скачать каталог JSON', exact: true
}).click();
const download = await pending;
expect(download.suggestedFilename()).toBe('catalog.json');
expect(new URL(download.url()).pathname)
  .toBe('/downloads/catalog.json');

Download представляет загрузку браузера. Предложенное имя не равно случайному имени внутреннего временного файла. Оно также не доказывает правильное содержимое: страница ошибки может получить то же имя. Поэтому проверим и адрес, и последующее завершение, и разобранный JSON.

Документация Downloads описывает порядок получения события и сохранения результата. В нашем случае полезно удерживать всю последовательность внутри одного теста. Независимый обработчик page.on() без ожидания завершения мог бы продолжить работу уже после окончания сценария, что затруднило бы понимание результата.

Собственный путь результата

Полный сценарий импортирует readFile из node:fs/promises и получает второй аргумент testInfo. Его outputPath() создаёт путь в области артефактов конкретного сценария. Мы задаём фиксированное имя сами и не используем предложенное сервером имя как произвольный путь файловой системы.

const target = testInfo.outputPath('catalog.json');
await download.saveAs(target);
expect(await download.failure()).toBeNull();
const value = JSON.parse(await readFile(target, 'utf8'));
expect(value).toEqual({ schemaVersion: 1, courses });
expect(value.courses.reduce((sum, course) => sum + course.lessons, 0))
  .toBe(50);

saveAs() ждёт необходимое завершение загрузки. Чтение после него уже относится к сохранённым байтам. Пустой файл или неверный JSON приведут к ошибке разбора. Сравнение объекта проверяет поля, порядок записей и значения. Итог пятьдесят дополнительно делает понятным смысл поля lessons, хотя не заменяет точное сравнение всего объекта.

Проверка не привязана к отступам JSON. Файл с теми же данными и другим форматированием должен выражать ту же договорённость. Если задача требует точных байтов, например подписи или контрольной суммы, проверяют байты отдельно. Здесь задача читателя — получить пригодные данные каталога, поэтому разбираем структуру, а не сравниваем красивое расположение пробелов.

Общие данные и независимые сценарии

Параллельность остаётся включённой, но каждый тест получает собственный браузерный контекст и путь результата. Сервер не изменяет массив при выгрузке. Поэтому два одновременных скачивания не должны перезаписывать общий catalog.json на диске сценариев. Запись в корень проекта с постоянным именем создала бы общий изменяемый ресурс и могла бы смешать результаты.

Временные загрузки браузерного контекста удаляются при его закрытии. Сохранённая через saveAs() копия принадлежит выбранной области артефактов. Жизненный цикл и ограничения объекта разобраны в API Download. Сейчас никаких файлов результата нет: архив содержит исходники будущего действия, а не доказательство выполненного скачивания.

Прямой HTTP-запрос к маршруту мог бы проверить JSON и заголовки, но не показал бы, что видимая ссылка запускает скачивание. Наш браузерный сценарий начинается с действия человека, поэтому дополнительно защищает саму связь интерфейса с файлом. Если href ошибочно укажет на другой путь, это наблюдение отличается от правильного ответа API, к которому тест обращается вручную.

Ссылка остаётся внутри того же origin. Поведение атрибута download для других origin и политика браузера не исследуются этим примером. Серверный Content-Disposition задаётся независимо от атрибута, чтобы назначение ответа было явным. Для экспорта, формируемого через Blob в браузере, понадобилась бы другая цепочка подготовки; нельзя автоматически переносить утверждение HTTP-пути на такой механизм.

До разбора JSON стоит также отделять успешное получение файла от его пригодности. download.failure() наблюдает ошибку загрузки, а JSON.parse() и сравнение объекта — смысл доставленных данных. Один сигнал не подменяет другой. Это разделение помогает сформулировать будущую карточку регрессии: «загрузка прервалась» и «доставлен каталог без нужной записи» требуют разных объяснений.

Ошибка, которую имя не обнаруживает

Рассмотрим изменение сервера: маршрут возвращает {courses:[]}, сохраняя имя catalog.json. Утверждение имени останется удовлетворённым, но сравнение структуры обнаружит потерю схемы и записей. Если сервер выдаст HTML, ошибка появится ещё при JSON.parse(). Эти различия полезны: они показывают, какой уровень договорённости нарушен.

При переносе сценария на настоящий сайт нужно отдельно решить, какие данные разрешено сохранять в отчёте. Наш файл содержит только вымышленные публичные названия. В рабочем экспорте могут оказаться персональные сведения, поэтому автоматическое прикрепление всей выгрузки не является универсальным решением. Состав артефакта выбирают по задаче расследования и доступу к отчёту.

После будущего успешного выполнения можно было бы утверждать, что конкретная ссылка отдала правильный JSON в выбранной среде. Это не подтвердило бы все браузеры, стабильность сети или остальные файлы сайта. Следующий урок использует тот же принцип в обратном направлении: выбранный файл нужно передать для безопасного предпросмотра и отличить допустимые данные от отклонённых.