import { readFileSync } from "node:fs"; import { devices, expect, test, type Page } from "@playwright/test"; import { configure, createProject, follow } from "@nibling/sdk"; import { axNode } from "@nibling/web/testing/ax"; import { fakeNetwork as sdkNetwork } from "../../sdk/tests/fake-pds.ts"; import { accountButton, answer, chooseAccount, chooseNew, header, headerButton, menuItem, newButton, project, signIn } from "./fixtures/header.ts"; import { fakeNetwork } from "./fixtures/network.ts"; /** * The header on every page and the home page under it, over the fake network with two accounts that * can both sign in. Set NIB_SHOTS to a directory to also save screenshots there. */ const network = fakeNetwork(); const { pds, escaped } = network; const alice = network.account("did:plc:alice2222222222222222222", "alice.test"); const bob = network.account("did:plc:bob222222222222222222222", "bob.test"); const carol = network.account("did:plc:carol22222222222222222222", "carol.test"); const encode = (text: string) => new TextEncoder().encode(text); const files = (title: string) => new Map([["index.html", encode(`

${title}

\n`)]]); test.beforeAll(async () => { configure(sdkNetwork([pds])); await network.profile(alice, { displayName: "Alice Liddell" }); await network.profile(bob, { displayName: "Bob Builder", avatar: readFileSync(new URL("../playground/public/icon-192.png", import.meta.url)) }); await createProject(pds.session(alice), { name: "tide pool", description: "Waves in a canvas.", files: files("tide pool") }); await createProject(pds.session(alice), { name: "garden", description: "Seeds that grow when you click.", files: files("garden") }); await createProject(pds.session(carol), { name: "lantern", description: "A paper lantern in CSS.", files: files("lantern") }); await follow(pds.session(alice), carol); }); const shoot = async (page: Page, name: string) => { if (process.env.NIB_SHOTS) await page.screenshot({ path: `${process.env.NIB_SHOTS}/ui-${name}.png`, animations: "disabled" }); }; const home = (page: Page) => page.locator("nib-home-page"); const openMenu = (page: Page) => header(page).locator("nib-menu:popover-open"); test.describe("desktop", () => { test.use({ viewport: { width: 1200, height: 800 } }); test("home shows your projects first, then the feed from people you follow", async ({ page }) => { await network.install(page); await page.goto("/"); await expect(home(page).getByRole("region", { name: "Welcome" })).toContainText("Sign in to see yours"); await signIn(page, "alice.test"); expect(new URL(page.url()).pathname).toBe("/"); const yours = home(page).getByRole("region", { name: "Your projects" }); await expect(yours.getByRole("heading", { level: 3 })).toHaveText(["garden", "tide pool"]); const feed = home(page).getByRole("region", { name: "From people you follow" }); await expect(feed.getByRole("heading", { level: 3 })).toHaveText(["lantern"]); expect(await home(page).locator("section").evaluateAll((sections) => sections.map((s) => s.ariaLabel))).toEqual(["Your projects", "From people you follow"]); await expect(header(page).getByRole("link", { name: "nibling" })).toHaveAttribute("aria-current", "page"); await shoot(page, "home"); await newButton(page).click(); await expect(newButton(page)).toHaveAttribute("aria-expanded", "true"); await expect(menuItem(page, "New project…")).toBeVisible(); await shoot(page, "new-menu"); await page.keyboard.press("Escape"); await accountButton(page).click(); await expect(menuItem(page, "Settings")).toBeVisible(); await shoot(page, "account-menu"); expect(escaped).toEqual([]); }); test("New → New project lands in the editor with the project open", async ({ page }) => { await network.install(page); await page.goto("/"); await signIn(page, "alice.test"); await chooseNew(page, "New project…"); await answer(page, "New project", "sketch", "Create"); await expect(page).toHaveURL("/alice.test/sketch/edit"); await expect(project(page)).toHaveText("sketch"); await expect(headerButton(page, "Save")).toBeVisible(); await expect(page.getByRole("textbox", { name: "index.ts" })).toBeVisible(); await shoot(page, "editor"); }); test("the account menu works from the keyboard and returns focus to its button", async ({ page }) => { await network.install(page); await page.goto("/"); await signIn(page, "alice.test"); const button = accountButton(page); const focused = (text: string) => expect(menuItem(page, text)).toBeFocused(); await button.focus(); await page.keyboard.press("Enter"); await expect(button).toHaveAttribute("aria-expanded", "true"); await expect.poll(() => axNode(page, "menu", "Account: alice.test")).toBeTruthy(); await expect.poll(() => axNode(page, "menuitemradio", "alice.test")).toMatchObject({ checked: "true" }); await focused("alice.test"); await page.keyboard.press("ArrowDown"); await focused("Add account…"); await page.keyboard.press("ArrowDown"); await focused("Profile"); await page.keyboard.press("End"); await focused("Sign out alice.test"); await page.keyboard.press("ArrowDown"); await focused("alice.test"); await page.keyboard.press("s"); await focused("Settings"); await page.keyboard.press("Escape"); await expect(openMenu(page)).toHaveCount(0); await expect(button).toBeFocused(); await expect(button).toHaveAttribute("aria-expanded", "false"); await page.keyboard.press("ArrowUp"); await focused("Sign out alice.test"); await page.keyboard.press("Home"); await focused("alice.test"); await page.keyboard.press("ArrowUp"); await focused("Sign out alice.test"); await page.keyboard.press("Escape"); await expect(button).toBeFocused(); await page.keyboard.press("ArrowDown"); await focused("alice.test"); await page.keyboard.press("p"); await page.keyboard.press("Enter"); await expect(page).toHaveURL("/alice.test"); }); test("two accounts: switch without signing in again, list both in Settings, and sign out one", async ({ page }) => { await network.install(page); const authorizations: string[] = []; page.on("request", (request) => { if (request.url().endsWith("/oauth/par")) authorizations.push(request.url()); }); await page.goto("/"); await signIn(page, "alice.test"); await signIn(page, "bob.test"); expect(authorizations).toHaveLength(2); await expect(accountButton(page).locator("img.avatar")).toHaveAttribute("src", new RegExp(`getBlob\\?did=${encodeURIComponent(bob)}`)); // Playwright's fulfilled responses skip COEP checks, so these fakes can't show a no-cors avatar // failing in the isolated editor; a real PDS blob does, which is why it loads with CORS. await expect(accountButton(page).locator("img.avatar")).toHaveAttribute("crossorigin", "anonymous"); await expect(accountButton(page).locator(".handle")).toHaveText("bob.test"); await accountButton(page).click(); await expect.poll(() => axNode(page, "menuitemradio", "bob.test")).toMatchObject({ checked: "true" }); await expect.poll(() => axNode(page, "menuitemradio", "alice.test")).toMatchObject({ checked: "false" }); await menuItem(page, "alice.test").click(); await expect(accountButton(page)).toHaveAccessibleName("Account: alice.test"); await expect(accountButton(page).locator(".handle")).toHaveText("alice.test"); await expect(accountButton(page).locator("span.avatar")).toHaveText("A"); await expect(home(page).getByRole("region", { name: "Your projects" })).toContainText("tide pool"); expect(authorizations).toHaveLength(2); await chooseAccount(page, "Settings"); await expect(page).toHaveURL("/settings"); const accounts = page.locator("nib-settings-page").getByRole("region", { name: "Accounts" }).getByRole("listitem"); await expect(accounts).toHaveCount(2); await expect(accounts.nth(0)).toContainText("Alice Liddell"); await expect(accounts.nth(0)).toContainText("@alice.test · current"); await expect(accounts.nth(1)).toContainText("Bob Builder"); await expect(accounts.nth(1).getByRole("button", { name: "Switch to bob.test" })).toBeVisible(); await shoot(page, "settings"); await accounts.nth(0).getByRole("button", { name: "Sign out alice.test" }).click(); await expect(accountButton(page)).toHaveAccessibleName("Account: bob.test"); await expect(accounts).toHaveCount(1); await expect(accounts.nth(0)).toContainText("@bob.test · current"); await page.reload(); await expect(accountButton(page)).toHaveAccessibleName("Account: bob.test"); await accountButton(page).click(); await expect(openMenu(page).locator("nib-menu-group nib-menu-item")).toHaveText(["bob.test", "Add account…"]); expect(authorizations).toHaveLength(2); expect(escaped).toEqual([]); }); }); test.describe("phone", () => { const { defaultBrowserType: _, ...pixel } = devices["Pixel 7"]; test.use(pixel); test("the header fits a phone, and its menus open by touch", async ({ page }) => { await network.install(page); await page.goto("/"); await signIn(page, "alice.test"); const { scroll, client } = await header(page).evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); expect(scroll).toBeLessThanOrEqual(client); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(412); await expect(accountButton(page).locator(".handle")).toHaveCSS("position", "absolute"); for (const button of [newButton(page), accountButton(page)]) { const box = (await button.boundingBox())!; expect(box.height).toBeGreaterThanOrEqual(44); expect(box.width).toBeGreaterThanOrEqual(44); } await shoot(page, "home-phone"); await accountButton(page).tap(); const menu = (await openMenu(page).boundingBox())!; expect(menu.x).toBeGreaterThanOrEqual(0); expect(menu.x + menu.width).toBeLessThanOrEqual(412); expect((await menuItem(page, "Settings").boundingBox())!.height).toBeGreaterThanOrEqual(44); await shoot(page, "account-menu-phone"); await menuItem(page, "Settings").tap(); await expect(page).toHaveURL("/settings"); await page.goto("/new?compact"); await expect(accountButton(page)).toBeVisible(); await shoot(page, "editor-phone"); }); });