Playwright 기반 E2E 테스트 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 07:42:51 +09:00
1 parent 306fcb2420
commit bb0328b39e
14 files changed
+890

No files matched your search

+3
View File
@@ -30,3 +30,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# E2E tests
/tests/e2e/results
+3
View File
@@ -28,3 +28,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# E2E tests
/tests/e2e/results
+2
View File
@@ -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",
+28
View File
@@ -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
+59
View File
@@ -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();
}
});
+127
View File
@@ -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<string, number>;
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$/)]);
});
+54
View File
@@ -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
+181
View File
@@ -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();
});
+18
View File
@@ -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",
},
});
+17
View File
@@ -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
+106
View File
@@ -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<string, number>;
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 });
});
+222
View File
@@ -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<string, string> = {};
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);
}
}
+55
View File
@@ -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<Blob>((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<string, Asset>;
};
// 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),
});
+15
View File
@@ -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");
}