frontend/e2e/app.spec.ts
Rama main · réplica de lectura del repositorio original.
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 <img>).
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()
})