import { expect, test } from "@playwright/test" import path from "node:path" import { adminApi, expectNoHorizontalOverflow, isMobile, MEDIA, navTo, registerUser, uniqueName } from "./helpers" test.describe.configure({ mode: "serial" }) // Diagnostics: surface network-level failures (e.g. aborted requests) in the output. test.beforeEach(({ page }) => { page.on("requestfailed", (r) => console.log(`REQFAIL ${r.method()} ${r.url()} :: ${r.failure()?.errorText}`)) page.on("crash", () => console.log("PAGE CRASHED")) }) test("unauthenticated users are sent to login; bad password shows an error", async ({ page }) => { await page.goto("/drive") await expect(page).toHaveURL(/\/login\?next=%2Fdrive/) await expect(page.getByRole("heading", { name: "登入" })).toBeVisible() await page.getByLabel("使用者名稱").fill(uniqueName("nobody")) await page.getByLabel("密碼").fill("definitely-wrong") await page.getByRole("button", { name: "登入" }).click() await expect(page.getByRole("alert")).toContainText("帳號或密碼錯誤") await expectNoHorizontalOverflow(page) }) test("register via invite, home, logout and login again", async ({ page }) => { const { username, password } = await registerUser(page) await expect(page.getByRole("link", { name: /照片/ }).first()).toBeVisible() await expect(page.getByRole("heading", { name: "儲存空間" })).toBeVisible() await expectNoHorizontalOverflow(page) await page.goto("/settings") await page.getByRole("button", { name: "登出" }).last().click() await expect(page).toHaveURL(/\/login/) await page.getByLabel("使用者名稱").fill(username) await page.getByLabel("密碼").fill(password) await page.getByRole("button", { name: "登入" }).click() await expect(page.getByRole("heading", { level: 1, name: "首頁" })).toBeVisible() }) test("photos: multi-upload, grid, viewer (zoom/next/info), album", async ({ page }) => { await registerUser(page) await navTo(page, "照片") await expect(page.getByText("還沒有照片")).toBeVisible() const files = ["photo_0.jpg", "photo_1.jpg", "photo_2.jpg", "big_8000x6000.jpg", "photo.heic"].map((f) => path.join(MEDIA, f)) await page.locator('input[type="file"]').setInputFiles(files) await expect(page.getByRole("region", { name: "上傳進度" })).toContainText("上傳完成", { timeout: 30_000 }) const grid = page.getByRole("button", { name: /^(photo[_.]|big_)/ }) await expect(grid).toHaveCount(5) // Thumbnails appear once the worker has processed them (lazy-loaded ). await expect(page.locator('img[src*="/thumb?size=sm"]')).toHaveCount(5, { timeout: 30_000 }) await expectNoHorizontalOverflow(page) await page.getByRole("button", { name: "big_8000x6000.jpg" }).click() const viewer = page.getByRole("dialog", { name: /照片預覽/ }) await expect(viewer).toBeVisible() await expect(viewer.locator("img")).toHaveAttribute("src", /thumb\?size=lg/) await page.keyboard.press("+") // Zooming in switches to the full-resolution original. await expect(viewer.locator("img")).toHaveAttribute("src", /\/content$/) await page.keyboard.press("0") await page.keyboard.press("i") await expect(page.getByRole("complementary", { name: "照片資訊" })).toContainText("8000 × 6000") await expect(page.getByRole("complementary", { name: "照片資訊" })).toContainText("Canon EOS R5") await page.keyboard.press("i") // The 2024 photo is the last (oldest) item: → is a no-op, ← goes to the previous one. const before = await viewer.getAttribute("aria-label") await page.keyboard.press("ArrowRight") await expect(viewer).toHaveAttribute("aria-label", before!) await page.keyboard.press("ArrowLeft") await expect(viewer).not.toHaveAttribute("aria-label", before!) await viewer.getByRole("button", { name: "收藏" }).click() await expect(viewer.getByRole("button", { name: "取消收藏" })).toBeVisible() await page.keyboard.press("Escape") await expect(viewer).toBeHidden() // Select two photos and add them to a new album. await page.getByRole("button", { name: "選取" }).click() await page.getByRole("button", { name: "photo_0.jpg" }).click() await page.getByRole("button", { name: "photo_1.jpg" }).click() await page.getByRole("button", { name: "加入相簿" }).click() const album = uniqueName("旅行") await page.getByLabel("新相簿").fill(album) await page.getByRole("button", { name: "建立並加入" }).click() await expect(page.getByText("已加入 2 個項目")).toBeVisible() await page.goto("/photos/albums") await page.getByRole("link", { name: new RegExp(album) }).click() await expect(page.getByRole("button", { name: /photo_/ })).toHaveCount(2) await expectNoHorizontalOverflow(page) }) test("videos: upload, play, seek, resume", async ({ page, browserName }) => { await registerUser(page) await navTo(page, "影片") await page.locator('input[type="file"]').setInputFiles(path.join(MEDIA, "sample_1080p.mp4")) const card = page.getByRole("link", { name: /sample_1080p\.mp4/ }) await expect(card).toBeVisible({ timeout: 30_000 }) await expect(card).toContainText("0:30", { timeout: 30_000 }) await expectNoHorizontalOverflow(page) await card.click() const video = page.locator("video") await expect(video).toBeVisible() await expect(page.getByText("直接播放原始檔")).toBeVisible() test.skip(browserName === "firefox" && !process.env.FIREFOX_H264, "Playwright's Firefox build ships without H.264") await video.evaluate((v: HTMLVideoElement) => { v.muted = true return v.play() }) await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), { timeout: 20_000 }).toBeGreaterThan(0.5) // Seek through the custom progress slider (keyboard: → = +5s). await video.evaluate((v: HTMLVideoElement) => v.pause()) await page.getByRole("slider", { name: "播放進度" }).fill("20") await expect.poll(() => video.evaluate((v: HTMLVideoElement) => Math.round(v.currentTime))).toBe(20) await page.keyboard.press("ArrowLeft") await expect.poll(() => video.evaluate((v: HTMLVideoElement) => Math.round(v.currentTime))).toBe(15) // Progress is saved on seek/pause; reloading offers resume. await page.waitForTimeout(800) await page.reload() await expect(page.getByText(/從 0:1\d 繼續播放/)).toBeVisible() await expect.poll(() => page.locator("video").evaluate((v: HTMLVideoElement) => Math.round(v.currentTime))).toBeGreaterThanOrEqual(14) }) test("drive: folders, upload, rename, move, grid/list, search, trash & restore, download", async ({ page }) => { await registerUser(page) await navTo(page, "檔案") await page.getByRole("button", { name: "新增資料夾" }).click() await page.getByLabel("名稱").fill("文件") await page.getByRole("button", { name: "建立" }).click() await page.getByRole("button", { name: "文件", exact: true }).click() await expect(page.getByRole("navigation", { name: "路徑" })).toContainText("文件") await page.locator('input[type="file"]').setInputFiles([path.join(MEDIA, "notes.txt"), path.join(MEDIA, "code.py")]) await expect(page.getByRole("button", { name: "notes.txt", exact: true })).toBeVisible({ timeout: 20_000 }) await expectNoHorizontalOverflow(page) // Rename via the row menu. await page.getByRole("button", { name: "notes.txt 的動作" }).click() await page.getByRole("menuitem", { name: "重新命名" }).click() await page.getByLabel("名稱").fill("readme.txt") await page.getByRole("button", { name: "重新命名" }).click() await expect(page.getByRole("button", { name: "readme.txt", exact: true })).toBeVisible() // Download. const dl = page.waitForEvent("download") await page.getByRole("button", { name: "readme.txt 的動作" }).click() await page.getByRole("menuitem", { name: "下載" }).click() expect((await dl).suggestedFilename()).toBe("readme.txt") // Move code.py to the root. await page.getByRole("button", { name: "code.py 的動作" }).click() await page.getByRole("menuitem", { name: "移動到…" }).click() await page.getByRole("dialog").getByRole("button", { name: "我的檔案" }).click() await page.getByRole("button", { name: "移動", exact: true }).click() await expect(page.getByText("已移動 1 個項目")).toBeVisible() await expect(page.getByRole("button", { name: "code.py", exact: true })).toBeHidden() // Grid view + global search. await page.getByRole("radio", { name: "圖示" }).click() await page.getByRole("searchbox", { name: "搜尋所有檔案" }).fill("code") await expect(page.getByRole("button", { name: "code.py", exact: true })).toBeVisible() await page.getByRole("searchbox", { name: "搜尋所有檔案" }).fill("") await page.getByRole("radio", { name: "清單" }).click() // Trash and restore. await page.getByRole("button", { name: "readme.txt 的動作" }).click() await page.getByRole("menuitem", { name: "移到垃圾桶" }).click() await page.getByRole("dialog").getByRole("button", { name: "移到垃圾桶" }).click() await expect(page.getByRole("button", { name: "readme.txt", exact: true })).toBeHidden() await page.goto("/trash") await expect(page.getByText("readme.txt")).toBeVisible() await page.getByRole("button", { name: "還原" }).click() await expect(page.getByText("垃圾桶是空的")).toBeVisible() await expectNoHorizontalOverflow(page) }) test("settings: dark mode persists; admin pages are hidden for users", async ({ page }) => { await registerUser(page) await page.goto("/settings") await page.getByRole("radio", { name: "深色" }).click() await expect(page.locator("html")).toHaveAttribute("data-theme", "dark") await page.reload() await expect(page.locator("html")).toHaveAttribute("data-theme", "dark") await expectNoHorizontalOverflow(page) await page.goto("/admin") await expect(page).toHaveURL(/\/$/) await page.goto("/settings") await page.getByRole("radio", { name: "淺色" }).click() await expect(page.locator("html")).toHaveAttribute("data-theme", "light") }) test("admin: users, invite link, system status", 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("/admin") await expect(page.getByRole("table")).toContainText("@admin") await page.getByRole("radio", { name: "邀請" }).click() await page.getByLabel("備註(選填)").fill("from playwright") await page.getByRole("button", { name: "建立" }).click() await expect(page.locator("code")).toContainText("/register?token=") await page.getByRole("radio", { name: "系統" }).click() await expect(page.getByText("伺服器磁碟")).toBeVisible() await expect(page.getByText(/ffmpeg version/)).toBeVisible() await expectNoHorizontalOverflow(page) }) test("visual tour: every page in light and dark without overflow", async ({ page }, info) => { await registerUser(page) const routes = ["/", "/photos", "/photos/albums", "/videos", "/drive", "/shared", "/trash", "/settings"] for (const theme of ["light", "dark"] as const) { await page.evaluate((t) => localStorage.setItem("mh-theme", t), theme) for (const r of routes) { await page.goto(r) await page.evaluate((t) => (document.documentElement.dataset.theme = t), theme) await expect(page.locator("main h1").first()).toBeVisible() await expectNoHorizontalOverflow(page) if (theme === "light" || r === "/") { await page.screenshot({ path: `../.devdata/screens/${info.project.name}-${theme}${r.replaceAll("/", "_") || "_home"}.png` }) } } } await page.goto("/login") expect(isMobile(page) || true).toBeTruthy() }) test("admin: storage network page, pairing QR, node detail", 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 expect(page.getByRole("heading", { name: "統一儲存空間" })).toBeVisible() const local = page.getByRole("link", { name: /本機(伺服器)/ }) await expect(local).toBeVisible() await expectNoHorizontalOverflow(page) await page.getByRole("button", { name: "新增節點" }).click() await page.getByRole("button", { name: "產生配對碼" }).click() await expect(page.getByText("mediahub-node.pyz pair")).toBeVisible() // computer tab is the default await page.getByRole("radio", { name: "Android 手機" }).click() await expect(page.getByRole("img", { name: "配對 QR Code" }).locator("svg")).toBeVisible() await expect(page.getByRole("link", { name: "下載 mediahub-node.apk" })).toHaveAttribute("href", "/api/admin/storage/agent/mediahub-node.apk") await expect(page.locator("pre").filter({ hasText: '"type":"mediahub-node-pairing"' })).toBeVisible() await page.getByRole("button", { name: "完成" }).click() await local.click() await expect(page.getByText("本機伺服器").first()).toBeVisible() await expect(page.getByRole("heading", { name: "背景傳輸與驗證" })).toBeVisible() await page.goto("/storage") await page.getByRole("radio", { name: "策略" }).click() await expect(page.getByLabel("每個檔案的副本數(含本機)")).toBeVisible() await page.screenshot({ path: `../.devdata/screens/storage-${test.info().project.name}.png` }) }) test("regular users cannot open the storage network page", async ({ page }) => { await registerUser(page) await page.goto("/storage") await expect(page).toHaveURL(/\/$/) }) test("admin: pair a computer, then delete it and its pairing record", async ({ page }) => { const admin = await adminApi() 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") const name = uniqueName("pc") await page.getByRole("button", { name: "新增節點" }).click() await page.getByLabel("裝置名稱(選填)").fill(name) await page.getByRole("button", { name: "產生配對碼" }).click() await expect(page.getByRole("link", { name: "下載 mediahub-node.pyz" })).toBeVisible() const cmd = await page.locator("pre").filter({ hasText: "mediahub-node.pyz pair" }).innerText() expect(cmd).toContain(`--name '${name}'`) const payload = JSON.parse(cmd.slice(cmd.indexOf("--payload '") + 11, -1)) expect(payload.ca_sha256).toMatch(/^[0-9a-f]{64}$/) // CA fingerprint for pinning is present await page.getByRole("button", { name: "完成" }).click() // Enrol a "computer" with that code (protocol call, as mediahub-node would), then revoke it. const r = await admin.post("/node-api/v1/pair", { data: { pairing_token: payload.token, device_name: name, device_kind: "computer", capacity_total: 1e9, capacity_free: 1e9 }, }) expect(r.status()).toBe(201) const nodeId = (await r.json()).node_id await admin.post(`/api/admin/storage/nodes/${nodeId}/revoke`) await page.goto(`/storage/nodes/${nodeId}`) await page.getByRole("button", { name: "刪除節點" }).click() await page.getByRole("dialog").getByRole("button", { name: "刪除" }).click() await expect(page).toHaveURL(/\/storage$/) await expect(page.getByRole("link", { name: new RegExp(name) })).toHaveCount(0) // Pairing records can be deleted too. const records = page.getByRole("region", { name: "配對碼紀錄" }) await expect(records).toContainText(name) await records.getByRole("listitem").filter({ hasText: name }).getByRole("button", { name: "刪除" }).click() await expect(records.getByText(name)).toHaveCount(0) await admin.dispose() })