From bb0328b39ef3d228547a1106d45d42bf81cce38c Mon Sep 17 00:00:00 2001 From: static Date: Mon, 12 Oct 2026 07:42:51 +0900 Subject: [PATCH] =?UTF-8?q?Playwright=20=EA=B8=B0=EB=B0=98=20E2E=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 3 + .gitignore | 3 + package.json | 2 + pnpm-lock.yaml | 28 ++++ tests/e2e/auth.spec.ts | 59 ++++++++ tests/e2e/cache.spec.ts | 127 +++++++++++++++++ tests/e2e/docker-compose.yaml | 54 ++++++++ tests/e2e/files.spec.ts | 181 ++++++++++++++++++++++++ tests/e2e/playwright.config.ts | 18 +++ tests/e2e/run.sh | 17 +++ tests/e2e/streaming.spec.ts | 106 ++++++++++++++ tests/e2e/support/app.ts | 222 ++++++++++++++++++++++++++++++ tests/e2e/support/assets.ts | 55 ++++++++ tests/e2e/support/global-setup.ts | 15 ++ 14 files changed, 890 insertions(+) create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/cache.spec.ts create mode 100644 tests/e2e/docker-compose.yaml create mode 100644 tests/e2e/files.spec.ts create mode 100644 tests/e2e/playwright.config.ts create mode 100755 tests/e2e/run.sh create mode 100644 tests/e2e/streaming.spec.ts create mode 100644 tests/e2e/support/app.ts create mode 100644 tests/e2e/support/assets.ts create mode 100644 tests/e2e/support/global-setup.ts diff --git a/.dockerignore b/.dockerignore index 4f68a3b..393fada 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,3 +30,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# E2E tests +/tests/e2e/results diff --git a/.gitignore b/.gitignore index 5078fa8..1582057 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# E2E tests +/tests/e2e/results diff --git a/package.json b/package.json index d4d40ba..68a712b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint": "prettier --check . && eslint .", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "bash tests/e2e/run.sh", "db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d", "db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down", "db:migrate": "kysely migrate" @@ -23,6 +24,7 @@ "@eslint/js": "^10.0.1", "@iconify-json/material-symbols": "^1.2.95", "@noble/hashes": "^2.4.0", + "@playwright/test": "1.64.0", "@sveltejs/adapter-node": "^6.0.0", "@sveltejs/kit": "^3.0.1", "@sveltejs/load-config": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c94737c..4ce0573 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@noble/hashes': specifier: ^2.4.0 version: 2.4.0 + '@playwright/test': + specifier: 1.64.0 + version: 1.64.0 '@sveltejs/adapter-node': specifier: ^6.0.0 version: 6.0.0(@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.71.1))(vite@8.3.4(@types/node@26.6.5)(jiti@1.21.7)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.71.1))(typescript@6.0.3)(vite@8.3.4(@types/node@26.6.5)(jiti@1.21.7)(yaml@2.9.1))) @@ -332,6 +335,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1993,6 +2001,16 @@ packages: pkg-types@2.3.3: resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -2803,6 +2821,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {} @@ -4260,6 +4282,12 @@ snapshots: exsolve: 1.1.3 pathe: 2.0.3 + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + postcss-load-config@3.1.4(postcss@8.5.29): dependencies: lilconfig: 2.1.0 diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..14cda99 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { App } from "./support/app.ts"; + +let app: App; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async ({ browser }) => { + app = await App.create(browser); +}); + +test.afterAll(async () => { + await app.close(); +}); + +test("로그인 후 새 암호 키를 생성하면 홈으로 이동한다", async () => { + await app.login(); + await app.generateKeys(); + await app.page.waitForURL(/\/home/); +}); + +test("비밀번호를 바꾸지 못하면 오류를 토스트로 표시한다", async () => { + const { page } = app; + await page.goto("/auth/change-password"); + await app.field("기존 비밀번호").fill("wrong-password"); + await app.field("새 비밀번호").fill("new-password-1234"); + await app.field("새 비밀번호 확인").fill("new-password-1234"); + await page.getByRole("button", { name: "비밀번호 바꾸기" }).click(); + + await expect(page.getByRole("alert")).toContainText("비밀번호를 바꾸지 못했어요"); + await expect(page).toHaveURL(/\/auth\/change-password/); +}); + +test("저장된 키가 있으면 다시 로그인할 때 키를 생성하지 않는다", async () => { + await app.page.goto("/menu"); + await app.text("로그아웃").click(); + await app.page.waitForURL(/\/auth\/login/); + + await app.login(); + await app.page.waitForURL(/\/home/); +}); + +test("새 디바이스는 다른 디바이스의 승인을 기다린다", async ({ browser }) => { + const device = await app.addDevice(browser); + const { page } = device; + + try { + await device.login(); + await device.generateKeys(); + await page.waitForURL(/\/client\/pending/); + await expect(page.getByText("암호 키 지문", { exact: true })).toBeVisible(); + await expect(page.locator("body")).toContainText(/[0-9A-F]{4} [0-9A-F]{4}/); + + await page.waitForTimeout(1000); + await expect(page).toHaveURL(/\/client\/pending/); + } finally { + await device.close(); + } +}); diff --git a/tests/e2e/cache.spec.ts b/tests/e2e/cache.spec.ts new file mode 100644 index 0000000..15d3e5a --- /dev/null +++ b/tests/e2e/cache.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from "@playwright/test"; +import { readFile, writeFile } from "fs/promises"; +import { App, CHUNK_SIZE, sha256 } from "./support/app.ts"; +import { createLargeAsset, createMediaAssets, type Asset } from "./support/assets.ts"; + +// Values of `HTMLMediaElement.readyState`, as DOM globals aren't available in tests +const HAVE_CURRENT_DATA = 2; + +let app: App; +let image: Asset; +let video: Asset; +let large: Asset; +let fileIds: Map; + +const listUploadCaches = () => + app.page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const cache = await root.getDirectoryHandle("cache", { create: true }); + const upload = await cache.getDirectoryHandle("upload", { create: true }); + const names: string[] = []; + for await (const name of upload.keys()) names.push(name); + return names; + }); + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async ({ browser }) => { + app = await App.create(browser); + await app.signUp(); + ({ image, video } = await createMediaAssets(app.page)); + large = createLargeAsset(); +}); + +test.afterAll(async () => { + await app.close(); +}); + +test("업로드한 파일은 청크 단위로 캐시되고 임시 폴더는 남지 않는다", async () => { + await app.upload([image, video, large]); + fileIds = await app.getFileIds(); + + for (const asset of [image, video, large]) { + await app.expectCached(fileIds.get(asset.mimeType)!, asset); + } + expect(await listUploadCaches()).toEqual([]); +}); + +test("일부 구간을 요청하면 해당 청크만 캐시되고, 이후 캐시와 서버의 청크를 함께 사용한다", async () => { + const id = fileIds.get(large.mimeType)!; + // Opening the file lets the service worker know its key, and saves it + const download = app.page.waitForEvent("download"); + await app.openFile(large.name); + await (await download).path(); + await app.expectCached(id, large); + await app.clearCache(id); + + const start = CHUNK_SIZE + 10; + const partial = await app.fetchDecrypted(id, `bytes=${start}-${start + 10}`); + expect(partial).toMatchObject({ + status: 206, + hash: sha256(large.buffer.subarray(start, start + 11)), + }); + await app.expectCached(id, large, [1]); + + // Chunk 1 is read from the cache, and chunks 0 and 2 from the server + const full = await app.fetchDecrypted(id); + expect(full).toMatchObject({ status: 200, hash: sha256(large.buffer) }); + await app.expectCached(id, large); +}); + +test("재생하면서 캐시된 동영상은 다음 재생 때 서버 대신 캐시에서 읽는다", async () => { + const id = fileIds.get(video.mimeType)!; + await app.clearCache(id); + + await app.page.goto(`/file/${id}`); + const element = app.page.locator("video"); + await expect + .poll(() => element.evaluate((video: HTMLVideoElement) => video.readyState)) + .toBeGreaterThanOrEqual(HAVE_CURRENT_DATA); + await app.expectCached(id, video); + + // Corrupt the content on the server, so that only the cache can be decrypted + const path = await app.getFilePath(id); + const original = await readFile(path); + await writeFile(path, Buffer.alloc(original.length)); + try { + await app.page.goto("/directory"); + await app.page.goto(`/file/${id}`); + await expect + .poll(() => element.evaluate((video: HTMLVideoElement) => video.readyState)) + .toBeGreaterThanOrEqual(HAVE_CURRENT_DATA); + expect((await app.fetchDecrypted(id)).hash).toBe(sha256(video.buffer)); + } finally { + await writeFile(path, original); + } +}); + +test("이전 형식의 캐시와 오래된 업로드 임시 폴더는 앱을 시작할 때 삭제된다", async () => { + const { page } = app; + await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const cache = await root.getDirectoryHandle("cache", { create: true }); + const writable = await (await cache.getFileHandle("999999", { create: true })).createWritable(); + await writable.write("plaintext"); + await writable.close(); + + // A stale one, and one which might be in progress in another tab + const upload = await cache.getDirectoryHandle("upload", { create: true }); + await upload.getDirectoryHandle("1000-stale", { create: true }); + await upload.getDirectoryHandle(`${Date.now()}-fresh`, { create: true }); + }); + await page.reload(); + + await expect + .poll(() => + page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const cache = await root.getDirectoryHandle("cache"); + return await cache.getFileHandle("999999").then( + () => true, + () => false, + ); + }), + ) + .toBe(false); + await expect.poll(listUploadCaches).toEqual([expect.stringMatching(/-fresh$/)]); +}); diff --git a/tests/e2e/docker-compose.yaml b/tests/e2e/docker-compose.yaml new file mode 100644 index 0000000..bc78921 --- /dev/null +++ b/tests/e2e/docker-compose.yaml @@ -0,0 +1,54 @@ +# Runs the app built from the Dockerfile and the E2E tests in isolation (see run.sh) +name: arkvault-e2e + +services: + database: + image: postgres:17 + environment: + POSTGRES_USER: arkvault + POSTGRES_PASSWORD: e2e + POSTGRES_DB: arkvault + tmpfs: /var/lib/postgresql/data + healthcheck: + test: pg_isready -U arkvault + interval: 1s + retries: 30 + + app: + build: ../.. + user: ${E2E_UID:-0}:${E2E_GID:-0} + environment: + NODE_ENV: production + PORT: 4173 + ORIGIN: http://127.0.0.1:4173 + DATABASE_HOST: database + DATABASE_USER: arkvault + DATABASE_PASSWORD: e2e + DATABASE_NAME: arkvault + SESSION_SECRET: e2e + LIBRARY_PATH: /data/library + THUMBNAILS_PATH: /data/thumbnails + volumes: + - ${E2E_DATA_DIR:?}:/data + depends_on: + database: + condition: service_healthy + + playwright: + # Must match the version of @playwright/test + image: mcr.microsoft.com/playwright:v1.64.0-noble + user: ${E2E_UID:-0}:${E2E_GID:-0} + # Shares the network with the app, so that it is served from a secure context (127.0.0.1) + # which service workers require + network_mode: service:app + working_dir: /work + environment: + HOME: /tmp + CI: ${CI:-} + volumes: + - ../..:/work + # Accessed by the tests to manipulate the stored files + - ${E2E_DATA_DIR:?}:/data + command: npx playwright test -c tests/e2e/playwright.config.ts ${E2E_ARGS:-} + depends_on: + - app diff --git a/tests/e2e/files.spec.ts b/tests/e2e/files.spec.ts new file mode 100644 index 0000000..ba33861 --- /dev/null +++ b/tests/e2e/files.spec.ts @@ -0,0 +1,181 @@ +import { expect, test } from "@playwright/test"; +import { App } from "./support/app.ts"; +import { createLargeAsset, createMediaAssets, type Asset } from "./support/assets.ts"; + +let app: App; +let image: Asset; +let video: Asset; +let large: Asset; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async ({ browser }) => { + app = await App.create(browser); + await app.signUp(); + ({ image, video } = await createMediaAssets(app.page)); + large = createLargeAsset(); +}); + +test.afterAll(async () => { + await app.close(); +}); + +test("폴더를 만든다", async () => { + await app.page.goto("/directory"); + await app.page.locator("button.pointer-events-auto").click(); // Floating button + await app.text("폴더 만들기").click(); + await app.field("폴더 이름").fill("문서"); + await app.text("만들기").click(); + + await expect(app.row("문서")).toBeVisible(); +}); + +test("파일을 업로드하면 목록에 썸네일과 함께 표시된다", async () => { + await app.upload([image, video, large]); + + await app.page.reload(); + for (const { name } of [image, video, large]) { + await expect(app.row(name)).toBeVisible(); + } + await expect(app.row(image.name).locator("img")).toBeVisible(); + await expect(app.row(video.name).locator("img")).toBeVisible(); +}); + +test("이미지를 서비스 워커가 복호화한 스트림으로 표시한다", async () => { + await app.openFile(image.name); + + const img = app.page.locator(`img[alt="${image.name}"]`); + await expect(img).toHaveAttribute("src", /^\/_internal\/decrypted-file\/\d+$/); + await expect + .poll(() => img.evaluate((element: HTMLImageElement) => element.naturalWidth)) + .toBe(640); +}); + +test("폴더 이름을 바꾼다", async () => { + await app.page.goto("/directory"); + await app.openMenu("문서"); + await app.text("이름 바꾸기").click(); + await app.field("이름").fill("보관함"); + await app.page.getByRole("button", { name: "바꾸기", exact: true }).click(); + + await expect(app.row("보관함")).toBeVisible(); +}); + +test("즐겨찾기에 추가하고 즐겨찾기 화면에서 해제한다", async () => { + await app.page.goto("/directory"); + await app.openMenu(image.name); + await app.text("즐겨찾기에 추가하기").click(); + await expect(app.text("즐겨찾기에 추가하기")).toBeHidden(); // The menu closes on success + + await app.page.goto("/favorites"); + await expect(app.row(image.name)).toBeVisible(); + await app.row(image.name).locator("#action-button").click(); + await expect(app.row(image.name)).toBeHidden(); + + await app.page.goto("/directory"); + await app.openMenu(image.name); + await expect(app.text("즐겨찾기에 추가하기")).toBeVisible(); +}); + +test("파일 화면의 메뉴에서 즐겨찾기를 전환한다", async () => { + await app.openFile(video.name); + const menuButton = app.page.locator("div.sticky button").last(); + + await menuButton.click(); + await app.text("즐겨찾기").click(); + await menuButton.click(); + await expect(app.text("즐겨찾기 해제")).toBeVisible(); +}); + +test("카테고리를 만들고 파일을 추가, 제거한 뒤 이름을 바꾸고 삭제한다", async () => { + const { page } = app; + await page.goto("/category"); + await app.text("카테고리 추가하기").click(); + await app.field("카테고리 이름").fill("여행"); + await app.text("만들기").click(); + await expect(app.row("여행")).toBeVisible(); + + await app.openFile(image.name); + await app.text("카테고리에 추가하기").click(); + await app.row("여행").click(); + await app.text("여행 카테고리에 추가하기").click(); + await expect(app.row("여행")).toBeVisible(); + + await page.goto("/category"); + await app.row("여행").click(); + await page.waitForURL(/\/category\/\d+/); + await expect(app.row(image.name)).toBeVisible(); + await app.row(image.name).locator("#action-button").click(); + await expect(app.row(image.name)).toBeHidden(); + + await page.goto("/category"); + await app.openMenu("여행"); + await app.text("이름 바꾸기").click(); + await app.field("이름").fill("여행 기록"); + await page.getByRole("button", { name: "바꾸기", exact: true }).click(); + await expect(app.row("여행 기록")).toBeVisible(); + + await app.openMenu("여행 기록"); + await app.text("삭제하기").click(); + await app.text("삭제할게요").click(); + await expect(app.row("여행 기록")).toBeHidden(); +}); + +test("이름으로 검색한다", async () => { + await app.page.goto("/search"); + await app.page.locator("input").first().fill("video"); + + await expect(app.row(video.name)).toBeVisible(); + await expect(app.row(image.name)).toBeHidden(); +}); + +test("갤러리에 사진과 동영상을 표시한다", async () => { + await app.page.goto("/gallery"); + + await expect(app.page.locator(`img[alt="${image.name}"]`)).toBeVisible(); + await expect(app.page.locator(`img[alt="${video.name}"]`)).toBeVisible(); +}); + +test("누락된 썸네일을 다시 생성한다", async () => { + const { rowCount } = await app.db.query( + `DELETE FROM thumbnail WHERE file_id IN ( + SELECT f.id FROM file f JOIN "user" u ON f.user_id = u.id WHERE u.email = $1 + )`, + [app.email], + ); + expect(rowCount).toBe(2); + + await app.page.goto("/settings/thumbnail"); + await expect(app.row(image.name)).toBeVisible(); + await expect(app.row(video.name)).toBeVisible(); + await app.text("모두 썸네일 생성하기").click(); + await expect(app.text("썸네일이 누락된 파일")).toBeHidden({ timeout: 60_000 }); + + const { rows } = await app.db.query( + `SELECT count(*)::int AS count FROM thumbnail t + JOIN file f ON t.file_id = f.id JOIN "user" u ON f.user_id = u.id WHERE u.email = $1`, + [app.email], + ); + expect(rows[0].count).toBe(2); +}); + +test("파일을 삭제한다", async () => { + await app.page.goto("/directory"); + await app.openMenu(large.name); + await app.text("삭제하기").click(); + await app.text("삭제할게요").click(); + await expect(app.row(large.name)).toBeHidden(); + + await app.page.reload(); + await expect(app.row(image.name)).toBeVisible(); + await expect(app.row(large.name)).toBeHidden(); +}); + +test("폴더를 삭제한다", async () => { + await app.page.goto("/directory"); + await app.openMenu("보관함"); + await app.text("삭제하기").click(); + await app.text("삭제할게요").click(); + + await expect(app.row("보관함")).toBeHidden(); +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..a06b651 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; +import { BASE_URL } from "./support/app.ts"; + +// Run with `pnpm test:e2e`, which runs the app and the tests in Docker (see run.sh) +export default defineConfig({ + testDir: ".", + outputDir: "results", + globalSetup: "./support/global-setup.ts", + timeout: 180_000, + expect: { timeout: 30_000 }, + // Each test file signs up a new user, so files can run in parallel + workers: 2, + reporter: [["list"]], + use: { + baseURL: BASE_URL, + trace: "retain-on-failure", + }, +}); diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..ffe0de1 --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Runs the E2E tests in Docker. Arguments are passed to Playwright (e.g. `-g "cache"`). +# The containers are isolated from the development database and removed afterwards. +set -u +cd "$(dirname "$0")" + +export E2E_UID="$(id -u)" E2E_GID="$(id -g)" E2E_ARGS="$*" +E2E_DATA_DIR="$(mktemp -d)" +export E2E_DATA_DIR + +compose=(docker compose -f docker-compose.yaml) +"${compose[@]}" up --build --attach playwright --abort-on-container-exit --exit-code-from playwright +code=$? + +"${compose[@]}" down --volumes --remove-orphans +rm -rf "$E2E_DATA_DIR" +exit $code diff --git a/tests/e2e/streaming.spec.ts b/tests/e2e/streaming.spec.ts new file mode 100644 index 0000000..8d1f471 --- /dev/null +++ b/tests/e2e/streaming.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; +import { readFile } from "fs/promises"; +import { App, sha256 } from "./support/app.ts"; +import { createLargeAsset, createMediaAssets, type Asset } from "./support/assets.ts"; + +// Values of `HTMLMediaElement.readyState`, as DOM globals aren't available in tests +const HAVE_METADATA = 1; + +let app: App; +let video: Asset; +let large: Asset; +let fileIds: Map; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async ({ browser }) => { + app = await App.create(browser); + await app.signUp(); + ({ video } = await createMediaAssets(app.page)); + large = createLargeAsset(); + await app.upload([video, large]); + fileIds = await app.getFileIds(); +}); + +test.afterAll(async () => { + await app.close(); +}); + +test("동영상을 서비스 워커가 복호화한 스트림으로 재생한다", async () => { + await app.openFile(video.name); + + const element = app.page.locator("video"); + await expect(element).toHaveAttribute("src", /^\/_internal\/decrypted-file\/\d+$/); + await expect + .poll(() => element.evaluate((video: HTMLVideoElement) => video.readyState)) + .toBeGreaterThanOrEqual(HAVE_METADATA); +}); + +test("서비스 워커가 재시작되어도 페이지에서 메타데이터를 다시 받아 복호화한다", async () => { + const id = fileIds.get(video.mimeType)!; + await app.clearCache(id); // Decrypt from the server + + const cdp = await app.context.newCDPSession(app.page); + await cdp.send("ServiceWorker.enable"); + await cdp.send("ServiceWorker.stopAllWorkers"); + + const partial = await app.fetchDecrypted(id, "bytes=0-99"); + expect(partial).toMatchObject({ status: 206, size: 100 }); + const full = await app.fetchDecrypted(id); + expect(full).toMatchObject({ status: 200, hash: sha256(video.buffer) }); +}); + +test("서비스 워커가 접미사 범위와 만족할 수 없는 범위를 처리한다", async () => { + const id = fileIds.get(video.mimeType)!; + + const suffix = await app.fetchDecrypted(id, "bytes=-10"); + expect(suffix).toMatchObject({ status: 206, size: 10 }); + expect(suffix.hash).toBe(sha256(video.buffer.subarray(-10))); + + const invalid = await app.fetchDecrypted(id, "bytes=999999999-"); + expect(invalid.status).toBe(416); +}); + +test("다운로드 API는 Range 요청에만 206으로 응답한다", async () => { + const id = fileIds.get(video.mimeType)!; + const result = await app.page.evaluate(async (id) => { + const full = await fetch(`/api/file/${id}/download`); + const partial = await fetch(`/api/file/${id}/download`, { headers: { Range: "bytes=0-9" } }); + return { + full: [full.status, full.headers.get("Content-Range")], + partial: [partial.status, partial.headers.get("Content-Range")], + }; + }, id); + + expect(result.full).toEqual([200, null]); + expect(result.partial[0]).toBe(206); + expect(result.partial[1]).toMatch(/^bytes 0-9\//); +}); + +test("큰 파일을 저장하면 내용이 일치하고 진행률이 전체 크기까지 보고된다", async () => { + const { page } = app; + await app.clearCache(fileIds.get(large.mimeType)!); // Download from the server + await page.goto("/directory"); + await page.evaluate(() => { + const progress: unknown[] = []; + Object.assign(window, { progress }); + navigator.serviceWorker.addEventListener("message", ({ data }) => { + if (data.type === "download-progress") progress.push(data); + }); + }); + + const download = page.waitForEvent("download", { timeout: 120_000 }); + await app.row(large.name).click(); + const content = await readFile(await (await download).path()); + expect(sha256(content)).toBe(sha256(large.buffer)); + + await expect + .poll(() => + page.evaluate(() => + (window as unknown as { progress: { status: string }[] }).progress.find( + ({ status }) => status === "completed", + ), + ), + ) + .toMatchObject({ loaded: large.buffer.length, total: large.buffer.length }); +}); diff --git a/tests/e2e/support/app.ts b/tests/e2e/support/app.ts new file mode 100644 index 0000000..a941f24 --- /dev/null +++ b/tests/e2e/support/app.ts @@ -0,0 +1,222 @@ +import { expect, type Browser, type BrowserContext, type Page } from "@playwright/test"; +import { createHash, randomUUID } from "crypto"; +import pg from "pg"; +import type { Asset } from "./assets.ts"; + +export const BASE_URL = "http://127.0.0.1:4173"; +export const CHUNK_SIZE = 4 * 1024 * 1024; +export const PASSWORD = "password1234"; + +// Argon2 hash of `PASSWORD`, as there is no way to sign up from the app +const PASSWORD_HASH = + "$argon2id$v=19$m=65536,p=4,t=3$EoYOFfAXppmuwKnIlpIJHg$7WMPMiTlC+474Ch3Ov7z8if5gkjJpdl+e+YDrDXRk1Y"; + +export const sha256 = (buffer: Uint8Array) => createHash("sha256").update(buffer).digest("hex"); + +export const createDatabaseClient = async () => { + const db = new pg.Client({ + host: "database", + user: "arkvault", + password: "e2e", + database: "arkvault", + }); + await db.connect(); + return db; +}; + +export const newContext = (browser: Browser) => { + return browser.newContext({ + baseURL: BASE_URL, + acceptDownloads: true, + viewport: { width: 420, height: 900 }, + }); +}; + +// Drives the app as a single user on a single device +export class App { + private constructor( + readonly context: BrowserContext, + readonly page: Page, + readonly db: pg.Client, + readonly email: string, + private readonly ownsDatabase = true, + ) {} + + static async create(browser: Browser) { + const db = await createDatabaseClient(); + const email = `e2e-${randomUUID()}@example.com`; + await db.query(`INSERT INTO "user" (email, nickname, password) VALUES ($1, 'e2e', $2)`, [ + email, + PASSWORD_HASH, + ]); + + const context = await newContext(browser); + return new App(context, await context.newPage(), db, email); + } + + // Another device of the same user, which doesn't have the keys of this device + async addDevice(browser: Browser) { + const context = await newContext(browser); + return new App(context, await context.newPage(), this.db, this.email, false); + } + + async close() { + await this.context.close(); + if (this.ownsDatabase) { + await this.db.end(); + } + } + + // Locators + + row(name: string) { + return this.page.locator("#container", { hasText: name }).first(); + } + + // Inputs are labeled by sibling elements instead of placeholders + field(label: string) { + return this.page + .locator("div.relative", { + has: this.page.locator("label", { hasText: new RegExp(`^\\s*${label}\\s*$`) }), + }) + .locator("input") + .last(); + } + + text(text: string) { + return this.page.getByText(text, { exact: true }).first(); + } + + // Actions + + async login() { + await this.page.goto("/home"); + await this.page.waitForURL(/\/auth\/login/); + await this.field("이메일").fill(this.email); + await this.field("비밀번호").fill(PASSWORD); + await this.text("로그인").click(); + } + + async generateKeys() { + await this.page.waitForURL(/\/key\/generate/); + await this.text("새 암호 키 생성하기").click(); + await this.page.waitForURL(/\/key\/export/); + await this.text("내보내지 않을래요").click(); + await this.text("계속할게요").click(); + } + + async signUp() { + await this.login(); + await this.generateKeys(); + await this.page.waitForURL(/\/home/); + } + + async openMenu(name: string) { + await this.row(name).locator("#action-button").click(); + } + + async upload(assets: Asset[]) { + await this.page.goto("/directory"); + await this.page.locator("input[type=file]").setInputFiles(assets); + for (const { name } of assets) { + await expect(this.row(name)).toBeVisible({ timeout: 60_000 }); + } + + // Wait until the uploads are finished + await expect(this.page.locator("body")).not.toContainText( + /대기 중|준비 중|암호화하는 중|업로드를 기다리는 중|전송됨/, + { timeout: 120_000 }, + ); + } + + async openFile(name: string) { + await this.page.goto("/directory"); + await this.row(name).click(); + await this.page.waitForURL(/\/file\/\d+/); + return Number(new URL(this.page.url()).pathname.split("/").pop()); + } + + // Database + + async getFileIds() { + const { rows } = await this.db.query<{ id: number; content_type: string }>( + `SELECT f.id, f.content_type FROM file f JOIN "user" u ON f.user_id = u.id WHERE u.email = $1`, + [this.email], + ); + return new Map(rows.map(({ id, content_type }) => [content_type, id])); + } + + async getFilePath(fileId: number) { + const { rows } = await this.db.query<{ path: string }>("SELECT path FROM file WHERE id = $1", [ + fileId, + ]); + return rows[0]!.path; + } + + // Service worker and cache + + async fetchDecrypted(fileId: number, range?: string) { + return await this.page.evaluate( + async ({ fileId, range }) => { + const res = await fetch(`/_internal/decrypted-file/${fileId}`, { + headers: range ? { Range: range } : {}, + }); + const buffer = await res.arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", buffer); + return { + status: res.status, + size: buffer.byteLength, + hash: Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""), + contentRange: res.headers.get("Content-Range"), + }; + }, + { fileId, range }, + ); + } + + // Returns the hashes of the cached chunks of the file + async readCache(fileId: number) { + return await this.page.evaluate(async (fileId) => { + const chunks: Record = {}; + try { + const root = await navigator.storage.getDirectory(); + const cache = await root.getDirectoryHandle("cache"); + const files = await cache.getDirectoryHandle("file"); + const directory = await files.getDirectoryHandle(String(fileId)); + for await (const handle of directory.values()) { + if (handle.kind === "file" && /^\d+$/.test(handle.name)) { + const buffer = await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", buffer); + chunks[handle.name] = Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + } + } + } catch { + // Not cached + } + return chunks; + }, fileId); + } + + async clearCache(fileId: number) { + await this.page.evaluate(async (fileId) => { + const root = await navigator.storage.getDirectory(); + const cache = await root.getDirectoryHandle("cache", { create: true }); + const files = await cache.getDirectoryHandle("file", { create: true }); + await files.removeEntry(String(fileId), { recursive: true }).catch(() => {}); + }, fileId); + } + + // Waits until the chunks (all by default) are cached with the content of the asset + async expectCached(fileId: number, asset: Asset, chunkIndexes?: number[]) { + const indexes = chunkIndexes ?? [...Array(Math.ceil(asset.buffer.length / CHUNK_SIZE)).keys()]; + const expected = Object.fromEntries( + indexes.map((index) => [ + String(index), + sha256(asset.buffer.subarray(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)), + ]), + ); + await expect.poll(() => this.readCache(fileId)).toEqual(expected); + } +} diff --git a/tests/e2e/support/assets.ts b/tests/e2e/support/assets.ts new file mode 100644 index 0000000..71668da --- /dev/null +++ b/tests/e2e/support/assets.ts @@ -0,0 +1,55 @@ +import type { Page } from "@playwright/test"; +import { randomBytes } from "crypto"; + +export interface Asset { + name: string; + mimeType: string; + buffer: Buffer; +} + +// Generates a 640x480 PNG image and a short WebM video in the browser +export const createMediaAssets = async (page: Page) => { + const { png, webm } = await page.evaluate(async () => { + const toBase64 = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer()).toBase64(); + + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + const context = canvas.getContext("2d")!; + const draw = (frame: number) => { + context.fillStyle = `hsl(${(frame * 50) % 360}, 80%, 50%)`; + context.fillRect(0, 0, 640, 480); + context.fillStyle = "white"; + context.font = "64px sans-serif"; + context.fillText(`ArkVault ${frame}`, 40, 240); + }; + + draw(0); + const png = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!))); + + const recorder = new MediaRecorder(canvas.captureStream(30), { mimeType: "video/webm" }); + const chunks: Blob[] = []; + recorder.ondataavailable = ({ data }) => chunks.push(data); + recorder.start(); + for (let frame = 0; frame < 30; frame++) { + draw(frame); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + recorder.stop(); + await new Promise((resolve) => (recorder.onstop = resolve)); + + return { png: await toBase64(png), webm: await toBase64(new Blob(chunks)) }; + }); + + return { + image: { name: "image.png", mimeType: "image/png", buffer: Buffer.from(png, "base64") }, + video: { name: "video.webm", mimeType: "video/webm", buffer: Buffer.from(webm, "base64") }, + } satisfies Record; +}; + +// Spans three chunks, the last of which is partial +export const createLargeAsset = (): Asset => ({ + name: "large.bin", + mimeType: "application/octet-stream", + buffer: randomBytes(9 * 1024 * 1024 + 123), +}); diff --git a/tests/e2e/support/global-setup.ts b/tests/e2e/support/global-setup.ts new file mode 100644 index 0000000..3d86a55 --- /dev/null +++ b/tests/e2e/support/global-setup.ts @@ -0,0 +1,15 @@ +import { BASE_URL } from "./app.ts"; + +// Waits until the app is ready (e.g. migrations are done) +export default async function globalSetup() { + for (let i = 0; i < 120; i++) { + try { + const res = await fetch(`${BASE_URL}/auth/login`); + if (res.ok) return; + } catch { + // Not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error("The app didn't start"); +}