frontend/e2e/i18n.spec.ts
Rama main · réplica de lectura del repositorio original.
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<string[]> {
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)
})