231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// A module's pages above a project on a real Postgres (F10): the four answers of
|
|
// `/<org>/<workspace>/modules/<module>`, the query string reaching the server, the navigation at that scope,
|
|
// and a project that cannot take the reserved segment.
|
|
//
|
|
// `E2E_BUILD_MODE=production pnpm exec playwright test -c e2e/playwright.config.ts workspace-pages` runs this
|
|
// file on a production `vite build` (e2e/support/stack.ts).
|
|
|
|
import { type Browser, expect, type Page, test } from "@playwright/test"
|
|
import { call, callRaw, chooseLanguage, makeOrganization, signIn, unique, violations } from "./support/app"
|
|
|
|
const base = `http://127.0.0.1:${process.env.E2E_WEB_PORT ?? 3100}`
|
|
|
|
async function person(browser: Browser, email: string): Promise<Page> {
|
|
const context = await browser.newContext({ baseURL: base })
|
|
const page = await context.newPage()
|
|
await signIn(page, email)
|
|
return page
|
|
}
|
|
|
|
const pathOf = (org: string, workspace: string, query = ""): string => `/${org}/${workspace}/modules/notes${query}`
|
|
|
|
test("signed out is a 302 to sign in, and the path comes back", async ({ browser, page }) => {
|
|
await signIn(page, `${unique("owner")}@example.test`)
|
|
const { org, workspace } = await makeOrganization(page)
|
|
const context = await browser.newContext({ baseURL: base })
|
|
const response = await context.request.get(pathOf(org, workspace), { maxRedirects: 0 })
|
|
expect(response.status()).toBe(302)
|
|
expect(response.headers().location).toContain("/auth/sign-in")
|
|
expect(decodeURIComponent(response.headers().location ?? "")).toContain(pathOf(org, workspace))
|
|
await context.close()
|
|
})
|
|
|
|
test("signed in is a 200 with the page, the navigation and the query string on the server", async ({ page }) => {
|
|
await signIn(page, `${unique("owner")}@example.test`)
|
|
const { org, workspace, project } = await makeOrganization(page)
|
|
await call(page, "tenancy.project.create", { org, workspace, slug: "other", name: "Other", moduleKey: "notes" })
|
|
|
|
const response = await page.goto(pathOf(org, workspace))
|
|
expect(response?.status()).toBe(200)
|
|
await expect(page.getByRole("heading", { level: 1, name: "Notes in this workspace" })).toBeVisible()
|
|
await expect(page.locator("main").getByRole("link", { name: "Review" })).toHaveAttribute(
|
|
"href",
|
|
`/${org}/${workspace}/${project}/notes`,
|
|
)
|
|
expect(await violations(page)).toEqual([])
|
|
|
|
// The first byte already holds the filtered list: `load` read `?q=`, the browser did not.
|
|
const filtered = await page.request.get(pathOf(org, workspace, "?q=oth"))
|
|
expect(filtered.status()).toBe(200)
|
|
const html = await filtered.text()
|
|
expect(html).toContain(">Other<")
|
|
expect(html).not.toContain(">Review<")
|
|
|
|
// A search can be submitted with the keyboard and recovered without editing the URL.
|
|
await page.getByRole("searchbox", { name: "Project name" }).fill("no matching project")
|
|
await page.getByRole("searchbox", { name: "Project name" }).press("Enter")
|
|
await expect(page.getByText("No project matches.")).toBeVisible()
|
|
await page.getByRole("link", { name: "Clear filter" }).click()
|
|
await expect(page.getByRole("link", { name: "Review" })).toBeVisible()
|
|
|
|
// `ui.nav` with `scope: "workspace"`, on the workspace's own page and on a project's.
|
|
await page.goto(`/${org}/${workspace}`)
|
|
const nav = page.getByRole("navigation", { name: "Main navigation" })
|
|
await expect(nav.getByRole("link", { name: "Notes in this workspace" })).toHaveAttribute(
|
|
"href",
|
|
pathOf(org, workspace),
|
|
)
|
|
await page.goto(`/${org}/${workspace}/${project}`)
|
|
await expect(nav.getByRole("link", { name: "Notes in this workspace" })).toBeVisible()
|
|
})
|
|
|
|
test("a count only the browser knows reads right in English and in Lithuanian", async ({ page }) => {
|
|
await signIn(page, `${unique("owner")}@example.test`)
|
|
const { org, workspace } = await makeOrganization(page)
|
|
await call(page, "tenancy.project.create", { org, workspace, slug: "other", name: "Other", moduleKey: "notes" })
|
|
await page.goto(pathOf(org, workspace))
|
|
const status = page.locator("[data-slot=resource-count]")
|
|
await expect(status).toHaveText("0 projects selected")
|
|
await page
|
|
.locator("label")
|
|
.filter({ has: page.getByRole("checkbox", { name: "Review", exact: true }) })
|
|
.click()
|
|
await expect(status).toHaveText("1 project selected")
|
|
await page
|
|
.locator("label")
|
|
.filter({ has: page.getByRole("checkbox", { name: "Other", exact: true }) })
|
|
.click()
|
|
await expect(status).toHaveText("2 projects selected")
|
|
|
|
// The language changes in place: the selection stays, and the count reads in Lithuanian, in each plural form.
|
|
await chooseLanguage(page, "Lietuvių")
|
|
await expect(page.getByRole("heading", { level: 1, name: "Užrašai šioje darbo srityje" })).toBeVisible()
|
|
await expect(status).toHaveText("Pasirinkti 2 projektai")
|
|
await page
|
|
.locator("label")
|
|
.filter({ has: page.getByRole("checkbox", { name: "Other", exact: true }) })
|
|
.click()
|
|
await expect(status).toHaveText("Pasirinktas 1 projektas")
|
|
await page
|
|
.locator("label")
|
|
.filter({ has: page.getByRole("checkbox", { name: "Review", exact: true }) })
|
|
.click()
|
|
await expect(status).toHaveText("Pasirinkta 0 projektų")
|
|
})
|
|
|
|
test("selection controls stay square and keyboard accessible in both themes", async ({ page }, testInfo) => {
|
|
const errors: string[] = []
|
|
const responses: { path: string; status: number }[] = []
|
|
page.on("pageerror", (error) => errors.push(error.message))
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") errors.push(message.text())
|
|
})
|
|
page.on("requestfailed", (request) =>
|
|
errors.push(`${new URL(request.url()).pathname}: ${request.failure()?.errorText}`),
|
|
)
|
|
page.on("response", (response) =>
|
|
responses.push({ path: new URL(response.url()).pathname, status: response.status() }),
|
|
)
|
|
await signIn(page, `${unique("owner")}@example.test`)
|
|
const { org, workspace } = await makeOrganization(page)
|
|
await call(page, "tenancy.project.create", { org, workspace, slug: "other", name: "Other", moduleKey: "notes" })
|
|
await page.goto(pathOf(org, workspace))
|
|
await page.waitForLoadState("networkidle")
|
|
const privacy = page.getByRole("checkbox", { name: /Also allow session replay/ })
|
|
if (await privacy.isVisible()) {
|
|
const size = await privacy.boundingBox()
|
|
expect(size?.width).toBe(size?.height)
|
|
const reject = page.getByRole("button", { name: "Reject analytics and replay" })
|
|
await reject.click()
|
|
// the choice applies in place: the banner closes and the document stays
|
|
await expect(page.getByRole("region", { name: "Privacy choices" })).toHaveCount(0)
|
|
}
|
|
for (const theme of ["light", "dark"]) {
|
|
await page.context().addCookies([{ name: "app_theme", value: theme, url: base }])
|
|
for (const width of [375, 778, 1280]) {
|
|
await page.setViewportSize({ width, height: 853 })
|
|
await page.reload()
|
|
await page.waitForLoadState("networkidle")
|
|
const boxes = await page.locator(".kit-data-table-box").evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const rect = element.getBoundingClientRect()
|
|
return { width: rect.width, height: rect.height }
|
|
}),
|
|
)
|
|
expect(boxes.length).toBe(3)
|
|
for (const box of boxes) expect(box.width).toBe(box.height)
|
|
const first = page.locator("tbody").getByRole("checkbox").first()
|
|
// A React Aria grid has one Tab stop; arrows reach its cells and controls.
|
|
await page.getByRole("button", { name: "Filter", exact: true }).focus()
|
|
await page.keyboard.press("Tab")
|
|
await page.keyboard.press("ArrowRight")
|
|
await expect(first).toBeFocused()
|
|
await page.keyboard.press("Space")
|
|
await expect(first).toBeChecked()
|
|
const all = page.getByRole("checkbox", { name: "Select all", exact: true })
|
|
expect(await all.evaluate((control) => (control as HTMLInputElement).indeterminate)).toBe(true)
|
|
await expect(page.locator("[data-slot=resource-count]")).toHaveText("1 project selected")
|
|
await page.screenshot({ path: testInfo.outputPath(`selection-${theme}-${width}.png`) })
|
|
await page.keyboard.press("Space")
|
|
await expect(first).not.toBeChecked()
|
|
}
|
|
expect(await violations(page)).toEqual([])
|
|
}
|
|
expect(errors).toEqual([])
|
|
expect(responses.filter((response) => response.status >= 400)).toEqual([])
|
|
await testInfo.attach("selection-network", {
|
|
body: JSON.stringify(responses, null, 2),
|
|
contentType: "application/json",
|
|
})
|
|
})
|
|
|
|
test("another organization is the 404 of a workspace that does not exist", async ({ browser, page }) => {
|
|
await signIn(page, `${unique("owner")}@example.test`)
|
|
const { org, workspace } = await makeOrganization(page)
|
|
const stranger = await person(browser, `${unique("stranger")}@example.test`)
|
|
const theirs = await makeOrganization(stranger, "other")
|
|
|
|
const refused = await stranger.request.get(pathOf(org, workspace), { maxRedirects: 0 })
|
|
const missing = await stranger.request.get(pathOf(theirs.org, "nowhere"), { maxRedirects: 0 })
|
|
expect(refused.status()).toBe(404)
|
|
expect(missing.status()).toBe(404)
|
|
expect((await stranger.request.get(pathOf(theirs.org, theirs.workspace))).status()).toBe(200)
|
|
await stranger.context().close()
|
|
})
|
|
|
|
test("a member without the permission gets a 403 and no link to it", async ({ browser, page }) => {
|
|
const owner = `${unique("owner")}@example.test`
|
|
const member = `${unique("member")}@example.test`
|
|
await signIn(page, owner)
|
|
const { org, workspace } = await makeOrganization(page)
|
|
|
|
// A custom role that reaches the organization and nothing of notes: a member who may not read them.
|
|
const role = await call<{ key: string }>(page, "tenancy.role.create", {
|
|
org,
|
|
name: "Guest",
|
|
level: "org",
|
|
permissions: ["tenancy.org.read", "tenancy.workspace.read"],
|
|
})
|
|
const invited = await call<{ token: string }>(page, "tenancy.member.invite", {
|
|
org,
|
|
email: member,
|
|
role: "viewer",
|
|
roleKey: role.key,
|
|
})
|
|
const memberPage = await person(browser, member)
|
|
await call(memberPage, "tenancy.invitation.accept", { org, token: invited.token })
|
|
|
|
const response = await memberPage.goto(pathOf(org, workspace))
|
|
expect(response?.status()).toBe(403)
|
|
await expect(memberPage.getByRole("heading", { level: 1, name: "You do not have access" })).toBeVisible()
|
|
await memberPage.goto(`/${org}/${workspace}`)
|
|
await expect(memberPage.getByRole("navigation", { name: "This workspace" })).toHaveCount(0)
|
|
await memberPage.context().close()
|
|
})
|
|
|
|
test("no project can take the route's segment", async ({ page }) => {
|
|
await signIn(page, `${unique("owner")}@example.test`)
|
|
const { org, workspace } = await makeOrganization(page)
|
|
await page.goto(`/${org}/${workspace}`)
|
|
const csrf = (await page.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
|
|
const refused = await callRaw(page.request, csrf, "tenancy.project.create", {
|
|
org,
|
|
workspace,
|
|
slug: "modules",
|
|
name: "Modules",
|
|
moduleKey: "notes",
|
|
})
|
|
expect(refused.status()).toBe(409)
|
|
})
|