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");
});
});