mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 00:00:28 +09:00
Playwright 기반 E2E 테스트 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
306fcb2420
commit
bb0328b39e
14 files changed
+890
No files matched your search
@@ -30,3 +30,6 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# E2E tests
|
||||
/tests/e2e/results
|
||||
@@ -28,3 +28,6 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# E2E tests
|
||||
/tests/e2e/results
|
||||
@@ -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",
|
||||
|
||||
Generated
+28
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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$/)]);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Executable
+17
@@ -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
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user