import { expect, test, type Page } from "@playwright/test" import { adminApi, expectNoHorizontalOverflow, newInvite, registerUser, uniqueName } from "./helpers" const CJK = /[㐀-鿿＀-￯ -〿]/ /** Visible UI text (user content excluded: this account has none yet). */ async function visibleCjk(page: Page): Promise { const text = await page.locator("body").innerText() // Language names are shown in their own script ("繁體中文") on purpose. return text.split("\n").filter((l) => CJK.test(l) && l.trim() !== "繁體中文") } test("English: pick it before signing in, whole UI in English, saved to the account", async ({ page }) => { // A fresh browser whose language is Chinese starts in Traditional Chinese … await page.goto("/login") await expect(page.getByRole("heading", { name: "登入" })).toBeVisible() // … the language switch on the sign-in page changes it instantly. await page.getByRole("radio", { name: "English" }).click() await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible() expect(await page.evaluate(() => document.documentElement.lang)).toBe("en") // Server errors come back in English too (the API follows the UI language). await page.getByLabel("Username").fill("nobody-" + Date.now()) await page.getByLabel("Password").fill("wrong-password-123") await page.getByRole("button", { name: "Sign in" }).click() await expect(page.getByRole("alert")).toHaveText("Incorrect username or password") // Register in English. const token = await newInvite() await page.goto(`/register?token=${token}`) await expect(page.getByText("Valid invitation")).toBeVisible() const username = uniqueName("en") await page.getByLabel("Username").fill(username) await page.getByLabel("Display name").fill("Pat Tester") await page.getByLabel("Password", { exact: true }).fill("playwright-pass-123") await page.getByLabel("Confirm password").fill("playwright-pass-123") await page.getByRole("button", { name: "Create account" }).click() await expect(page.getByRole("heading", { level: 1, name: "Home" })).toBeVisible() // Every page: no Chinese left, no horizontal overflow with the (longer) English strings. for (const route of ["/", "/photos", "/photos/albums", "/videos", "/drive", "/shared", "/trash", "/settings"]) { await page.goto(route) await expect(page.locator("main h1").first()).toBeVisible() await page.waitForTimeout(300) expect(await visibleCjk(page), `Chinese text on ${route}`).toEqual([]) await expectNoHorizontalOverflow(page) } // The choice made before signing in was saved to the account (it follows the user to other devices). const me = await page.request.get("/api/me") expect((await me.json()).user.locale).toBe("en") // Switching back in Settings: instant, and saved. await page.goto("/settings") await page.getByRole("radio", { name: "繁體中文" }).click() await expect(page.getByRole("heading", { level: 1, name: "設定" })).toBeVisible() await expect.poll(async () => (await (await page.request.get("/api/me")).json()).user.locale).toBe("zh-TW") await page.reload() await expect(page.getByRole("heading", { level: 1, name: "設定" })).toBeVisible() }) test("account language applies after signing in on another device", async ({ browser }) => { const admin = await adminApi() const token = (await (await admin.post("/api/admin/invitations", { data: {} })).json()).token const username = uniqueName("dev") const reg = await admin.post("/api/auth/register", { data: { invite_token: token, username, display_name: "Dev", password: "playwright-pass-123" }, }) expect(reg.ok()).toBeTruthy() await admin.dispose() // "Device 1": save English to the account. const one = await browser.newContext({ ignoreHTTPSErrors: true, locale: "zh-TW" }) const p1 = await one.newPage() await p1.goto("/login") await p1.getByLabel("使用者名稱").fill(username) await p1.getByLabel("密碼").fill("playwright-pass-123") await p1.getByRole("button", { name: "登入" }).click() await expect(p1.getByRole("heading", { level: 1, name: "首頁" })).toBeVisible() await p1.goto("/settings") await p1.getByRole("radio", { name: "English" }).click() await expect(p1.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible() await expect.poll(async () => (await (await p1.request.get("/api/me")).json()).user.locale).toBe("en") // "Device 2" (Chinese browser, nothing stored): English right after signing in. const two = await browser.newContext({ ignoreHTTPSErrors: true, locale: "zh-TW" }) const p2 = await two.newPage() await p2.goto("/login") await p2.getByLabel("使用者名稱").fill(username) await p2.getByLabel("密碼").fill("playwright-pass-123") await p2.getByRole("button", { name: "登入" }).click() await expect(p2.getByRole("heading", { level: 1, name: "Home" })).toBeVisible() await one.close() await two.close() }) test("drive: same-name upload offers replace / keep both / skip; trash has undo", async ({ page }) => { await registerUser(page) await page.goto("/drive") const input = page.locator('input[type="file"]') const file = (body: string) => ({ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from(body) }) await input.setInputFiles(file("v1")) await expect(page.getByRole("button", { name: "notes.txt", exact: true })).toBeVisible() // Keep both -> "notes (1).txt" await input.setInputFiles(file("v2")) const dialog = page.getByRole("dialog") await expect(dialog.getByText("這個位置已經有同名的檔案:")).toBeVisible() await dialog.getByRole("button", { name: "兩者都保留" }).click() await expect(page.getByRole("button", { name: "notes (1).txt", exact: true })).toBeVisible() // Skip -> nothing new await input.setInputFiles(file("v3")) await dialog.getByRole("button", { name: "略過" }).click() await expect(page.getByText("已略過同名檔案")).toBeVisible() // Replace -> still one "notes.txt", the old one is in the trash await input.setInputFiles(file("v4 replaced")) await dialog.getByRole("button", { name: "取代" }).click() await expect.poll(async () => { const r = await page.request.get("/api/drive/list") return (await r.json()).files.map((f: { name: string }) => f.name).sort().join("|") }).toBe("notes (1).txt|notes.txt") const trash = await (await page.request.get("/api/drive/trash")).json() expect(trash.files.map((f: { name: string }) => f.name)).toEqual(["notes.txt"]) // Move to trash, then Undo from the toast. await page.getByRole("button", { name: "notes (1).txt", exact: true }).click({ button: "right" }) await page.getByRole("menuitem", { name: "移到垃圾桶" }).click() await page.getByRole("dialog").getByRole("button", { name: "移到垃圾桶" }).click() await expect(page.getByRole("button", { name: "notes (1).txt", exact: true })).toHaveCount(0) await page.getByRole("button", { name: "復原" }).click() await expect(page.getByRole("button", { name: "notes (1).txt", exact: true })).toBeVisible() }) test("admin: an expired confirmation asks for the password, then the action goes through", async ({ page }) => { await page.goto("/login") await page.getByLabel("使用者名稱").fill("admin") await page.getByLabel("密碼").fill(process.env.E2E_ADMIN_PASSWORD!) await page.getByRole("button", { name: "登入" }).click() await expect(page).not.toHaveURL(/\/login/) // Simulate the 15-minute window having passed: the server refuses the first two attempts. let refusals = 0 await page.route("**/api/admin/invitations", async (route) => { if (route.request().method() === "POST" && refusals < 2) { refusals++ await route.fulfill({ status: 403, contentType: "application/json", body: JSON.stringify({ detail: "x", code: "confirmation_required" }) }) return } await route.continue() }) await page.goto("/admin") await page.getByRole("radio", { name: "邀請" }).click() await page.getByLabel("備註(選填)").fill("needs confirmation") await page.getByRole("button", { name: "建立", exact: true }).click() const confirm = page.getByRole("dialog", { name: "確認密碼以繼續" }) await expect(confirm).toBeVisible() await confirm.getByLabel("密碼").fill("definitely-wrong") await confirm.getByRole("button", { name: "確認" }).click() await expect(page.getByText("密碼不正確")).toBeVisible() // Again, with the right password: the original request is retried automatically. await page.getByRole("button", { name: "建立", exact: true }).click() await expect(confirm).toBeVisible() await confirm.getByLabel("密碼").fill(process.env.E2E_ADMIN_PASSWORD!) await confirm.getByRole("button", { name: "確認" }).click() await expect(page.locator("code")).toContainText("/register?token=") expect(refusals).toBe(2) }) test("drive: a large file goes through the resumable (tus) upload, including replace", async ({ page }) => { await registerUser(page) await page.goto("/drive") const tusCalls: string[] = [] page.on("request", (r) => { if (r.url().includes("/api/uploads")) { tusCalls.push(r.method()) } }) const input = page.locator('input[type="file"]') const big = (fill: number) => ({ name: "big.bin", mimeType: "application/octet-stream", buffer: Buffer.alloc(9 * 1024 * 1024, fill) }) await input.setInputFiles(big(1)) await expect(page.getByRole("button", { name: "big.bin", exact: true })).toBeVisible({ timeout: 60_000 }) expect(tusCalls).toContain("POST") expect(tusCalls).toContain("PATCH") // Same name again, "replace": the old one goes to the trash, the new content is served. await input.setInputFiles(big(2)) await page.getByRole("dialog").getByRole("button", { name: "取代" }).click() await expect.poll(async () => (await (await page.request.get("/api/drive/trash")).json()).files.length, { timeout: 60_000 }).toBe(1) const files = (await (await page.request.get("/api/drive/list")).json()).files expect(files.map((f: { name: string }) => f.name)).toEqual(["big.bin"]) const body = await (await page.request.get(`/api/files/${files[0].id}/content?download=1`)).body() expect(body.length).toBe(9 * 1024 * 1024) expect(body[0]).toBe(2) }) test("admin: create and revoke a Nextcloud integration token", async ({ page }) => { await page.goto("/login") await page.getByLabel("使用者名稱").fill("admin") await page.getByLabel("密碼").fill(process.env.E2E_ADMIN_PASSWORD!) await page.getByRole("button", { name: "登入" }).click() await expect(page).not.toHaveURL(/\/login/) await page.goto("/storage") await page.getByRole("radio", { name: "整合" }).click() const name = `NC ${Date.now()}` await page.getByLabel("名稱").fill(name) await page.getByRole("button", { name: "建立權杖" }).click() const done = page.getByRole("status").filter({ hasText: name }) await expect(done).toContainText("權杖只會顯示這一次") await expect(done.locator("pre").first()).toContainText("mhi_") await expect(done.locator("pre").nth(2)).toHaveText(/^[0-9a-f]{64}$/) await done.getByRole("button", { name: "我已經複製好了" }).click() const row = page.getByRole("listitem").filter({ hasText: name }) await expect(row).toContainText("使用中") await row.getByRole("button", { name: "撤銷" }).click() await page.getByRole("dialog").getByRole("button", { name: "撤銷" }).click() await expect(row).toContainText("已撤銷") await expectNoHorizontalOverflow(page) }) test("settings: sign in on the phone with a QR code", async ({ page }) => { await registerUser(page) await page.goto("/settings") const created = page.waitForResponse((r) => r.url().endsWith("/api/me/device-logins") && r.request().method() === "POST") await page.getByRole("button", { name: "顯示登入 QR Code" }).click() const payload = (await (await created).json()).payload await expect(page.getByRole("img", { name: "登入 QR Code" }).locator("svg")).toBeVisible() await expect(page.getByText("請勿把這個 QR Code 給別人看")).toBeVisible() // What the app does after scanning: const r = await page.request.post("/api/auth/device-login", { data: { code: payload.code, device_name: "Test Phone" }, headers: { "X-Requested-With": "mediahub" }, }) expect(r.ok()).toBeTruthy() await expect(page.getByText("已在「Test Phone」登入。")).toBeVisible() await expect(page.getByText("Media Hub App · Test Phone")).toBeVisible() await expectNoHorizontalOverflow(page) })