test(e2e): Playwright E2E 도입 — 로그인→강좌→퀴즈 + 멘토/감사로그
Some checks failed
CI / backend-test (push) Successful in 1m3s
CI / frontend-build (push) Failing after 27s
CI / backend-dep-scan (push) Successful in 6s

실제 브라우저로 '다 조립된 앱'(프론트+백엔드+DB)을 태우는 통합 테스트 6개:
- 인증 가드(비로그인→로그인), 로그인 실패 안내, 학생 로그인→대시보드
- 학습 센터→강좌 열기, 퀴즈 풀기→제출→서버 채점 결과
- 멘토 로그인→멘토 화면+개인정보 접근 기록(SEC-02 감사로그까지 검증)

운영 무오염 설계:
- deploy/docker-compose.e2e.yml — 별도 프로젝트(mirime2e)·네트워크·휘발성 DB(tmpfs)
- deploy/Caddyfile.e2e — 평문 HTTP로 SPA+/api 프록시(운영과 동일 구조)
- deploy/e2e.sh — 스택 기동→테스트 학생 API 생성→퀴즈 slug 자동조회→
  Playwright(브라우저 내장 이미지) 실행→down -v(정리)
- 백엔드 힙 256MB 제한(작은 인스턴스에서 운영과 공존)

EC2에서 6/6 통과 확인. 러너가 운영과 같은 인스턴스라 push마다가 아닌
온디맨드(릴리스 전)로 운영 — 전용 러너 확보 시 CI 게이트로 승격.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-20 08:27:42 +09:00
parent db54b9bc19
commit 1661591d8b
13 changed files with 374 additions and 4 deletions

4
.gitignore vendored
View File

@ -3,6 +3,10 @@ backend/target/
frontend/dist/ frontend/dist/
frontend/node_modules/ frontend/node_modules/
frontend/coverage/ frontend/coverage/
# E2E(Playwright) 산출물
frontend/playwright-report/
frontend/test-results/
frontend/playwright/.cache/
# 비밀값 — 절대 커밋 금지 # 비밀값 — 절대 커밋 금지
deploy/.env deploy/.env

View File

@ -113,9 +113,30 @@ CI가 통과해야 하는 관문이 아래처럼 늘었다. **로컬에서 먼
학생 실습 흐름: `git switch -c feat/내작업` → 커밋 → push → Gitea에서 PR 생성 → 학생 실습 흐름: `git switch -c feat/내작업` → 커밋 → push → Gitea에서 PR 생성 →
CI 초록불 + 멘토 승인 → merge. (실무 표준 흐름을 그대로 경험) CI 초록불 + 멘토 승인 → merge. (실무 표준 흐름을 그대로 경험)
## E2E 테스트 (`deploy/e2e.sh`)
단위/컴포넌트 테스트가 "부품"을 본다면, E2E는 **다 조립된 앱을 진짜 브라우저로** 눌러 본다 —
프론트+백엔드+DB가 함께 도는 통합 경로를 태워 "연결 지점의 회귀"를 잡는다(Playwright).
```bash
bash deploy/e2e.sh # 격리 임시 스택 기동 → 테스트 → down -v(정리)
```
검사하는 여정(6): 인증 가드(비로그인→로그인) · 로그인 실패 안내 · 학생 로그인→대시보드 ·
학습 센터→강좌 · **퀴즈 풀기→제출→채점** · 멘토 로그인→화면+감사로그.
**설계 — 운영 무오염**: 별도 프로젝트(`mirime2e`)·네트워크·**휘발성 DB(tmpfs)** 로 완전히 격리된
임시 스택을 띄우고(호스트 포트 8899), 테스트 학생을 API로 만들고, 브라우저 내장 이미지
(`mcr.microsoft.com/playwright`)로 돌린 뒤 통째로 내린다. 운영 DB/컨테이너는 건드리지 않는다.
> **왜 push마다 CI로 안 돌리나?** 러너가 운영과 같은 작은 인스턴스(3.7GB)에 있어, 매 push마다
> 두 번째 풀스택+브라우저를 띄우면 운영 메모리를 압박할 수 있다. 그래서 E2E는 **릴리스 전/필요 시
> 온디맨드**로 돌린다(백엔드 힙도 256MB로 제한). 전용 러너가 생기면 그때 CI 게이트로 승격한다.
## 다음 개선 (학생 티켓) ## 다음 개선 (학생 티켓)
- Maven·npm 캐시로 CI 실행 시간 단축 - Maven·npm 캐시로 CI 실행 시간 단축
- 커버리지 바닥을 단계적으로 상향(30%→40%→…) - 커버리지 바닥을 단계적으로 상향(30%→40%→…)
- 스모크 테스트를 배포 스크립트에 자동 편입(deploy 후 자동 실행·실패 시 알림) - 스모크 테스트를 배포 스크립트에 자동 편입(deploy 후 자동 실행·실패 시 알림)
- E2E 테스트(Playwright: 로그인→강좌→퀴즈) - React 컴포넌트 테스트(Testing Library) — 프론트 커버리지 확장
- 전용 러너 확보 시 E2E를 CI 게이트로 승격(현재는 온디맨드 `deploy/e2e.sh`)

13
deploy/Caddyfile.e2e Normal file
View File

@ -0,0 +1,13 @@
# E2E 전용 Caddy 설정 운영과 달리 HTTPS/도메인 없이 평문 HTTP로만 서빙한다.
# (E2E는 격리된 임시 스택이라 인증서가 필요 없다.) 역할은 운영과 동일:
# /api/* 백엔드(backend:8080), 나머지 SPA 정적 파일(+ 새로고침 대비 index.html 폴백).
:80 {
handle /api/* {
reverse_proxy backend:8080
}
handle {
root * /srv
try_files {path} /index.html
file_server
}
}

View File

@ -0,0 +1,59 @@
# E2E 전용 임시 스택 — 운영과 완전히 격리(별도 프로젝트명 mirime2e, 별도 네트워크, 휘발성 DB).
# 운영 DB/컨테이너는 절대 건드리지 않는다. E2E가 끝나면 통째로 내려간다(down -v).
#
# 학습 포인트: E2E는 "진짜 같은 환경"이되 "버려도 되는 환경"이라야 한다.
# - Postgres 데이터는 tmpfs(메모리)에 둬 매 실행 깨끗하게 시작하고 흔적을 안 남긴다.
# - 백엔드 힙을 256MB로 제한해(작은 인스턴스에서 운영과 공존) 자원을 아낀다.
# - 멘토 초기 비밀번호를 알려진 값으로 고정해 E2E가 로그인할 수 있게 한다.
name: mirime2e
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: mirim
POSTGRES_USER: mirim
POSTGRES_PASSWORD: e2epass
tmpfs:
- /var/lib/postgresql/data # 휘발성 — 매 실행 새 DB, 디스크에 안 남음
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mirim"]
interval: 4s
timeout: 5s
retries: 15
backend:
build: ../backend
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: mirim
DB_USER: mirim
DB_PASSWORD: e2epass
COOKIE_SECURE: "false" # 평문 HTTP라 secure 쿠키는 꺼야 로그인된다
LOG_LEVEL: INFO
MENTOR_INITIAL_PASSWORD: e2e-mentor-pass
JAVA_TOOL_OPTIONS: "-Xmx256m" # 작은 인스턴스에서 운영과 공존하도록 힙 제한
depends_on:
postgres:
condition: service_healthy
# 프론트 빌드 결과물을 webroot 볼륨에 복사(운영과 동일한 방식)
frontend-build:
build: ../frontend
volumes:
- webroot:/out
caddy:
image: caddy:2-alpine
ports:
- "8899:80" # 호스트 8899 → E2E 사이트(운영 80/443과 안 겹침)
volumes:
- ./Caddyfile.e2e:/etc/caddy/Caddyfile:ro
- webroot:/srv:ro
depends_on:
- backend
- frontend-build
volumes:
webroot:

60
deploy/e2e.sh Normal file
View File

@ -0,0 +1,60 @@
#!/bin/bash
# 이 파일이 하는 일: 격리된 임시 스택을 띄우고 Playwright E2E를 돌린 뒤 통째로 정리한다.
#
# 흐름: 스택 기동(postgres+backend+frontend+caddy) → 백엔드 준비 대기 → 테스트 학생 생성
# → 퀴즈 있는 코스 slug 조회 → Playwright(브라우저 내장 이미지) 실행 → down -v(정리).
#
# 사용법: bash deploy/e2e.sh
# 운영과 완전히 격리된다(별도 프로젝트·네트워크·휘발성 DB, 호스트 포트 8899). 운영 DB 무접촉.
set -uo pipefail
cd "$(dirname "$0")" # deploy/
COMPOSE="docker compose -f docker-compose.e2e.yml"
NET="mirime2e_default"
BASE="http://localhost:8899"
cleanup() { echo "▶ E2E 스택 정리(down -v)..."; $COMPOSE down -v >/dev/null 2>&1 || true; }
trap cleanup EXIT
echo "▶ E2E 스택 빌드·기동..."
$COMPOSE up -d --build
echo "▶ 백엔드 준비 대기(최대 ~3분)..."
ready=0
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/auth/me" 2>/dev/null || true)
if [ "$code" = "401" ]; then ready=1; echo " 준비됨"; break; fi
sleep 3
done
if [ "$ready" != "1" ]; then
echo "🔴 백엔드가 뜨지 않았습니다. 최근 로그:"; $COMPOSE logs backend 2>&1 | tail -30; exit 1
fi
echo "▶ 테스트 학생 생성(주소는 문자열 — Daum 팝업 우회)..."
sc=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/auth/signup" \
-H 'Content-Type: application/json' -d '{
"username":"e2estudent","password":"E2ePass123!","name":"이투이","track":"DEV",
"companyEmailLocal":"e2e.student","birthDate":"2007-03-15","phone":"010-1234-5678",
"address":"서울특별시 강남구 테헤란로 1","school":"미림마이스터고","major":"소프트웨어과",
"emergencyName":"이보호","emergencyRelation":"부","emergencyPhone":"010-9876-5432","privacyConsent":true
}')
echo " signup 응답: $sc (201=생성 / 409=이미 있음 — 둘 다 정상)"
echo "▶ 퀴즈가 있는 코스 slug 조회..."
SLUG=$($COMPOSE exec -T postgres psql -U mirim -d mirim -tAc \
"SELECT course_slug FROM quiz_question GROUP BY course_slug ORDER BY course_slug LIMIT 1" 2>/dev/null | tr -d '[:space:]')
echo " quiz slug: ${SLUG:-(없음 — 퀴즈 테스트는 skip)}"
echo "▶ Playwright 실행(브라우저 내장 이미지)..."
docker run --rm --network "$NET" \
-v "$PWD/../frontend:/work" -w /work \
-e E2E_BASE_URL=http://caddy \
-e E2E_STUDENT_USER=e2estudent -e E2E_STUDENT_PASS='E2ePass123!' \
-e E2E_MENTOR_USER=mentor1 -e E2E_MENTOR_PASS=e2e-mentor-pass \
-e E2E_QUIZ_SLUG="$SLUG" \
-e CI=true \
mcr.microsoft.com/playwright:v1.49.1-jammy \
sh -c "npm ci --no-audit --no-fund && npx playwright test"
rc=$?
echo "▶ Playwright 종료 코드: $rc"
exit $rc

27
frontend/e2e/auth.spec.js Normal file
View File

@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test'
import { login, STUDENT } from './helpers.js'
// 인증 경계 — "로그인 전/후"가 올바로 갈리는지 실제 브라우저로 확인.
test('비로그인으로 보호 페이지 접근 → 로그인 화면으로 보낸다', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
await expect(page.locator('#username')).toBeVisible()
})
test('잘못된 비밀번호는 로그인 실패 안내를 보여준다', async ({ page }) => {
await page.goto('/login')
await page.locator('#username').fill(STUDENT.username)
await page.locator('#password').fill('wrong-password-xyz')
await page.getByRole('button', { name: '로그인' }).click()
await expect(page.getByText(/맞지 않아요|올바르지|너무 많/)).toBeVisible()
// 실패했으니 여전히 로그인 화면에 남아 있어야 한다.
await expect(page).toHaveURL(/\/login/)
})
test('학생 로그인 → 대시보드 진입', async ({ page }) => {
await login(page, STUDENT)
await expect(page).toHaveURL(/\/$/)
// 로그인 화면 요소(#username)는 더 이상 없어야 한다.
await expect(page.locator('#username')).toHaveCount(0)
})

23
frontend/e2e/helpers.js Normal file
View File

@ -0,0 +1,23 @@
import { expect } from '@playwright/test'
// 테스트 계정·퀴즈 slug는 deploy/e2e.sh 가 임시 스택을 띄우며 env로 주입한다.
// (기본값은 로컬에서 같은 값으로 스택을 띄웠을 때를 위한 폴백)
export const STUDENT = {
username: process.env.E2E_STUDENT_USER || 'e2estudent',
password: process.env.E2E_STUDENT_PASS || 'E2ePass123!',
}
export const MENTOR = {
username: process.env.E2E_MENTOR_USER || 'mentor1',
password: process.env.E2E_MENTOR_PASS || 'e2e-mentor-pass',
}
export const QUIZ_SLUG = process.env.E2E_QUIZ_SLUG || ''
/** 로그인 폼을 채워 제출하고, 대시보드(/)로 이동할 때까지 기다린다. */
export async function login(page, account) {
await page.goto('/login')
await page.locator('#username').fill(account.username)
await page.locator('#password').fill(account.password)
await page.getByRole('button', { name: '로그인' }).click()
await page.waitForURL((url) => url.pathname === '/', { timeout: 15_000 })
await expect(page.getByText('주차 진행 상황', { exact: false })).toBeVisible()
}

View File

@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test'
import { login, STUDENT, QUIZ_SLUG } from './helpers.js'
// 핵심 사용자 여정: 로그인 → 학습 센터 → 강좌 → 퀴즈 채점.
// (프론트 SPA 라우팅 + 백엔드 API + DB가 함께 도는 통합 경로를 실제로 태운다.)
test.beforeEach(async ({ page }) => {
await login(page, STUDENT)
})
test('학습 센터 → 카테고리 → 강좌 열기', async ({ page }) => {
await page.goto('/learn')
await expect(page.getByRole('heading', { name: '어썸데브 학습 센터' })).toBeVisible()
// 강좌 카드(링크)가 보이고, 클릭하면 강좌 페이지로 이동한다.
const courseLink = page.locator('a[href^="/learn/"]').first()
await expect(courseLink).toBeVisible()
await courseLink.click()
await expect(page).toHaveURL(/\/learn\/[^/]+$/)
})
test('퀴즈 풀기 → 제출 → 채점 결과 표시', async ({ page }) => {
test.skip(!QUIZ_SLUG, '퀴즈가 있는 코스 slug가 주입되지 않았음(E2E_QUIZ_SLUG)')
// "안 푼 문제가 있어요" 확인창이 뜨면 자동 수락(모두 풀 것이므로 보통 안 뜬다).
page.on('dialog', (dialog) => dialog.accept())
await page.goto(`/learn/${QUIZ_SLUG}/quiz`)
// 제출 버튼이 보일 때까지 = 문항 로드 완료.
const submit = page.getByRole('button', { name: '제출하고 채점받기' })
await expect(submit).toBeVisible()
// 각 문항 카드에서 첫 번째 보기를 고른다.
// 보기는 라디오를 감싼 <label>이므로, 라벨을 클릭하면 해당 보기가 선택된다.
const questionCards = page.locator('.card').filter({ has: page.locator('input[type=radio]') })
const count = await questionCards.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
await questionCards.nth(i).locator('label').first().click()
}
await submit.click()
// 서버 채점 결과가 표시된다(점수).
await expect(page.getByText(/채점 완료/)).toBeVisible()
await expect(page.locator('.stat-num')).toContainText('/')
})

View File

@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test'
import { login, MENTOR } from './helpers.js'
// 멘토 여정: 로그인 → 멘토 화면 → 인적사항 명단 + 개인정보 접근 기록(감사로그).
// 명단을 조회하는 순간 백엔드가 감사기록(VIEW_STUDENT_ROSTER)을 남기고, 화면에 보인다 —
// SEC-02 감사로그가 실제로 "쓰이고 보이는지"까지 한 번에 검증한다.
test('멘토 로그인 → 멘토 화면 + 감사로그 표시', async ({ page }) => {
await login(page, MENTOR)
await page.goto('/mentor')
await expect(page.getByRole('heading', { name: '멘토 리뷰' })).toBeVisible()
// 수습생 명단 섹션
await expect(page.getByRole('heading', { name: '수습생 명단' })).toBeVisible()
// 개인정보 접근 기록(감사로그) 섹션 + 방금 명단 조회가 기록으로 남았는지
await expect(page.getByRole('heading', { name: '개인정보 접근 기록' })).toBeVisible()
await expect(page.getByText('명단 조회', { exact: false }).first()).toBeVisible()
})

View File

@ -9,8 +9,8 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
export default [ export default [
// 검사에서 제외할 폴더 (빌드 산출물·커버리지·의존성) // 검사에서 제외할 폴더 (빌드 산출물·커버리지·의존성·E2E 리포트)
{ ignores: ['dist', 'coverage', 'node_modules'] }, { ignores: ['dist', 'coverage', 'node_modules', 'playwright-report', 'test-results'] },
{ {
files: ['**/*.{js,jsx}'], files: ['**/*.{js,jsx}'],

View File

@ -16,6 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@playwright/test": "1.49.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"eslint": "^9.17.0", "eslint": "^9.17.0",
@ -1032,6 +1033,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -3171,6 +3188,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.19", "version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",

View File

@ -9,7 +9,8 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"lint": "eslint ." "lint": "eslint .",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
@ -20,6 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@playwright/test": "1.49.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"eslint": "^9.17.0", "eslint": "^9.17.0",

View File

@ -0,0 +1,29 @@
import { defineConfig, devices } from '@playwright/test'
// 이 파일이 하는 일: E2E(End-to-End) 테스트 설정.
//
// 학습 포인트 — E2E는 무엇을 검증하나?
// 단위/컴포넌트 테스트는 "부품 하나"를 본다. E2E는 "다 조립된 앱"을 진짜 브라우저로
// 사용자처럼 눌러 본다 — 프론트+백엔드+DB가 함께 도는 통합 경로(로그인→강좌→퀴즈)를
// 확인해, 부품별 테스트가 못 잡는 "연결 지점의 회귀"를 잡는다.
//
// 스택(프론트+백엔드+DB)은 deploy/e2e.sh 가 격리된 임시 환경으로 띄우고(운영 무오염),
// 이 설정은 그 주소(E2E_BASE_URL)를 브라우저로 연다. 계정/퀴즈 slug도 env로 주입받는다.
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
// 하나의 임시 DB를 공유하므로 순차 실행(계정/세션이 서로 안 얽히게).
fullyParallel: false,
workers: 1,
// 네트워크 타이밍으로 한 번 실패할 수 있으니 1회 재시도(진짜 회귀는 재시도해도 실패).
retries: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:8899',
trace: 'on-first-retry', // 재시도 시 추적 파일 남겨 실패 원인 분석
screenshot: 'only-on-failure',
actionTimeout: 10_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})