Ctrl K
ComunidadGuías…

frontend/e2e/app.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()
})