From 48ef396ec1f3240fbae032ff43e828b612f95294 Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 7 Oct 2026 21:13:40 +0900 Subject: [PATCH] refactor: organize E2E tests with page objects --- README.md | 10 + docs/webmcp-prototype.md | 6 +- e2e/fixtures.ts | 56 ++++ e2e/integrations/access.test.ts | 3 +- e2e/integrations/auth.test.ts | 93 ++++--- e2e/integrations/connections.test.ts | 72 +++--- e2e/integrations/deck.test.ts | 359 ++++++++++++-------------- e2e/integrations/home.test.ts | 200 +++++++------- e2e/integrations/inbox.test.ts | 93 +++---- e2e/integrations/mcp-http.test.ts | 7 +- e2e/integrations/research.test.ts | 206 +++++++-------- e2e/integrations/support.test.ts | 132 +++++----- e2e/integrations/url-state.test.ts | 107 ++++---- e2e/integrations/vitals.test.ts | 137 ++++------ e2e/integrations/webmcp.test.ts | 81 +++--- e2e/integrations/workspace.test.ts | 25 +- e2e/models/AuthPage.ts | 44 ++++ e2e/models/ConnectedAccountsDialog.ts | 26 ++ e2e/models/DeckPage.ts | 140 ++++++++++ e2e/models/HomePage.ts | 67 +++++ e2e/models/JournalPage.ts | 44 ++++ e2e/models/MessagesPage.ts | 72 ++++++ e2e/models/OnboardingPage.ts | 23 ++ e2e/models/ReaderPage.ts | 71 +++++ e2e/models/ResearchPage.ts | 45 ++++ e2e/models/VitalsPage.ts | 115 +++++++++ e2e/models/WorkspacePage.ts | 61 +++++ e2e/workspace.ts | 33 --- 28 files changed, 1494 insertions(+), 834 deletions(-) create mode 100644 e2e/models/AuthPage.ts create mode 100644 e2e/models/ConnectedAccountsDialog.ts create mode 100644 e2e/models/DeckPage.ts create mode 100644 e2e/models/HomePage.ts create mode 100644 e2e/models/JournalPage.ts create mode 100644 e2e/models/MessagesPage.ts create mode 100644 e2e/models/OnboardingPage.ts create mode 100644 e2e/models/ReaderPage.ts create mode 100644 e2e/models/ResearchPage.ts create mode 100644 e2e/models/VitalsPage.ts create mode 100644 e2e/models/WorkspacePage.ts delete mode 100644 e2e/workspace.ts diff --git a/README.md b/README.md index 1bfffbc..bc144c9 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,16 @@ X or a personal relay. Orval generates API fixture types from a pinned Generated files are committed; run `generate:e2e-openapi` only when changing the pin or selected operations. Runtime requests use Bird. +Browser scenarios use the Page Object Model, following the +[web-app-template E2E structure](https://github.com/hiroppy/web-app-template/tree/main/e2e). +`e2e/models/` owns page-specific locators and user actions; `WorkspacePage` +owns shared navigation and sidebar behavior. `e2e/fixtures.ts` supplies these +objects per test, alongside the isolated database, session, and accessibility +fixtures. Specs in `e2e/integrations/` retain scenario assertions, network mocks, +API checks, and browser history/viewport controls. For a new screen, add its +model and fixture instead of duplicating selectors across specs. A model can +also be constructed with another browser page to test multiple sessions. + `test:live` is an explicit, opt-in smoke test that performs one read-only Top search. For this standalone probe only, `BIRD_PROFILE_NAME` selects the account and `TWITTER_LITE_LIVE_QUERY` overrides the search text. diff --git a/docs/webmcp-prototype.md b/docs/webmcp-prototype.md index 0d87c75..2b354db 100644 --- a/docs/webmcp-prototype.md +++ b/docs/webmcp-prototype.md @@ -110,8 +110,10 @@ nix develop -c pnpm typecheck nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts ``` -E2E tests enable Chromium WebMCP/testing flags and invoke actual registered -tools through `navigator.modelContextTesting`. Mock relay responses pass +E2E tests enable Chromium's `WebMCP` feature and invoke actual registered +tools through `document.modelContext.getTools()` and `executeTool()`. The pinned +Chromium accepts the registered tool object and JSON-serialized arguments. +Mock relay responses pass through real server functions and isolated database state. For interactive testing, enable `chrome://flags/#enable-webmcp-testing`, diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index f27cd53..e3530bc 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -9,11 +9,67 @@ import { } from "../src/features/auth/auth.server"; import { openDatabase } from "../src/features/storage/database.server"; +import { WorkspacePage } from "./models/WorkspacePage"; +import { AuthPage } from "./models/AuthPage"; +import { OnboardingPage } from "./models/OnboardingPage"; +import { ConnectedAccountsDialog } from "./models/ConnectedAccountsDialog"; +import { HomePage } from "./models/HomePage"; +import { MessagesPage } from "./models/MessagesPage"; +import { JournalPage } from "./models/JournalPage"; +import { ReaderPage } from "./models/ReaderPage"; +import { VitalsPage } from "./models/VitalsPage"; +import { DeckPage } from "./models/DeckPage"; +import { ResearchPage } from "./models/ResearchPage"; + export const test = base.extend<{ + workspacePage: WorkspacePage; + authPage: AuthPage; + onboardingPage: OnboardingPage; + connectedAccounts: ConnectedAccountsDialog; + homePage: HomePage; + messagesPage: MessagesPage; + journalPage: JournalPage; + readerPage: ReaderPage; + vitalsPage: VitalsPage; + deckPage: DeckPage; + researchPage: ResearchPage; a11y: () => AxeBuilder; resetDecks: undefined; sessionToken: string; }>({ + workspacePage: async ({ page }, use) => { + await use(new WorkspacePage(page)); + }, + authPage: async ({ page }, use) => { + await use(new AuthPage(page)); + }, + onboardingPage: async ({ page }, use) => { + await use(new OnboardingPage(page)); + }, + connectedAccounts: async ({ page }, use) => { + await use(new ConnectedAccountsDialog(page)); + }, + homePage: async ({ page }, use) => { + await use(new HomePage(page)); + }, + messagesPage: async ({ page }, use) => { + await use(new MessagesPage(page)); + }, + journalPage: async ({ page }, use) => { + await use(new JournalPage(page)); + }, + readerPage: async ({ page }, use) => { + await use(new ReaderPage(page)); + }, + vitalsPage: async ({ page }, use) => { + await use(new VitalsPage(page)); + }, + deckPage: async ({ page }, use) => { + await use(new DeckPage(page)); + }, + researchPage: async ({ page }, use) => { + await use(new ResearchPage(page)); + }, // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments. sessionToken: async ({}, use) => { const path = process.env.TWITTER_LITE_E2E_DB_PATH; diff --git a/e2e/integrations/access.test.ts b/e2e/integrations/access.test.ts index 2660835..513d139 100644 --- a/e2e/integrations/access.test.ts +++ b/e2e/integrations/access.test.ts @@ -28,10 +28,11 @@ test("mutations require exact Origin even with same-origin Fetch Metadata", asyn test("server functions also reject foreign identity and cross-site calls", async ({ page, + deckPage, request, }) => { const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/")); - await page.goto("/deck"); + await deckPage.goTo(); const url = (await serverRequest).url(); expect( ( diff --git a/e2e/integrations/auth.test.ts b/e2e/integrations/auth.test.ts index 74551d8..3f1429e 100644 --- a/e2e/integrations/auth.test.ts +++ b/e2e/integrations/auth.test.ts @@ -1,83 +1,96 @@ import { openDatabase } from "../../src/features/storage/database.server"; import { expect, test } from "../fixtures"; -const origin = "http://127.0.0.1:4173"; - test("creates the owner once and enters onboarding with a real session", async ({ page, context, a11y, + authPage, + onboardingPage, + homePage, }) => { const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? ""); db.$client.exec("DELETE FROM workspace_owner; DELETE FROM auth_throttle;"); db.$client.close(); await context.clearCookies(); - await page.goto("/", { waitUntil: "networkidle" }); + await homePage.goTo(); + await page.waitForLoadState("networkidle"); await expect(page).toHaveURL(/\/setup$/); expect((await a11y().analyze()).violations).toEqual([]); - await page.getByLabel("Your name", { exact: true }).fill("Yuta"); - await page.getByLabel("Email address").fill("owner@workspace.invalid"); - await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026"); - await page.getByLabel("Confirm password", { exact: true }).fill("E2E-only-passphrase-2026"); - await page.getByLabel("Setup code").fill("isolated-e2e-setup-token-not-for-production"); - await page.getByRole("button", { name: "Create account", exact: true }).click(); + await authPage.createAccount({ + name: "Yuta", + email: "owner@workspace.invalid", + password: "E2E-only-passphrase-2026", + setupCode: "isolated-e2e-setup-token-not-for-production", + }); await expect(page).toHaveURL(/\/onboarding$/); await page.waitForLoadState("networkidle"); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await page.getByRole("button", { name: "Open workspace" }).click(); - await expect(page.getByRole("heading", { name: "Good morning, Yuta" })).toBeVisible(); + await onboardingPage.complete(); + await expect(homePage.greeting("Yuta")).toBeVisible(); }); test("requires login for documents, server functions, and live updates", async ({ page, context, + authPage, + workspacePage, + deckPage, + journalPage, + baseURL, }) => { const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/")); - await page.goto("/deck"); + await deckPage.goTo(); const serverUrl = (await serverRequest).url(); await context.clearCookies(); for (const path of ["/api/research/events", serverUrl]) { const result = await context.request.get(path, { - headers: { Origin: origin, "Sec-Fetch-Site": "same-origin" }, + headers: { Origin: new URL("/", baseURL).origin, "Sec-Fetch-Site": "same-origin" }, }); expect(result.status()).toBe(401); } - await page.goto("/journal"); + await journalPage.goTo(); await expect(page).toHaveURL(/\/login(?:\?|$)/); - await expect(page.getByRole("heading", { name: "Welcome back." })).toBeVisible(); - await expect(page.getByRole("link", { name: "Journal", exact: true })).toHaveCount(0); + await expect(authPage.welcome).toBeVisible(); + await expect(workspacePage.navigationLink("Journal")).toHaveCount(0); }); -test("signs in and revokes the session on sign out", async ({ page, context, a11y }) => { +test("signs in and revokes the session on sign out", async ({ + page, + context, + a11y, + authPage, + workspacePage, + connectedAccounts, +}) => { await context.clearCookies(); - await page.goto("/login", { waitUntil: "networkidle" }); + await authPage.goTo(); expect((await a11y().analyze()).violations).toEqual([]); - await page.getByLabel("Email address").fill("owner@workspace.invalid"); - await page.getByLabel("Password", { exact: true }).fill("Wrong-passphrase-2026"); - await page.getByRole("button", { name: "Sign in", exact: true }).click(); - await expect(page.getByRole("alert")).toContainText("Invalid email or password."); - await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026"); - await page.getByRole("button", { name: "Sign in", exact: true }).click(); - await expect(page).toHaveURL(`${origin}/`); + await authPage.signIn("owner@workspace.invalid", "Wrong-passphrase-2026"); + await expect(authPage.error).toContainText("Invalid email or password."); + await authPage.password.fill("E2E-only-passphrase-2026"); + await authPage.signInButton.click(); + await expect(page).toHaveURL("/"); await page.waitForLoadState("networkidle"); const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session"); expect(token?.httpOnly).toBe(true); expect(token?.sameSite).toBe("Lax"); - await page.getByRole("button", { name: "Manage connected accounts" }).click(); - await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await connectedAccounts.open(); + await connectedAccounts.signOut.click(); await expect(page).toHaveURL(/\/login(?:\?|$)/); const replay = await context.request.get("/api/auth", { headers: { Cookie: `workspace_session=${token?.value}` }, }); expect((await replay.json()).owner).toBeNull(); await page.goBack(); - await expect(page.getByRole("link", { name: "Home", exact: true })).toHaveCount(0); + await expect(workspacePage.navigationLink("Home")).toHaveCount(0); }); test("requires onboarding and remembers its completion and name", async ({ page, request, a11y, + onboardingPage, + homePage, }) => { const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? ""); db.$client @@ -85,17 +98,17 @@ test("requires onboarding and remembers its completion and name", async ({ .run(); try { expect((await request.get("/api/research/events")).status()).toBe(403); - await page.goto("/", { waitUntil: "networkidle" }); + await homePage.goTo(); + await page.waitForLoadState("networkidle"); await expect(page).toHaveURL(/\/onboarding$/); expect((await a11y().analyze()).violations).toEqual([]); - await page.getByLabel("What should we call you?").fill("Yuta Test"); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await page.getByRole("button", { name: "Open workspace" }).click(); - await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible(); + await onboardingPage.name.fill("Yuta Test"); + await onboardingPage.complete(); + await expect(homePage.greeting("Yuta Test")).toBeVisible(); await page.reload(); - await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible(); - await page.goto("/onboarding"); - await expect(page).toHaveURL(`${origin}/`); + await expect(homePage.greeting("Yuta Test")).toBeVisible(); + await onboardingPage.goTo(); + await expect(page).toHaveURL("/"); } finally { db.$client .prepare("UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1") @@ -108,6 +121,8 @@ test("rejects cross-origin login and further account registration", async ({ request, page, context, + authPage, + baseURL, }) => { expect( ( @@ -118,7 +133,7 @@ test("rejects cross-origin login and further account registration", async ({ ).status(), ).toBe(403); const result = await request.post("/api/auth", { - headers: { Origin: origin }, + headers: { Origin: new URL("/", baseURL).origin }, data: { action: "setup", email: "intruder@example.com", @@ -129,6 +144,6 @@ test("rejects cross-origin login and further account registration", async ({ }); expect(result.status()).toBe(409); await context.clearCookies(); - await page.goto("/setup"); + await authPage.goTo("setup"); await expect(page).toHaveURL(/\/login(?:\?|$)/); }); diff --git a/e2e/integrations/connections.test.ts b/e2e/integrations/connections.test.ts index 73c1dd6..5916bb5 100644 --- a/e2e/integrations/connections.test.ts +++ b/e2e/integrations/connections.test.ts @@ -1,52 +1,50 @@ import { expect, test } from "../fixtures"; test("opens account management after hydration without inheriting real OAuth configuration", async ({ - page, + deckPage, + workspacePage, + connectedAccounts, }) => { - await page.goto("/deck"); - await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); - const manage = page.getByRole("button", { name: "Manage connected accounts" }); - if (!(await manage.isVisible())) { - await page.getByRole("button", { name: "Toggle sidebar" }).click(); - } - await expect(manage).toBeEnabled(); - await manage.click(); - const dialog = page.getByRole("dialog", { name: "Connected accounts" }); - await expect(dialog).toBeVisible(); - await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Authorize with Mastodon" })).toHaveCount(0); - await page.keyboard.press("Escape"); - await expect(dialog).toHaveCount(0); - await expect(manage).toBeFocused(); + await deckPage.goTo(); + await expect(workspacePage.researchReady).toBeEnabled(); + await workspacePage.ensureSidebarOpen(); + await expect(workspacePage.manageAccounts).toBeEnabled(); + await connectedAccounts.open(); + await expect(connectedAccounts.dialog).toBeVisible(); + await expect(connectedAccounts.unconfiguredMastodon).toBeVisible(); + await expect(connectedAccounts.authorizeMastodon).toHaveCount(0); + await connectedAccounts.close(); + await expect(connectedAccounts.dialog).toHaveCount(0); + await expect(workspacePage.manageAccounts).toBeFocused(); }); test("opens account management from the inbox footer and restores focus on close", async ({ - page, + readerPage, + workspacePage, + connectedAccounts, a11y, }) => { - await page.goto("/inbox"); - await page.waitForLoadState("networkidle"); - const manage = page.getByRole("button", { name: "Manage connected accounts" }); - await expect(manage).toBeVisible(); - await manage.click(); - const dialog = page.getByRole("dialog", { name: "Connected accounts" }); - await expect(dialog).toBeVisible(); - await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible(); + await readerPage.goTo(); + await expect(workspacePage.manageAccounts).toBeVisible(); + await connectedAccounts.open(); + await expect(connectedAccounts.dialog).toBeVisible(); + await expect(connectedAccounts.unconfiguredMastodon).toBeVisible(); expect((await a11y().analyze()).violations).toEqual([]); - await page.keyboard.press("Escape"); - await expect(dialog).toHaveCount(0); - await expect(manage).toBeFocused(); - await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible(); + await connectedAccounts.close(); + await expect(connectedAccounts.dialog).toHaveCount(0); + await expect(workspacePage.manageAccounts).toBeFocused(); + await expect(readerPage.searchInput).toBeVisible(); }); -test("rejects an unbound OAuth callback without contacting an instance", async ({ page }) => { +test("rejects an unbound OAuth callback without contacting an instance", async ({ + page, + deckPage, + workspacePage, +}) => { await page.goto("/oauth/mastodon/callback?state=invalid&code=not-a-real-code"); await expect(page).toHaveURL(/\/deck\?mastodon=failed$/); - await expect(page.getByRole("alert")).toContainText("Could not complete Mastodon authorization."); - await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); - const manage = page.getByRole("button", { name: "Manage connected accounts" }); - if (!(await manage.isVisible())) { - await page.getByRole("button", { name: "Toggle sidebar" }).click(); - } - await expect(page.getByRole("button", { name: "Manage connected accounts" })).toBeEnabled(); + await expect(deckPage.alert).toContainText("Could not complete Mastodon authorization."); + await expect(workspacePage.researchReady).toBeEnabled(); + await workspacePage.ensureSidebarOpen(); + await expect(workspacePage.manageAccounts).toBeEnabled(); }); diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index 73800ff..4001d30 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -1,152 +1,124 @@ -import type { Page } from "@playwright/test"; import { expect, test } from "../fixtures"; -import { closeMobileSidebar, showDeckManagement } from "../workspace"; +import { DeckPage } from "../models/DeckPage"; -async function addColumn( - page: Page, - title: string, - query: string, - profile = "e2e", - kind = "search", -) { - await showDeckManagement(page); - await page.getByRole("button", { name: "Add column", exact: true }).click(); - const editor = page.getByRole("form", { name: "Add column", exact: true }); - await editor.getByLabel("Column name", { exact: true }).fill(title); - await editor - .getByRole("combobox", { name: "Connection profile", exact: true }) - .selectOption({ label: profile }); - await editor.getByRole("combobox", { name: "Column type", exact: true }).selectOption(kind); - await editor - .getByLabel(kind === "search" ? "Twitter search query" : kind === "user" ? "User" : "List", { - exact: true, - }) - .fill(query); - await editor.getByRole("button", { name: "Add and search" }).click(); - await closeMobileSidebar(page); - await expect( - page.getByRole("region", { name: title, exact: true }).locator("article").first(), - ).toBeVisible(); -} - -test.beforeEach(async ({ page }) => { - await page.goto("/deck"); - await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled(); - await page.getByRole("button", { name: "Save as deck", exact: true }).click(); - await showDeckManagement(page); - await expect(page.getByText("Saved to server · Shared across devices")).toBeVisible(); - await closeMobileSidebar(page); +test.beforeEach(async ({ deckPage, workspacePage }) => { + await deckPage.goTo(); + await expect(deckPage.saveAsDeck).toBeEnabled(); + await deckPage.saveAsDeck.click(); + await workspacePage.showDeckManagement(); + await expect(deckPage.savedNotice).toBeVisible(); + await workspacePage.closeMobileSidebar(); }); test("binds identical searches to independent profiles through paging, editing and reload", async ({ page, + deckPage, }) => { - await addColumn(page, "通常の接続", "same-query"); - await addColumn(page, "別の接続", "same-query", "alternate"); - const first = page.getByRole("region", { name: "通常の接続", exact: true }); - const second = page.getByRole("region", { name: "別の接続", exact: true }); - await expect(first.getByText("Latest · all page 1", { exact: true })).toBeVisible(); - await expect(second.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible(); - await first.getByRole("button", { name: "Load more" }).click(); - await expect(first.locator("article")).toHaveCount(2); - await expect(second.locator("article")).toHaveCount(1); - await second.getByRole("button", { name: "Load more" }).click(); - await expect(second.getByText("alternate · Latest · all page 2")).toBeVisible(); - await second.getByRole("button", { name: "Actions for 別の接続" }).click(); - await page.getByRole("menuitem", { name: "Edit", exact: true }).click(); - await page - .getByRole("combobox", { name: "Connection profile", exact: true }) - .selectOption({ label: "e2e" }); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible(); - await expect(second.getByText("alternate · Latest · all page 1")).toHaveCount(0); + await deckPage.addColumn("通常の接続", "same-query"); + await deckPage.addColumn("別の接続", "same-query", "alternate"); + await expect(deckPage.columnPostText("通常の接続", "Latest · all page 1", true)).toBeVisible(); + await expect( + deckPage.columnPostText("別の接続", "alternate · Latest · all page 1", true), + ).toBeVisible(); + await deckPage.loadMore("通常の接続"); + await expect(deckPage.columnPosts("通常の接続")).toHaveCount(2); + await expect(deckPage.columnPosts("別の接続")).toHaveCount(1); + await deckPage.loadMore("別の接続"); + await expect( + deckPage.columnPostText("別の接続", "alternate · Latest · all page 2"), + ).toBeVisible(); + await deckPage.openColumnActions("別の接続"); + await deckPage.editColumn.click(); + await deckPage.editorProfile.selectOption({ label: "e2e" }); + await deckPage.saveChanges.click(); + await expect(deckPage.columnPostText("別の接続", "Latest · all page 1", true)).toBeVisible(); + await expect(deckPage.columnPostText("別の接続", "alternate · Latest · all page 1")).toHaveCount( + 0, + ); await page.reload(); - await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible(); - await expect(first.locator("article")).toHaveCount(1); + await expect(deckPage.columnPostText("別の接続", "Latest · all page 1", true)).toBeVisible(); + await expect(deckPage.columnPosts("通常の接続")).toHaveCount(1); }); -test("creates, switches, renames and deletes persisted deck profiles", async ({ page }) => { - await page.getByRole("button", { name: "Rename" }).click(); - await page.getByLabel("Research topic / deck name").fill("最初の調査"); - await page.getByRole("button", { name: "Save name" }).click(); - await addColumn(page, "最初の観点", "first-deck"); - await showDeckManagement(page); - await page.getByRole("button", { name: "Create deck", exact: true }).click(); - await page.getByLabel("New deck name").fill("別の調査"); - await page.getByRole("button", { name: "Create", exact: true }).click(); - await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0); - await addColumn(page, "別の観点", "second-deck", "alternate"); - await showDeckManagement(page); - await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" }); - await closeMobileSidebar(page); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]); +test("creates, switches, renames and deletes persisted deck profiles", async ({ + page, + deckPage, + workspacePage, +}) => { + await deckPage.rename.click(); + await deckPage.deckName.fill("最初の調査"); + await deckPage.saveName.click(); + await deckPage.addColumn("最初の観点", "first-deck"); + await workspacePage.showDeckManagement(); + await deckPage.createDeck.click(); + await deckPage.newDeckName.fill("別の調査"); + await deckPage.confirmCreate.click(); + await expect(deckPage.columns).toHaveCount(0); + await deckPage.addColumn("別の観点", "second-deck", "alternate"); + await workspacePage.showDeckManagement(); + await deckPage.deckProfile.selectOption({ label: "最初の調査" }); + await workspacePage.closeMobileSidebar(); + await expect(deckPage.columnTitles).toHaveText(["最初の観点"]); await page.reload(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査"); - await showDeckManagement(page); - await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" }); - await closeMobileSidebar(page); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["別の観点"]); - await showDeckManagement(page); - await page.getByRole("button", { name: "Delete deck", exact: true }).click(); - await page.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(deckPage.heading).toHaveText("最初の調査"); + await workspacePage.showDeckManagement(); + await deckPage.deckProfile.selectOption({ label: "別の調査" }); + await workspacePage.closeMobileSidebar(); + await expect(deckPage.columnTitles).toHaveText(["別の観点"]); + await workspacePage.showDeckManagement(); + await deckPage.deleteDeck.click(); + await deckPage.confirmDelete.click(); await page.reload(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査"); - await showDeckManagement(page); - await expect(page.getByLabel("Deck profile", { exact: true }).locator("option")).toHaveCount(1); + await expect(deckPage.heading).toHaveText("最初の調査"); + await workspacePage.showDeckManagement(); + await expect(deckPage.profileOptions).toHaveCount(1); }); test("reads user and list timelines within columns and reorders, deletes and restores them", async ({ page, + deckPage, }) => { - await addColumn(page, "アカウント", "yuta", "alternate", "user"); - await addColumn(page, "リストの投稿", "1234567890", "e2e", "list"); - const user = page.getByRole("region", { name: "アカウント", exact: true }); - const list = page.getByRole("region", { name: "リストの投稿", exact: true }); - await expect(user.getByText("alternate profile page 1")).toBeVisible(); - await expect(list.getByText("list page 1")).toBeVisible(); - await list.getByRole("button", { name: "Load more" }).click(); - await expect(list.getByText("list page 2")).toBeVisible(); - await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); - await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click(); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([ - "リストの投稿", - "アカウント", - ]); - await list.getByRole("button", { name: "Actions for リストの投稿" }).click(); - await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click(); - await page.getByRole("button", { name: "Undo" }).click(); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([ - "リストの投稿", - "アカウント", - ]); + await deckPage.addColumn("アカウント", "yuta", "alternate", "user"); + await deckPage.addColumn("リストの投稿", "1234567890", "e2e", "list"); + await expect(deckPage.columnPostText("アカウント", "alternate profile page 1")).toBeVisible(); + await expect(deckPage.columnPostText("リストの投稿", "list page 1")).toBeVisible(); + await deckPage.loadMore("リストの投稿"); + await expect(deckPage.columnPostText("リストの投稿", "list page 2")).toBeVisible(); + await deckPage.openColumnActions("リストの投稿"); + await deckPage.columnAction("Move リストの投稿 left").click(); + await expect(deckPage.columnTitles).toHaveText(["リストの投稿", "アカウント"]); + await deckPage.openColumnActions("リストの投稿"); + await deckPage.columnAction("Delete リストの投稿").click(); + await deckPage.undo.click(); + await expect(deckPage.columnTitles).toHaveText(["リストの投稿", "アカウント"]); await page.reload(); - await expect(user.getByText("alternate profile page 1")).toBeVisible(); - await expect(list.getByText("list page 1")).toBeVisible(); + await expect(deckPage.columnPostText("アカウント", "alternate profile page 1")).toBeVisible(); + await expect(deckPage.columnPostText("リストの投稿", "list page 1")).toBeVisible(); }); test("keeps other columns usable when pagination fails and retries that column", async ({ - page, + deckPage, }, testInfo) => { - await addColumn(page, "失敗する検索", `retry-deck-${testInfo.project.name}`); - await addColumn(page, "別の検索", "independent-deck"); - const failed = page.getByRole("region", { name: "失敗する検索", exact: true }); - const other = page.getByRole("region", { name: "別の検索", exact: true }); - await failed.getByRole("button", { name: "Load more" }).click(); - await expect(failed.getByRole("alert")).toBeVisible(); - await expect(failed.locator("article")).toHaveCount(1); - await other.getByRole("button", { name: "Load more" }).click(); - await expect(other.locator("article")).toHaveCount(2); - await failed.getByRole("button", { name: "Retry" }).click(); - await expect(failed.locator("article")).toHaveCount(2); + await deckPage.addColumn("失敗する検索", `retry-deck-${testInfo.project.name}`); + await deckPage.addColumn("別の検索", "independent-deck"); + await deckPage.loadMore("失敗する検索"); + await expect(deckPage.columnError("失敗する検索")).toBeVisible(); + await expect(deckPage.columnPosts("失敗する検索")).toHaveCount(1); + await deckPage.loadMore("別の検索"); + await expect(deckPage.columnPosts("別の検索")).toHaveCount(2); + await deckPage.retryColumn("失敗する検索"); + await expect(deckPage.columnPosts("失敗する検索")).toHaveCount(2); }); test("keeps the deck and editor accessible within narrow viewports", async ({ page, + deckPage, + workspacePage, a11y, }, testInfo) => { - await addColumn(page, "日本語の反応", "WebMCP lang:ja"); - await addColumn(page, "海外の反応", "WebMCP lang:en", "alternate"); - await addColumn(page, "公式アカウント", "OpenAI", "e2e", "user"); + await deckPage.addColumn("日本語の反応", "WebMCP lang:ja"); + await deckPage.addColumn("海外の反応", "WebMCP lang:en", "alternate"); + await deckPage.addColumn("公式アカウント", "OpenAI", "e2e", "user"); for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 900 }); expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe( @@ -155,7 +127,7 @@ test("keeps the deck and editor accessible within narrow viewports", async ({ expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe( true, ); - const board = await page.locator("[data-testid='deck-board']").boundingBox(); + const board = await deckPage.board.boundingBox(); expect(board).not.toBeNull(); expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901); } @@ -163,15 +135,15 @@ test("keeps the deck and editor accessible within narrow viewports", async ({ width: testInfo.project.name === "desktop" ? 1440 : 393, height: 900, }); - await expect(page.getByRole("navigation", { name: "Columns" })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "公式アカウント", exact: true })).toBeVisible(); + await expect(deckPage.columnNavigation).toHaveCount(0); + await expect(deckPage.columnHeading("公式アカウント")).toBeVisible(); expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath("deck-populated.png"), fullPage: true, }); - await showDeckManagement(page); - await page.getByRole("button", { name: "Add column", exact: true }).click(); + await workspacePage.showDeckManagement(); + await deckPage.addColumnButton.click(); expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath("deck.png"), @@ -181,57 +153,53 @@ test("keeps the deck and editor accessible within narrow viewports", async ({ test("closes the column editor with Escape and restores focus without applying edits", async ({ page, + deckPage, + workspacePage, }) => { - await showDeckManagement(page); - const opener = page.getByRole("button", { name: "Add column", exact: true }); + await workspacePage.showDeckManagement(); + const opener = deckPage.addColumnButton; await opener.click(); - await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toBeVisible(); - await page.getByLabel("Column name", { exact: true }).fill("キャンセルする観点"); + await expect(deckPage.columnDialog).toBeVisible(); + await deckPage.columnName.fill("キャンセルする観点"); await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0); + await expect(deckPage.columnDialog).toHaveCount(0); await expect(opener).toBeFocused(); - await expect(page.locator("[data-testid='deck-column']")).toHaveCount(0); + await expect(deckPage.columns).toHaveCount(0); await opener.click(); - await expect(page.getByLabel("Column name", { exact: true })).toHaveValue(""); + await expect(deckPage.columnName).toHaveValue(""); }); -test("does not overwrite unreadable storage on reload", async ({ page }) => { +test("does not overwrite unreadable storage on reload", async ({ page, deckPage }) => { await page.evaluate(() => localStorage.setItem("twitter-lite-research-deck", "{broken")); await page.reload(); - await expect(page.getByRole("alert")).toContainText("Unable to load"); + await expect(deckPage.alert).toContainText("Unable to load"); expect(await page.evaluate(() => localStorage.getItem("twitter-lite-research-deck"))).toBe( "{broken", ); }); test("selects a list from the column profile catalog and refreshes it on profile change", async ({ - page, + deckPage, + workspacePage, }) => { - await showDeckManagement(page); - await page.getByRole("button", { name: "Add column", exact: true }).click(); - await page.getByLabel("Column name", { exact: true }).fill("購読リスト"); - await page - .getByRole("combobox", { name: "Connection profile", exact: true }) - .selectOption({ label: "e2e" }); - await page.getByRole("combobox", { name: "Column type", exact: true }).selectOption("list"); - await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toBeVisible(); - await page - .getByRole("combobox", { name: "Connection profile", exact: true }) - .selectOption({ label: "alternate" }); - await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toHaveCount(0); - await page.getByRole("button", { name: "Alternate List", exact: true }).click(); - await expect(page.getByLabel("List", { exact: true })).toHaveValue("1234567890"); - await page.getByRole("button", { name: "Add and search" }).click(); - await closeMobileSidebar(page); - await expect( - page - .getByRole("region", { name: "購読リスト", exact: true }) - .getByText("list page 1", { exact: true }), - ).toBeVisible(); + await workspacePage.showDeckManagement(); + await deckPage.addColumnButton.click(); + await deckPage.columnName.fill("購読リスト"); + await deckPage.editorProfile.selectOption({ label: "e2e" }); + await deckPage.columnType.selectOption("list"); + await expect(deckPage.primaryList).toBeVisible(); + await deckPage.editorProfile.selectOption({ label: "alternate" }); + await expect(deckPage.primaryList).toHaveCount(0); + await deckPage.alternateList.click(); + await expect(deckPage.listId).toHaveValue("1234567890"); + await deckPage.addAndSearch.click(); + await workspacePage.closeMobileSidebar(); + await expect(deckPage.columnPostText("購読リスト", "list page 1", true)).toBeVisible(); }); test("keeps an open draft through remote edits and rejects its stale save", async ({ page, + deckPage, browser, baseURL, }) => { @@ -242,21 +210,22 @@ test("keeps an open draft through remote edits and rejects its stale save", asyn }); try { const device = await other.newPage(); - await device.goto("/deck"); - await page.getByRole("button", { name: "Rename" }).click(); - await page.getByLabel("Research topic / deck name").fill("編集中の名前"); - await device.getByRole("button", { name: "Rename" }).click(); - await device.getByLabel("Research topic / deck name").fill("別の端末の変更"); - await device.getByRole("button", { name: "Save name" }).click(); - await expect(device.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更"); + const deviceDeck = new DeckPage(device); + await deviceDeck.goTo(); + await deckPage.rename.click(); + await deckPage.deckName.fill("編集中の名前"); + await deviceDeck.rename.click(); + await deviceDeck.deckName.fill("別の端末の変更"); + await deviceDeck.saveName.click(); + await expect(deviceDeck.heading).toHaveText("別の端末の変更"); await page.evaluate(() => window.dispatchEvent(new Event("focus"))); - await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前"); - await page.getByRole("button", { name: "Save name" }).click(); - await expect(page.getByRole("alert")).toContainText("This deck changed on another device"); - await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前"); + await expect(deckPage.deckName).toHaveValue("編集中の名前"); + await deckPage.saveName.click(); + await expect(deckPage.alert).toContainText("This deck changed on another device"); + await expect(deckPage.deckName).toHaveValue("編集中の名前"); await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Reload latest deck" }).click(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更"); + await deckPage.reloadLatest.click(); + await expect(deckPage.heading).toHaveText("別の端末の変更"); } finally { await other.close(); } @@ -264,6 +233,8 @@ test("keeps an open draft through remote edits and rejects its stale save", asyn test("imports old browser decks explicitly and avoids duplicate imports after retry", async ({ page, + deckPage, + workspacePage, }) => { const legacy = { version: 2, @@ -294,40 +265,44 @@ test("imports old browser decks explicitly and avoids duplicate imports after re legacy, ); await page.reload(); - await showDeckManagement(page); - const selector = page.getByLabel("Deck profile", { exact: true }); - await expect(selector.locator("option")).toHaveCount(1); - await closeMobileSidebar(page); - await page.getByRole("button", { name: "Import legacy decks" }).click(); - await showDeckManagement(page); - await expect(selector.locator("option")).toHaveCount(2); + await workspacePage.showDeckManagement(); + const selector = deckPage.deckProfile; + await expect(deckPage.profileOptions).toHaveCount(1); + await workspacePage.closeMobileSidebar(); + await deckPage.importLegacy.click(); + await workspacePage.showDeckManagement(); + await expect(deckPage.profileOptions).toHaveCount(2); await selector.selectOption({ label: "以前の調査" }); - await closeMobileSidebar(page); - await expect(page.getByText("Latest · all page 1", { exact: true })).toBeVisible(); + await workspacePage.closeMobileSidebar(); + await expect(deckPage.postText("Latest · all page 1")).toBeVisible(); await page.evaluate( (legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)), legacy, ); await page.reload(); - await page.getByRole("button", { name: "Import legacy decks" }).click(); - await expect(page.getByRole("button", { name: "Import legacy decks" })).toHaveCount(0); - await showDeckManagement(page); - await expect(selector.locator("option")).toHaveCount(2); + await deckPage.importLegacy.click(); + await expect(deckPage.importLegacy).toHaveCount(0); + await workspacePage.showDeckManagement(); + await expect(deckPage.profileOptions).toHaveCount(2); }); -test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({ page }) => { - await showDeckManagement(page); - await page.getByRole("button", { name: "Create deck", exact: true }).click(); - const dialog = page.getByRole("dialog", { name: "Create deck", exact: true }); - await dialog.getByLabel("New deck name").fill("画面を回転しても残る調査"); +test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({ + page, + deckPage, + workspacePage, +}) => { + await workspacePage.showDeckManagement(); + await deckPage.createDeck.click(); + const dialog = deckPage.createDialog; + await deckPage.newDeckName.fill("画面を回転しても残る調査"); for (const width of [1024, 375, 1024]) { await page.setViewportSize({ width, height: 900 }); await expect(dialog).toHaveCount(1); await expect(dialog).toBeVisible(); - await expect(dialog.getByLabel("New deck name")).toHaveValue("画面を回転しても残る調査"); - await expect(page.locator("#new-deck-title")).toHaveCount(1); + await expect(deckPage.newDeckName).toHaveValue("画面を回転しても残る調査"); + await expect(deckPage.newDeckTitleInput).toHaveCount(1); } - await dialog.getByRole("button", { name: "Create", exact: true }).click(); + await deckPage.confirmCreate.click(); await expect(dialog).toHaveCount(0); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("画面を回転しても残る調査"); + await expect(deckPage.heading).toHaveText("画面を回転しても残る調査"); }); diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts index df66d89..c7f8456 100644 --- a/e2e/integrations/home.test.ts +++ b/e2e/integrations/home.test.ts @@ -3,187 +3,185 @@ import { expect, test } from "../fixtures"; test("executes routine steps and records a measurement in the shared journal", async ({ page, a11y, + homePage, + journalPage, + workspacePage, }, testInfo) => { - await page.goto("/"); + await homePage.goTo(); await page.waitForLoadState("networkidle"); - await page.getByRole("button", { name: /Morning routine 0 of 3/ }).click(); + await homePage.morningRoutine.click(); await expect(page).toHaveURL(/activity=task/); - await page.getByRole("checkbox", { name: /Drink water/ }).check(); - await expect(page.getByRole("button", { name: "Finish routine" })).toBeDisabled(); - await page.getByRole("checkbox", { name: /Get ready/ }).check(); - await page.getByRole("checkbox", { name: /Have breakfast/ }).check(); + await homePage.routineStep(/Drink water/).check(); + await expect(homePage.finishRoutine).toBeDisabled(); + await homePage.routineStep(/Get ready/).check(); + await homePage.routineStep(/Have breakfast/).check(); expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath("home-routine.png"), fullPage: true }); - await page.getByRole("button", { name: "Finish routine" }).click(); - await page.getByRole("button", { name: /Record weight A quick check-in/ }).click(); - await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); - await page.getByRole("button", { name: "Save measurement" }).click(); - await expect( - page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }), - ).toBeVisible(); - await page.getByRole("link", { name: "Journal", exact: true }).first().click(); - await expect(page.getByRole("article", { name: "Weight entry" })).toContainText("65.2 kg"); - await page.getByRole("link", { name: "Home", exact: true }).first().click(); - await expect(page.getByRole("button", { name: "Restore Morning routine" })).toBeVisible(); + await homePage.finishRoutine.click(); + await homePage.recordMeasurement("65.2"); + await expect(homePage.recordedWeight("65.2")).toBeVisible(); + await workspacePage.navigateTo("Journal"); + await expect(journalPage.weightEntries).toContainText("65.2 kg"); + await workspacePage.navigateTo("Home"); + await expect(homePage.restoreTask("Morning routine")).toBeVisible(); }); -test("shows the day flow and conversation context at desktop width", async ({ page }, testInfo) => { +test("shows the day flow and conversation context at desktop width", async ({ + page, + homePage, + messagesPage, +}, testInfo) => { await page.setViewportSize({ width: 1440, height: 1080 }); - await page.goto("/"); + await homePage.goTo(); await page.waitForLoadState("networkidle"); - await expect(page.getByRole("region", { name: "Today’s plan" })).toBeVisible(); + await expect(homePage.todayPlan).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("home-desktop.png") }); - await page.goto("/support?conversation=design-review"); + await messagesPage.goTo("?conversation=design-review"); await page.waitForLoadState("networkidle"); - await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await expect(messagesPage.reply).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("messages-desktop.png") }); }); test("previews a natural-language memo and shares the executed record with journal", async ({ page, + homePage, + journalPage, + workspacePage, }) => { - await page.goto("/"); + await homePage.goTo(); await page.waitForLoadState("networkidle"); const memo = "明日のレビューは導線から確認する"; - await page.getByRole("textbox", { name: "Home input" }).fill(`Note: ${memo}`); - await page.getByRole("button", { name: "Preview action" }).click(); - const execute = page.getByRole("button", { name: "Apply", exact: true }); + await homePage.previewCommand(`Note: ${memo}`); + const execute = homePage.applyAction; await expect(execute).toBeVisible(); await execute.click(); - await page.getByRole("link", { name: "Journal", exact: true }).first().click(); - await expect(page.getByRole("article", { name: "Note entry" })).toContainText(memo); - await page.getByRole("link", { name: "Home", exact: true }).first().click(); - await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible(); + await workspacePage.navigateTo("Journal"); + await expect(journalPage.noteEntries).toContainText(memo); + await workspacePage.navigateTo("Home"); + await expect(homePage.input).toBeVisible(); }); -test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => { - await page.goto("/"); +test("keeps the home usable on narrow screens", async ({ page, a11y, homePage }, testInfo) => { + await homePage.goTo(); await page.waitForLoadState("networkidle"); for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); - await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible(); + await expect(homePage.input).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) }); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); } }); -test("adds tasks only after confirmation and can defer and restore them", async ({ page }) => { - await page.goto("/"); +test("adds tasks only after confirmation and can defer and restore them", async ({ + page, + homePage, +}) => { + await homePage.goTo(); await page.waitForLoadState("networkidle"); - const input = page.getByRole("textbox", { name: "Home input" }); - const complete = page.getByRole("button", { - name: "Complete grocery shopping", - exact: true, - }); - await input.fill("Add grocery shopping"); - await page.getByRole("button", { name: "Preview action" }).click(); + const complete = homePage.completeTask("grocery shopping"); + await homePage.previewCommand("Add grocery shopping"); await expect(complete).toHaveCount(0); - await page.getByRole("button", { name: "Apply", exact: true }).click(); + await homePage.applyAction.click(); await expect(complete).toBeVisible(); - await input.fill("Defer grocery shopping"); - await page.getByRole("button", { name: "Preview action" }).click(); + await homePage.previewCommand("Defer grocery shopping"); await expect(complete).toBeVisible(); - await page.getByRole("button", { name: "Apply", exact: true }).click(); + await homePage.applyAction.click(); await expect(complete).toHaveCount(0); - await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click(); + await homePage.restoreTask("grocery shopping").click(); await complete.click(); await expect(complete).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Restore grocery shopping", exact: true }), - ).toBeVisible(); + await expect(homePage.restoreTask("grocery shopping")).toBeVisible(); }); test("opens a conversation page from home and retains outcomes on return", async ({ page, a11y, + homePage, + messagesPage, + workspacePage, }) => { - await page.goto("/"); + await homePage.goTo(); await page.waitForLoadState("networkidle"); - const conversationLink = page.getByRole("link", { name: "Open conversation with Haruka Aoki" }); + const conversationLink = homePage.conversation("Haruka Aoki"); await expect(conversationLink).toHaveAttribute("href", /conversation=design-review/); await conversationLink.click(); await expect(page).toHaveURL(/\/support\?.*conversation=design-review/); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await expect(messagesPage.dialogs).toHaveCount(0); + await expect(messagesPage.reply).toBeVisible(); expect((await a11y().analyze()).violations).toEqual([]); await page.goBack(); await expect(conversationLink).toBeVisible(); await page.goForward(); - await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await expect(messagesPage.reply).toBeVisible(); await page.reload(); await page.waitForLoadState("networkidle"); - await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Leave for later" }).click(); - await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(messagesPage.reply).toBeVisible(); + await messagesPage.leaveForLater.click(); + await workspacePage.navigateTo("Home"); await expect(conversationLink).toContainText("Set aside"); await conversationLink.click(); await expect(page).toHaveURL(/conversation=design-review/); - await expect(page.getByRole("button", { name: "Move to Inbox" })).toBeVisible(); - await page.getByRole("button", { name: "Move to Inbox" }).click(); - const back = page.getByRole("button", { name: "Back to messages" }); - if (await back.isVisible()) await back.click(); - await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(messagesPage.moveToInbox).toBeVisible(); + await messagesPage.moveToInbox.click(); + await messagesPage.showConversationList(); + await workspacePage.navigateTo("Home"); await expect(conversationLink).toBeVisible(); }); test("updates the prose brief as priorities change and opens the referenced conversation", async ({ page, + homePage, + messagesPage, + workspacePage, }) => { - await page.goto("/"); + await homePage.goTo(); await page.waitForLoadState("networkidle"); - const brief = page.getByRole("region", { name: "Daily brief" }); + const brief = homePage.dailyBrief; await expect(brief).toContainText("Review the documents"); await expect(brief).toContainText("3 conversations"); - await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); + await homePage.completeTask("Review the documents").click(); await expect(brief).not.toContainText("Review the documents"); - await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click(); - await page.getByRole("button", { name: "Leave for later" }).click(); - await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await homePage.openBriefConversation("Haruka Aoki"); + await messagesPage.leaveForLater.click(); + await workspacePage.navigateTo("Home"); await expect(brief).toContainText("2 conversations"); await expect(brief).not.toContainText("Haruka Aoki"); - await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から"); - await page.getByRole("button", { name: "Preview action" }).click(); - await page.getByRole("button", { name: "Apply", exact: true }).click(); + await homePage.previewCommand("再開地点:資料の2ページ目から"); + await homePage.applyAction.click(); await expect(brief).toContainText("資料の2ページ目から"); }); test("keeps completed actions in their original positions with visible outcomes", async ({ page, a11y, + homePage, + messagesPage, + workspacePage, }, testInfo) => { - await page.goto("/"); + await homePage.goTo(); await page.waitForLoadState("networkidle"); - const titles = page.locator("[data-testid='execution-flow-title']"); + const titles = homePage.flowTitles; const before = await titles.allTextContents(); - await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Restore Review the documents", exact: true }), - ).toHaveAttribute("aria-pressed", "true"); - await expect( - page.getByRole("button", { name: "Review the documents Completed", exact: true }), - ).toBeVisible(); - await page.getByRole("link", { name: "Open conversation with Haruka Aoki" }).click(); - await page.getByRole("button", { name: "Review reply", exact: true }).click(); - await page - .getByRole("dialog", { name: "Review reply", exact: true }) - .getByRole("button", { name: "Try sending" }) - .click(); - await page.getByRole("link", { name: "Home", exact: true }).first().click(); - await expect( - page.getByRole("link", { name: "Open conversation with Haruka Aoki" }), - ).toContainText("Reply recorded"); - await page.getByRole("button", { name: /Record weight A quick check-in/ }).click(); - await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); - await page.getByRole("button", { name: "Save measurement" }).click(); - await expect( - page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }), - ).toBeVisible(); + await homePage.completeTask("Review the documents").click(); + await expect(homePage.restoreTask("Review the documents")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(homePage.completedTask("Review the documents")).toBeVisible(); + await homePage.conversation("Haruka Aoki").click(); + await messagesPage.reviewReply.click(); + await messagesPage.trySending.click(); + await workspacePage.navigateTo("Home"); + await expect(homePage.conversation("Haruka Aoki")).toContainText("Reply recorded"); + await homePage.recordMeasurement("65.2"); + await expect(homePage.recordedWeight("65.2")).toBeVisible(); expect(await titles.allTextContents()).toEqual(before); expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") }); - await page.getByRole("button", { name: "Restore Review the documents", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Complete Review the documents", exact: true }), - ).toHaveAttribute("aria-pressed", "false"); + await homePage.restoreTask("Review the documents").click(); + await expect(homePage.completeTask("Review the documents")).toHaveAttribute( + "aria-pressed", + "false", + ); }); diff --git a/e2e/integrations/inbox.test.ts b/e2e/integrations/inbox.test.ts index 31baa72..9a47f21 100644 --- a/e2e/integrations/inbox.test.ts +++ b/e2e/integrations/inbox.test.ts @@ -1,75 +1,66 @@ import { expect, test } from "../fixtures"; -test("selects articles and keeps the reader focused on their content", async ({ page }) => { - await page.goto("/inbox"); - await page.waitForLoadState("networkidle"); - const rows = page.locator("[data-testid='inbox-article-row']"); - await expect(rows).toHaveCount(9); +test("selects articles and keeps the reader focused on their content", async ({ readerPage }) => { + await readerPage.goTo(); + await expect(readerPage.rows).toHaveCount(9); const articleTitle = "Context for an agent can be brief"; - await rows.filter({ hasText: articleTitle }).click(); - const reader = page.getByRole("article", { name: articleTitle, exact: true }); + await readerPage.selectArticle(articleTitle); + const reader = readerPage.article(articleTitle); await expect(reader).toBeVisible(); - await expect(reader.getByRole("heading", { name: articleTitle, exact: true })).toBeVisible(); + await expect(readerPage.articleHeading(articleTitle)).toBeVisible(); await expect( - reader.getByText("Start with a one-sentence goal, then add the constraints that must hold.", { - exact: false, - }), - ).toBeVisible(); - await expect(page.getByText("FOR YOU", { exact: true })).toHaveCount(0); - await expect(page.getByText("Mock", { exact: true })).toHaveCount(0); - await expect(page.getByText("Why this article", { exact: true })).toHaveCount(0); - await expect(page.getByRole("button", { name: /For you|Read later|Unread only/ })).toHaveCount(0); - const back = page.getByRole("button", { name: "Back to articles" }); - if (await back.isVisible()) await back.click(); - await rows.filter({ hasText: "Three columns create a rhythm for reading" }).click(); - await expect( - page.getByRole("article", { - name: "Three columns create a rhythm for reading", - exact: true, - }), + readerPage.articleText( + articleTitle, + "Start with a one-sentence goal, then add the constraints that must hold.", + ), ).toBeVisible(); + await expect(readerPage.forYouLabel).toHaveCount(0); + await expect(readerPage.mockLabel).toHaveCount(0); + await expect(readerPage.articleExplanation).toHaveCount(0); + await expect(readerPage.legacyFilters).toHaveCount(0); + await readerPage.backToArticles(); + await readerPage.selectArticle("Three columns create a rhythm for reading"); + await expect(readerPage.article("Three columns create a rhythm for reading")).toBeVisible(); }); -test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => { - await page.goto("/inbox"); - await page.waitForLoadState("networkidle"); - const rows = page.locator("[data-testid='inbox-article-row']"); - await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz"); - await expect(rows).toHaveCount(0); - await expect(page.getByText("No articles found", { exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Clear search" }).click(); - await expect(rows).toHaveCount(9); +test("recovers from empty search and shares navigation with Research", async ({ + page, + readerPage, + workspacePage, + a11y, +}) => { + await readerPage.goTo(); + await readerPage.search("nonexistent-query-xyz"); + await expect(readerPage.rows).toHaveCount(0); + await expect(readerPage.emptyState).toBeVisible(); + await readerPage.clearSearch(); + await expect(readerPage.rows).toHaveCount(9); expect((await a11y().analyze()).violations).toEqual([]); - await expect(page.getByRole("button", { name: "Edit interests", exact: true })).toHaveCount(0); - await page.getByRole("link", { name: "Research", exact: true }).click(); + await expect(readerPage.editInterestsButton).toHaveCount(0); + await workspacePage.navigateTo("Research"); await expect(page).toHaveURL(/\/deck$/); - await page.getByRole("link", { name: "Reader", exact: true }).click(); + await workspacePage.navigateTo("Reader"); await expect(page).toHaveURL(/\/inbox$/); - await expect(rows).toHaveCount(9); + await expect(readerPage.rows).toHaveCount(9); }); test("keeps article navigation readable without horizontal overflow", async ({ page, + readerPage, a11y, }, testInfo) => { - await page.goto("/inbox"); - await page.waitForLoadState("networkidle"); + await readerPage.goTo(); for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); - await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible(); + await expect(readerPage.searchInput).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); - await page.locator("[data-testid='inbox-article-row']").first().click(); - const reader = page.getByRole("article", { - name: "Thinking in the browser: a new workflow with AI agents", - exact: true, - }); - await expect(reader).toBeVisible(); + await readerPage.openFirstArticle(); + await expect( + readerPage.article("Thinking in the browser: a new workflow with AI agents"), + ).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); - await page.screenshot({ - path: testInfo.outputPath(`inbox-reader-${width}.png`), - }); + await page.screenshot({ path: testInfo.outputPath(`inbox-reader-${width}.png`) }); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); - const back = page.getByRole("button", { name: "Back to articles" }); - if (await back.isVisible()) await back.click(); + await readerPage.backToArticles(); } }); diff --git a/e2e/integrations/mcp-http.test.ts b/e2e/integrations/mcp-http.test.ts index 1bbe35a..5da14cf 100644 --- a/e2e/integrations/mcp-http.test.ts +++ b/e2e/integrations/mcp-http.test.ts @@ -4,6 +4,7 @@ test("HTTP MCP persists decks visible in Research without browser credentials", playwright, baseURL, page, + deckPage, }) => { const client = await playwright.request.newContext({ baseURL, @@ -59,8 +60,8 @@ test("HTTP MCP persists decks visible in Research without browser credentials", }); expect(fetched.ok).toBe(true); expect(fetched.posts.length).toBeGreaterThan(0); - await page.goto(`/deck?deck=${created.deck.id}`); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("HTTP MCP research"); + await deckPage.goTo(`?deck=${created.deck.id}`); + await expect(deckPage.heading).toHaveText("HTTP MCP research"); const updated = await tool("replace_deck", { deckId: created.deck.id, expectedRevision: 1, @@ -72,7 +73,7 @@ test("HTTP MCP persists decks visible in Research without browser credentials", await tool("delete_deck", { deckId: created.deck.id, expectedRevision: 1 }), ).toMatchObject({ ok: false, error: { code: "conflict" } }); await page.reload(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Updated via HTTP"); + await expect(deckPage.heading).toHaveText("Updated via HTTP"); expect( await tool("delete_deck", { deckId: created.deck.id, expectedRevision: 2 }), ).toMatchObject({ ok: true }); diff --git a/e2e/integrations/research.test.ts b/e2e/integrations/research.test.ts index a3d7535..ec71331 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -1,10 +1,11 @@ import type { DeckColumn } from "../../src/features/decks/model"; import type { ResearchRun } from "../../src/features/research/model"; import { expect, test } from "../fixtures"; -import { closeMobileSidebar, showDeckManagement, showResearch } from "../workspace"; +import { ResearchPage } from "../models/ResearchPage"; +import { WorkspacePage } from "../models/WorkspacePage"; -test("pushes the current research snapshot on each SSE connection", async ({ page }) => { - await page.goto("/deck"); +test("pushes the current research snapshot on each SSE connection", async ({ page, deckPage }) => { + await deckPage.goTo(); const snapshots = await page.evaluate(async () => { const connect = () => new Promise((resolve, reject) => { @@ -34,18 +35,18 @@ test("pushes the current research snapshot on each SSE connection", async ({ pag test("restores the conversation and growing temporary deck from reconnect snapshots", async ({ page, + deckPage, + researchPage, + workspacePage, }) => { - await page.goto("/deck"); - await showDeckManagement(page); - await page.getByRole("button", { name: "Add column", exact: true }).click(); - const accounts = page.getByRole("combobox", { - name: "Connection profile", - exact: true, - }); + await deckPage.goTo(); + await workspacePage.showDeckManagement(); + await deckPage.addColumnButton.click(); + const accounts = deckPage.editorProfile; await accounts.selectOption({ label: "e2e" }); const connectionId = await accounts.inputValue(); await page.keyboard.press("Escape"); - await closeMobileSidebar(page); + await workspacePage.closeMobileSidebar(); const run: ResearchRun = { id: "36ad8cc7-318c-46ae-94fa-28d30336f027", threadId: "conversation-test", @@ -90,17 +91,14 @@ test("restores the conversation and growing temporary deck from reconnect snapsh body: `data: ${JSON.stringify({ configured: true, run })}\n\n`, }), ); - await page.goto(`/deck?run=${run.id}`); - await showResearch(page); - const conversation = page.getByRole("log", { name: "Research conversation" }); + await deckPage.goTo(`?run=${run.id}`); + await workspacePage.showResearch(); + const conversation = researchPage.conversation; await expect(conversation).toContainText("既存の観点を使って調査して"); await expect(conversation).toContainText("list_decks: 完了"); await expect(conversation).toContainText("既存の検索条件を再利用しています。"); - await expect(conversation.locator('[data-slot="bubble-content"]')).not.toHaveCSS( - "background-color", - "rgba(0, 0, 0, 0)", - ); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["最初の観点"]); + await expect(researchPage.bubbles).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(deckPage.columnTitles).toHaveText(["最初の観点"]); run.deckVersion = 2; run.status = "complete"; run.messages.push({ @@ -121,52 +119,47 @@ test("restores the conversation and growing temporary deck from reconnect snapsh }, }); await page.reload(); - await showResearch(page); + await workspacePage.showResearch(); await expect(conversation).toContainText("既存の観点を使って調査して"); await expect(conversation).toContainText("比較する観点を追加しました。"); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText([ - "最初の観点", - "比較の観点", - ]); - await closeMobileSidebar(page); - await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled(); - await showResearch(page); - const accountPicker = page.getByRole("button", { - name: "Connected accounts · 1", - }); + await expect(deckPage.columnTitles).toHaveText(["最初の観点", "比較の観点"]); + await workspacePage.closeMobileSidebar(); + await expect(deckPage.saveAsDeck).toBeEnabled(); + await workspacePage.showResearch(); + const accountPicker = researchPage.accountPicker(1); await accountPicker.click(); await expect(accountPicker).toHaveAttribute("aria-expanded", "true"); - await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).toBeChecked(); + await expect(researchPage.account("e2e · Twitter")).toBeChecked(); await accountPicker.click(); await expect(accountPicker).toHaveAttribute("aria-expanded", "false"); - await page.getByLabel("Message", { exact: true }).fill("この観点を詳しく"); - await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled(); - await page.getByRole("button", { name: "Close sidebar", exact: true }).click(); + await researchPage.composer.fill("この観点を詳しく"); + await expect(researchPage.send).toBeEnabled(); + await workspacePage.closeSidebar.click(); run.deckVersion = 3; if (run.deck) run.deck.title = "閉じている間に更新された調査"; - await expect(page.getByRole("heading", { level: 1 })).toHaveText("閉じている間に更新された調査", { + await expect(deckPage.heading).toHaveText("閉じている間に更新された調査", { timeout: 15000, }); - await showResearch(page); - await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); + await workspacePage.showResearch(); + await expect(researchPage.composer).toHaveValue("この観点を詳しく"); await page.setViewportSize({ width: 600, height: 900 }); - await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1); - await showResearch(page); - await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); - await page.getByRole("button", { name: "Connected accounts · 1" }).click(); - await page.getByRole("checkbox", { name: "e2e · Twitter" }).uncheck(); + await expect(workspacePage.mobileSidebar).toHaveCount(1); + await workspacePage.showResearch(); + await expect(researchPage.composer).toHaveValue("この観点を詳しく"); + await researchPage.accountPicker(1).click(); + await researchPage.account("e2e · Twitter").uncheck(); await page.setViewportSize({ width: 1100, height: 900 }); - await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(0); - await showResearch(page); - await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); - await page.getByRole("button", { name: "Connected accounts · 0" }).click(); - await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked(); + await expect(workspacePage.mobileSidebar).toHaveCount(0); + await workspacePage.showResearch(); + await expect(researchPage.composer).toHaveValue("この観点を詳しく"); + await researchPage.accountPicker(0).click(); + await expect(researchPage.account("e2e · Twitter")).not.toBeChecked(); await page.setViewportSize({ width: 600, height: 900 }); - await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1); - await showResearch(page); - await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく"); - await page.getByRole("button", { name: "Connected accounts · 0" }).click(); - await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked(); + await expect(workspacePage.mobileSidebar).toHaveCount(1); + await workspacePage.showResearch(); + await expect(researchPage.composer).toHaveValue("この観点を詳しく"); + await researchPage.accountPicker(0).click(); + await expect(researchPage.account("e2e · Twitter")).not.toBeChecked(); }); test("rejects a different tailnet owner at the SSE endpoint", async ({ request }) => { @@ -178,20 +171,20 @@ test("rejects a different tailnet owner at the SSE endpoint", async ({ request } test("renders Markdown and reveals an unloaded citation in an offscreen deck column", async ({ page, + deckPage, + researchPage, + workspacePage, a11y, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/deck"); - await showDeckManagement(page); - await page.getByRole("button", { name: "Add column", exact: true }).click(); - const accounts = page.getByRole("combobox", { - name: "Connection profile", - exact: true, - }); + await deckPage.goTo(); + await workspacePage.showDeckManagement(); + await deckPage.addColumnButton.click(); + const accounts = deckPage.editorProfile; await accounts.selectOption({ label: "e2e" }); const connectionId = await accounts.inputValue(); await page.keyboard.press("Escape"); - await closeMobileSidebar(page); + await workspacePage.closeMobileSidebar(); const source: DeckColumn = { id: "citation-column-2", title: "引用の観点", @@ -252,27 +245,22 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col body: `data: ${JSON.stringify({ configured: true, run })}\n\n`, }), ); - await page.goto(`/deck?run=${run.id}`); - await showResearch(page); - const chat = page.getByRole("log", { name: "Research conversation" }); - await expect(chat.getByRole("heading", { name: "調査結果" })).toBeVisible(); - await expect(chat.getByRole("heading", { name: "調査結果" })).toHaveCSS("font-weight", "600"); - await expect(chat.locator("ul")).toHaveCSS("list-style-type", "disc"); - await expect(chat.locator("strong").filter({ hasText: "重要な発見" })).toBeVisible(); - await expect(chat.getByRole("table")).toBeVisible(); - await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0); - await chat.getByRole("link", { name: "根拠の投稿" }).click(); - const card = page.locator( - '[data-testid="research-post"][data-post-url="https://twitter.com/author/status/123456789"]', - ); + await deckPage.goTo(`?run=${run.id}`); + await workspacePage.showResearch(); + await expect(researchPage.messageHeading("調査結果")).toBeVisible(); + await expect(researchPage.messageHeading("調査結果")).toHaveCSS("font-weight", "600"); + await expect(researchPage.messageLists).toHaveCSS("list-style-type", "disc"); + await expect(researchPage.emphasis("重要な発見")).toBeVisible(); + await expect(researchPage.messageTables).toBeVisible(); + await expect(deckPage.postText("AIが調査時に取得した引用本文")).toHaveCount(0); + await researchPage.citation("根拠の投稿").click(); + const card = deckPage.post("https://twitter.com/author/status/123456789"); await expect(card).toBeVisible(); await expect(card).toHaveAttribute("data-citation-highlight", "true"); await expect(card).toBeFocused(); await expect(card).toContainText("Post saved during research"); await expect(card).toContainText("AIが調査時に取得した引用本文"); - expect( - await page.locator("[data-testid='deck-board']").evaluate((element) => element.scrollLeft), - ).toBeGreaterThan(0); + expect(await deckPage.board.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); expect(page.context().pages()).toHaveLength(1); await expect(page).toHaveURL(/citation=twitter%3A123456789/); const bookmarked = new URL(page.url()); @@ -284,45 +272,47 @@ test("renders Markdown and reveals an unloaded citation in an offscreen deck col }); await page.goto(bookmarked.toString()); await expect(card).toBeVisible(); - await expect(card).toHaveClass(/citation-highlight/); + await expect(card).toHaveAttribute("data-citation-highlight", "true"); await expect(page).toHaveURL(/citation=twitter%3A123456789/); - await expect(page.locator("[data-testid='deck-column']")).toHaveCount(3); + await expect(deckPage.columns).toHaveCount(3); expect(submitted).toEqual([]); expect((await a11y().analyze()).violations).toEqual([]); }); test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({ - page, + deckPage, + researchPage, + workspacePage, a11y, }) => { - await page.goto("/deck"); - const heading = page.getByRole("heading", { level: 1 }); + await deckPage.goTo(); + const heading = deckPage.heading; await expect(heading).toBeVisible(); const originalTitle = await heading.textContent(); - await showResearch(page); - const panel = page.getByRole("complementary", { name: "Research chat" }); + await workspacePage.showResearch(); + const panel = researchPage.panel; await expect(panel).toBeVisible(); - await expect( - panel.getByText("The connection to Codex on your home server is not configured."), - ).toBeVisible(); - await expect(panel.getByRole("button", { name: "Send", exact: true })).toBeDisabled(); - await expect(panel.getByLabel("Message", { exact: true })).toBeDisabled(); + await expect(researchPage.unconfiguredNotice).toBeVisible(); + await expect(researchPage.send).toBeDisabled(); + await expect(researchPage.composer).toBeDisabled(); const accessibility = await a11y().analyze(); expect(accessibility.violations).toEqual([]); - await closeMobileSidebar(page); + await workspacePage.closeMobileSidebar(); await expect(heading).toHaveText(originalTitle ?? ""); - await showDeckManagement(page); - await page.getByRole("button", { name: "Create deck", exact: true }).click(); - await page.getByLabel("New deck name").fill("手動の調査"); - await page.getByRole("button", { name: "Create", exact: true }).click(); - await closeMobileSidebar(page); + await workspacePage.showDeckManagement(); + await deckPage.createDeck.click(); + await deckPage.newDeckName.fill("手動の調査"); + await deckPage.confirmCreate.click(); + await workspacePage.closeMobileSidebar(); await expect(heading).toHaveText("手動の調査"); - await showResearch(page); + await workspacePage.showResearch(); await expect(panel).toBeVisible(); }); test("keeps browser conversation selection independent and restores it on reload", async ({ page, + researchPage, + workspacePage, browser, context, }) => { @@ -353,26 +343,22 @@ test("keeps browser conversation selection independent and restores it on reload }); try { const secondPage = await other.newPage(); - await page.goto(`/deck?run=${first}`, { waitUntil: "domcontentloaded" }); - await showResearch(page); - await secondPage.goto(`/deck?run=${second}`, { + const secondResearch = new ResearchPage(secondPage); + const secondWorkspace = new WorkspacePage(secondPage); + await researchPage.goTo(`?run=${first}`, { waitUntil: "domcontentloaded" }); + await workspacePage.showResearch(); + await secondResearch.goTo(`?run=${second}`, { waitUntil: "domcontentloaded", }); - await showResearch(secondPage); - await expect(page.getByRole("log", { name: "Research conversation" })).toContainText( - "Browser one conversation", - ); - await expect(secondPage.getByRole("log", { name: "Research conversation" })).toContainText( - "Browser two conversation", - ); - await secondPage.getByRole("button", { name: "New chat", exact: true }).click(); + await secondWorkspace.showResearch(); + await expect(researchPage.conversation).toContainText("Browser one conversation"); + await expect(secondResearch.conversation).toContainText("Browser two conversation"); + await secondResearch.newChat.click(); await expect(secondPage).toHaveURL(/run=new/); await page.reload({ waitUntil: "domcontentloaded" }); - await showResearch(page); + await workspacePage.showResearch(); await expect(page).toHaveURL(new RegExp(`run=${first}`)); - await expect(page.getByRole("log", { name: "Research conversation" })).toContainText( - "Browser one conversation", - ); + await expect(researchPage.conversation).toContainText("Browser one conversation"); } finally { await other.close(); db.$client.prepare("DELETE FROM research_sessions WHERE id IN (?, ?)").run(first, second); diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts index ec889f4..054b2c5 100644 --- a/e2e/integrations/support.test.ts +++ b/e2e/integrations/support.test.ts @@ -6,102 +6,86 @@ const coffeeSubject = "Catch up over coffee"; test("reviews an edited reply and keeps the simulated result in history", async ({ page, a11y, + messagesPage, }) => { - await page.goto("/support"); + await messagesPage.goTo(); await page.waitForLoadState("networkidle"); - const rows = page.locator("[data-testid='support-row']"); + const rows = messagesPage.rows; await expect(rows).toHaveCount(3); - await rows.filter({ hasText: reviewSubject }).click(); - await page.locator("[data-testid='conversation-suggestions'] summary:visible").click(); - await page.getByRole("button", { name: /^Polite/ }).click(); - const reply = page.getByRole("textbox", { name: "Reply", exact: true }); + await messagesPage.conversation(reviewSubject).click(); + await messagesPage.chooseReplySuggestion(/^Polite/); + const reply = messagesPage.reply; await expect(reply).toHaveValue(/Thank you for sharing/); const edited = "I reviewed the materials. Let’s discuss the first-time experience."; await reply.fill(edited); - await page.locator("[data-testid='conversation-scheduling'] summary:visible").click(); const slot = "Mon, Oct 5, 14:00–14:30"; - await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot); - await page.getByRole("button", { name: "Add to reply" }).click(); + await messagesPage.addSuggestedTime(slot); await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`); - await page.getByRole("button", { name: "Review reply" }).click(); - const dialog = page.getByRole("dialog", { name: "Review reply" }); + await messagesPage.reviewReply.click(); + const dialog = messagesPage.reviewDialog; await expect(dialog).toContainText(edited); await expect(dialog).toContainText(slot); await expect(dialog).toContainText("No message will be sent"); expect((await a11y().analyze()).violations).toEqual([]); - await dialog.getByRole("button", { name: "Back", exact: true }).click(); + await messagesPage.editReply.click(); await expect(reply).toHaveValue(new RegExp(edited)); - await page.getByRole("button", { name: "Review reply" }).click(); - await dialog.getByRole("button", { name: "Try sending" }).click(); + await messagesPage.reviewReply.click(); + await messagesPage.trySending.click(); await expect(dialog).toHaveCount(0); - await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0); - await page.getByRole("button", { name: "Archive", exact: true }).click(); - await rows.filter({ hasText: reviewSubject }).click(); - await expect(page.locator("[data-testid='support-outcome']")).toContainText(edited); - await expect(page.locator("[data-testid='support-outcome']")).toContainText(slot); + await expect(messagesPage.conversation(reviewSubject)).toHaveCount(0); + await messagesPage.selectView("Archive"); + await messagesPage.conversation(reviewSubject).click(); + await expect(messagesPage.outcome).toContainText(edited); + await expect(messagesPage.outcome).toContainText(slot); }); -test("defers a conversation and restores it to the current queue", async ({ page }) => { - await page.goto("/support"); +test("defers a conversation and restores it to the current queue", async ({ + page, + messagesPage, +}) => { + await messagesPage.goTo(); await page.waitForLoadState("networkidle"); - const rows = page.locator("[data-testid='support-row']"); - await rows.filter({ hasText: coffeeSubject }).click(); - await expect(rows.filter({ hasText: coffeeSubject })).toHaveAttribute("aria-current", "true"); - await page - .getByRole("textbox", { name: "Reply", exact: true }) - .fill("土曜の予定を確認してから返事するね。"); - await page.getByRole("button", { name: "Leave for later" }).click(); - await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0); - await page.getByRole("button", { name: "Set aside", exact: true }).click(); - await rows.filter({ hasText: coffeeSubject }).click(); - await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue( - "土曜の予定を確認してから返事するね。", - ); - await page.getByRole("button", { name: "Move to Inbox" }).click(); - const back = page.getByRole("button", { name: "Back to messages" }); - if (await back.isVisible()) await back.click(); - await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible(); - await expect(page.getByRole("button", { name: "Inbox", exact: true })).toHaveAttribute( - "aria-pressed", - "true", - ); + await messagesPage.conversation(coffeeSubject).click(); + await expect(messagesPage.conversation(coffeeSubject)).toHaveAttribute("aria-current", "true"); + await messagesPage.reply.fill("土曜の予定を確認してから返事するね。"); + await messagesPage.leaveForLater.click(); + await expect(messagesPage.conversation(coffeeSubject)).toHaveCount(0); + await messagesPage.selectView("Set aside"); + await messagesPage.conversation(coffeeSubject).click(); + await expect(messagesPage.reply).toHaveValue("土曜の予定を確認してから返事するね。"); + await messagesPage.moveToInbox.click(); + await messagesPage.showConversationList(); + await expect(messagesPage.conversation(coffeeSubject)).toBeVisible(); + await expect(messagesPage.inbox).toHaveAttribute("aria-pressed", "true"); }); test("keeps corrections as new records and summarizes only selected current facts", async ({ page, a11y, + journalPage, }) => { - await page.goto("/journal"); + await journalPage.goTo(); await page.waitForLoadState("networkidle"); - await expect(page.getByRole("button", { name: "Save entry", exact: true })).toBeDisabled(); - await page.getByRole("textbox", { name: "Entry text" }).fill("次はレビュー資料の導線を確認する"); - await page.getByRole("button", { name: "Save entry", exact: true }).click(); - await expect(page.getByRole("article", { name: "Note entry" })).toContainText( - "Occurred: Unknown", - ); - await page.getByRole("button", { name: "Weight", exact: true }).click(); - await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); - await page.getByRole("textbox", { name: "Source" }).fill("自宅の体重計"); - await page.getByRole("button", { name: "Save entry", exact: true }).click(); - await page - .getByRole("article", { name: "Weight entry" }) - .getByRole("button", { name: "Correct" }) - .click(); - const correction = page.getByRole("dialog", { name: "Correct entry" }); - await correction.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.8"); - await correction.getByRole("button", { name: "Add correction" }).click(); - const weights = page.getByRole("article", { name: "Weight entry" }); + await expect(journalPage.saveEntry).toBeDisabled(); + await journalPage.entryText.fill("次はレビュー資料の導線を確認する"); + await journalPage.saveEntry.click(); + await expect(journalPage.noteEntries).toContainText("Occurred: Unknown"); + await journalPage.weightTab.click(); + await journalPage.weight.fill("65.2"); + await journalPage.source.fill("自宅の体重計"); + await journalPage.saveEntry.click(); + await journalPage.correctWeight("65.8"); + const weights = journalPage.weightEntries; await expect(weights).toHaveCount(2); await expect(weights.filter({ hasText: "65.2 kg" })).toContainText("Original"); await expect(weights.filter({ hasText: "65.8 kg" })).toContainText("Correction"); - await page.getByRole("button", { name: "Summarize entries", exact: true }).click(); - const review = page.getByRole("dialog", { name: "Summarize entries" }); - const summary = review.getByRole("textbox", { name: "Entry summary" }); + await journalPage.summarizeEntries.click(); + const summary = journalPage.summary; await expect(summary).toHaveValue(/65.8 kg/); await expect(summary).not.toHaveValue(/65.2 kg/); await expect(summary).toHaveValue(/Source: 自宅の体重計/); await expect(summary).toHaveValue(/Entries with an unknown event time: 2 entries/); - await review.getByRole("checkbox", { name: /次はレビュー資料/ }).uncheck(); + await journalPage.excludeFromSummary(/次はレビュー資料/); await expect(summary).not.toHaveValue(/次はレビュー資料/); await expect(summary).toHaveValue( /Medication and weight are unknown for periods without entries/, @@ -112,22 +96,24 @@ test("keeps corrections as new records and summarizes only selected current fact test("fits communication and journal interactions in narrow screens", async ({ page, a11y, + messagesPage, + journalPage, + workspacePage, }, testInfo) => { for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); - await page.goto("/support"); + await messagesPage.goTo(); await page.waitForLoadState("networkidle"); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); - await page.locator("[data-testid='support-row']").first().click(); - await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await messagesPage.rows.first().click(); + await expect(messagesPage.reply).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); await page.screenshot({ path: testInfo.outputPath(`messages-${width}.png`) }); - const back = page.getByRole("button", { name: "Back to messages" }); - if (await back.isVisible()) await back.click(); - await page.getByRole("link", { name: "Journal", exact: true }).click(); - await page.getByRole("button", { name: "Weight", exact: true }).click(); - await expect(page.getByRole("spinbutton", { name: "Weight", exact: true })).toBeVisible(); + await messagesPage.showConversationList(); + await workspacePage.navigateTo("Journal"); + await journalPage.weightTab.click(); + await expect(journalPage.weight).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); } diff --git a/e2e/integrations/url-state.test.ts b/e2e/integrations/url-state.test.ts index 9455c69..897bdb3 100644 --- a/e2e/integrations/url-state.test.ts +++ b/e2e/integrations/url-state.test.ts @@ -1,96 +1,101 @@ import { expect, test } from "../fixtures"; -test("restores article selection with browser history and reload", async ({ page }) => { - await page.goto("/inbox", { waitUntil: "networkidle" }); - const rows = page.locator("[data-testid='inbox-article-row']"); - const heading = await rows.nth(1).locator("[data-testid='inbox-row-title']").innerText(); +test("restores article selection with browser history and reload", async ({ page, readerPage }) => { + await readerPage.goTo(); + const rows = readerPage.rows; + const heading = await readerPage.rowTitle(1).innerText(); await rows.nth(1).click(); - await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading); + await expect(readerPage.articleTitle).toHaveText(heading); const selectedUrl = page.url(); expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy(); await page.reload({ waitUntil: "networkidle" }); - await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? ""); + await expect(readerPage.articleTitle).toHaveText(heading ?? ""); await page.goBack(); - await expect(page.locator("[data-testid='inbox-workspace']")).toHaveAttribute( - "data-reading", - "false", - ); + await expect(readerPage.workspace).toHaveAttribute("data-reading", "false"); await page.goForward(); await expect(page).toHaveURL(selectedUrl); - await expect(page.locator("[data-testid='inbox-article-title']")).toHaveText(heading ?? ""); + await expect(readerPage.articleTitle).toHaveText(heading ?? ""); }); -test("restores search and replaces typing history", async ({ page }) => { - await page.goto("/", { waitUntil: "networkidle" }); - await page.getByRole("link", { name: "Reader", exact: true }).click(); - const search = page.getByRole("textbox", { name: "Search articles" }); +test("restores search and replaces typing history", async ({ + page, + readerPage, + homePage, + workspacePage, +}) => { + await homePage.goTo(); + await page.waitForLoadState("networkidle"); + await workspacePage.navigateTo("Reader"); + const search = readerPage.searchInput; await search.pressSequentially("browser"); await expect(search).toHaveValue("browser"); await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser"); - const count = await page.locator("[data-testid='inbox-article-row']").count(); + const count = await readerPage.rows.count(); await page.reload({ waitUntil: "networkidle" }); await expect(search).toHaveValue("browser"); - await expect(page.locator("[data-testid='inbox-article-row']")).toHaveCount(count); + await expect(readerPage.rows).toHaveCount(count); await page.goBack(); - await expect(page).toHaveURL("http://127.0.0.1:4173/"); + await expect(page).toHaveURL("/"); }); -test("restores account dialog without losing underlying article", async ({ page }) => { - await page.goto("/inbox", { waitUntil: "networkidle" }); - await page.getByRole("button", { name: "Manage connected accounts" }).click(); +test("restores account dialog without losing underlying article", async ({ + page, + readerPage, + connectedAccounts, +}) => { + await readerPage.goTo(); + await connectedAccounts.open(); await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true"); await page.reload({ waitUntil: "networkidle" }); - await expect(page.getByRole("dialog")).toBeVisible(); + await expect(connectedAccounts.dialog).toBeVisible(); await page.goBack(); - await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(connectedAccounts.dialog).toHaveCount(0); await page.goForward(); - await expect(page.getByRole("dialog")).toBeVisible(); + await expect(connectedAccounts.dialog).toBeVisible(); }); -test("restores a message view and conversation from its URL", async ({ page }) => { - await page.goto("/support", { waitUntil: "networkidle" }); - await page.getByRole("button", { name: "All", exact: true }).click(); - const row = page.locator("[data-testid='support-row']").nth(1); - const heading = await row.locator("span").first().innerText(); +test("restores a message view and conversation from its URL", async ({ page, messagesPage }) => { + await messagesPage.goTo(); + await page.waitForLoadState("networkidle"); + await messagesPage.selectView("All"); + const row = messagesPage.rows.nth(1); + const heading = await messagesPage.rowName(1).innerText(); await row.click(); - await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText(heading); + await expect(messagesPage.toolbarName).toHaveText(heading); const url = page.url(); expect(new URL(url).searchParams.get("view")).toBe("all"); expect(new URL(url).searchParams.get("conversation")).toBeTruthy(); await page.reload({ waitUntil: "networkidle" }); - await expect(page.locator("[data-testid='support-toolbar'] > span").first()).toHaveText( - heading ?? "", - ); - await expect(page.locator("[data-testid='support-workspace']")).toHaveAttribute( - "data-reading", - "true", - ); + await expect(messagesPage.toolbarName).toHaveText(heading ?? ""); + await expect(messagesPage.workspace).toHaveAttribute("data-reading", "true"); }); -test("keeps a deep link through login", async ({ page, context }) => { +test("keeps a deep link through login", async ({ page, context, readerPage, authPage }) => { await context.clearCookies(); - await page.goto("/inbox?q=browser", { waitUntil: "networkidle" }); + await readerPage.goTo("?q=browser"); await expect(page).toHaveURL(/\/login\?returnTo=/); - await page.getByLabel("Email address").fill("owner@workspace.invalid"); - await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026"); - await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await authPage.signIn("owner@workspace.invalid", "E2E-only-passphrase-2026"); await expect(page).toHaveURL(/\/inbox\?q=browser$/); - await expect(page.getByRole("textbox", { name: "Search articles" })).toHaveValue("browser"); + await expect(readerPage.searchInput).toHaveValue("browser"); }); -test("debounces search and cancels pending input on history navigation", async ({ page }) => { - await page.goto("/support", { waitUntil: "networkidle" }); - await page.getByRole("button", { name: "All", exact: true }).click(); +test("debounces search and cancels pending input on history navigation", async ({ + page, + messagesPage, +}) => { + await messagesPage.goTo(); + await page.waitForLoadState("networkidle"); + await messagesPage.selectView("All"); await expect(page).toHaveURL(/view=all/); await page.clock.install(); - const input = page.getByRole("textbox", { name: "Search messages" }); + const input = messagesPage.searchInput; await input.fill("not-committed"); await expect(input).toHaveValue("not-committed"); expect(new URL(page.url()).searchParams.has("q")).toBe(false); await page.clock.runFor(200); expect(new URL(page.url()).searchParams.has("q")).toBe(false); await page.goBack(); - await expect(page).toHaveURL("http://127.0.0.1:4173/support"); + await expect(page).toHaveURL("/support"); await page.clock.runFor(400); await expect(input).toHaveValue(""); expect(new URL(page.url()).searchParams.has("q")).toBe(false); @@ -99,10 +104,10 @@ test("debounces search and cancels pending input on history navigation", async ( await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("final search"); }); -test("waits for IME composition to finish before searching", async ({ page }) => { - await page.goto("/inbox", { waitUntil: "networkidle" }); +test("waits for IME composition to finish before searching", async ({ page, readerPage }) => { + await readerPage.goTo(); await page.clock.install(); - const input = page.getByRole("textbox", { name: "Search articles" }); + const input = readerPage.searchInput; await input.dispatchEvent("compositionstart"); await input.fill("browser"); await page.clock.runFor(1000); diff --git a/e2e/integrations/vitals.test.ts b/e2e/integrations/vitals.test.ts index 66ba00a..fea7257 100644 --- a/e2e/integrations/vitals.test.ts +++ b/e2e/integrations/vitals.test.ts @@ -1,127 +1,84 @@ import { expect, test } from "../fixtures"; -test("filters body systems and opens lab history", async ({ page, a11y }) => { - await page.goto("/vitals"); - await page.waitForLoadState("networkidle"); - await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible(); - const systems = page.locator("[data-testid='vital-system-card']"); - await expect(systems).toHaveCount(8); - await page - .getByRole("group", { name: "Body systems", exact: true }) - .getByRole("button", { name: "Cardiovascular", exact: true }) - .click(); - await expect(systems).toHaveCount(1); +test("filters body systems and opens lab history", async ({ page, vitalsPage, a11y }) => { + await vitalsPage.goTo(); + await expect(vitalsPage.bodyHeading).toBeVisible(); + await expect(vitalsPage.systems).toHaveCount(8); + await vitalsPage.selectSystem("Cardiovascular"); + await expect(vitalsPage.systems).toHaveCount(1); await expect(page).toHaveURL(/vitalTab=Cardiovascular/); await page.reload(); await page.waitForLoadState("networkidle"); - await expect(systems).toHaveCount(1); - await systems.first().click(); - const dialog = page.getByRole("dialog", { name: "Cardiovascular" }); + await expect(vitalsPage.systems).toHaveCount(1); + await vitalsPage.openFirstSystem(); + const dialog = vitalsPage.systemDialog("Cardiovascular"); await expect(dialog).toContainText("68"); await page.goBack(); await expect(dialog).not.toBeVisible(); await page.goForward(); await expect(dialog).toBeVisible(); - await dialog.getByRole("button", { name: "Close", exact: true }).last().click(); - await page.getByRole("button", { name: "Lab history", exact: true }).click(); - await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28"); - await expect(page.locator("[data-testid='vital-history']")).toContainText("93"); + await vitalsPage.closeSystem("Cardiovascular"); + await vitalsPage.showLabHistory(); + await expect(vitalsPage.history).toContainText("Jul 28"); + await expect(vitalsPage.history).toContainText("93"); await page.reload(); await page.waitForLoadState("networkidle"); - await expect(page.locator("[data-testid='vital-history']")).toContainText("Jul 28"); - await page.getByRole("button", { name: "Systems", exact: true }).click(); - await page - .getByRole("group", { name: "Body systems", exact: true }) - .getByRole("button", { name: "Overview", exact: true }) - .click(); - await expect(systems).toHaveCount(8); + await expect(vitalsPage.history).toContainText("Jul 28"); + await vitalsPage.showSystems(); + await vitalsPage.selectSystem("Overview"); + await expect(vitalsPage.systems).toHaveCount(8); expect((await a11y().analyze()).violations).toEqual([]); }); -test("explores environment charts and simulated climate controls", async ({ page, a11y }) => { - await page.goto("/vitals"); - await page.waitForLoadState("networkidle"); - const menu = page.getByRole("button", { name: "Open Health menu" }); - if (await menu.isVisible()) await menu.click(); - await page - .getByRole("complementary", { name: "Health navigation" }) - .getByRole("button", { name: "Environment", exact: true }) - .click(); - await expect(page.getByRole("heading", { name: "Environment", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "7D", exact: true }).click(); - await page - .getByRole("group", { name: "Trend metric" }) - .getByRole("button", { name: "CO₂", exact: true }) - .click(); - await expect( - page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }), - ).toBeVisible(); +test("explores environment charts and simulated climate controls", async ({ + page, + vitalsPage, + a11y, +}) => { + await vitalsPage.goTo(); + await vitalsPage.navigateTo("Environment"); + await expect(vitalsPage.environmentHeading).toBeVisible(); + await vitalsPage.selectTrendPeriod("7D"); + await vitalsPage.selectTrendMetric("CO₂"); + await expect(vitalsPage.trendChart("7D", "CO₂")).toBeVisible(); await page.reload(); await page.waitForLoadState("networkidle"); - await expect( - page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }), - ).toBeVisible(); - await page - .locator("[data-testid='vital-metric']") - .filter({ hasText: "Room temperature" }) - .click(); - const dialog = page.getByRole("dialog", { name: "Room temperature" }); + await expect(vitalsPage.trendChart("7D", "CO₂")).toBeVisible(); + await vitalsPage.openMetric("Room temperature"); + const dialog = vitalsPage.metricDialog("Room temperature"); await expect(dialog).toContainText("64.0"); await page.reload(); await page.waitForLoadState("networkidle"); await expect(dialog).toContainText("64.0"); - await dialog.getByRole("button", { name: "Back to overview" }).click(); - await page.getByRole("button", { name: "Sync now", exact: true }).click(); - await expect( - page.getByRole("status").filter({ hasText: "Sample readings refreshed" }), - ).toBeVisible(); + await vitalsPage.closeMetric("Room temperature"); + await vitalsPage.syncReadings(); + await expect(vitalsPage.refreshedStatus).toBeVisible(); expect((await a11y().analyze()).violations).toEqual([]); - await page.getByRole("button", { name: "Climate control", exact: true }).click(); - await page.getByRole("button", { name: "Cool", exact: true }).click(); - await expect(page.getByRole("button", { name: "Cool", exact: true })).toHaveAttribute( - "aria-pressed", - "true", - ); - const temperature = page.getByRole("slider", { name: "Target temperature" }); + await vitalsPage.openClimateControl(); + await vitalsPage.enableCooling(); + await expect(vitalsPage.coolButton).toHaveAttribute("aria-pressed", "true"); + const temperature = vitalsPage.targetTemperature; await temperature.focus(); await temperature.press("ArrowRight"); await expect(temperature).toHaveValue("69"); - await page.getByRole("button", { name: "Air purifier · On" }).click(); - await expect(page.getByRole("button", { name: "Air purifier · Off" })).toHaveAttribute( - "aria-pressed", - "false", - ); + await vitalsPage.turnOffPurifier(); + await expect(vitalsPage.purifierOffButton).toHaveAttribute("aria-pressed", "false"); await page.reload(); await page.waitForLoadState("networkidle"); await expect(temperature).toHaveValue("68"); - await expect(page.getByRole("button", { name: "Auto", exact: true })).toHaveAttribute( - "aria-pressed", - "true", - ); - await expect(page.getByRole("button", { name: "Air purifier · On" })).toHaveAttribute( - "aria-pressed", - "true", - ); + await expect(vitalsPage.autoButton).toHaveAttribute("aria-pressed", "true"); + await expect(vitalsPage.purifierOnButton).toHaveAttribute("aria-pressed", "true"); }); -test("keeps both dashboards within the viewport", async ({ page }, testInfo) => { - await page.goto("/vitals"); - await page.waitForLoadState("networkidle"); +test("keeps both dashboards within the viewport", async ({ page, vitalsPage }, testInfo) => { + await vitalsPage.goTo(); for (const width of [375, 768, 1440]) { await page.setViewportSize({ width, height: 1000 }); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) }); - const menu = page.getByRole("button", { name: "Open Health menu" }); - if (await menu.isVisible()) await menu.click(); - const navigation = page.getByRole("complementary", { - name: "Health navigation", - }); - await navigation.getByRole("button", { name: "Environment", exact: true }).click(); + await vitalsPage.navigateTo("Environment"); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); - await page.screenshot({ - path: testInfo.outputPath(`environment-${width}.png`), - }); - if (await menu.isVisible()) await menu.click(); - await navigation.getByRole("button", { name: "Body", exact: true }).click(); + await page.screenshot({ path: testInfo.outputPath(`environment-${width}.png`) }); + await vitalsPage.navigateTo("Body"); } }); diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index 1429b5e..8ffab84 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -1,10 +1,11 @@ import type { Page } from "@playwright/test"; import Database from "better-sqlite3"; import { expect, test } from "../fixtures"; +import { DeckPage } from "../models/DeckPage"; -type NativeTesting = { - listTools(): { name: string }[]; - executeTool(name: string, input: string): Promise; +type NativeModelContext = { + getTools(): Promise<{ name: string }[]>; + executeTool(tool: { name: string }, input: string): Promise; }; type ToolResult = { content: { type: string; text: string }[]; @@ -13,13 +14,12 @@ type ToolResult = { test.use({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, - args: ["--enable-blink-features=WebMCP,WebMCPTesting"], + args: ["--enable-blink-features=WebMCP"], }, }); async function toolNames(page: Page) { - return page.evaluate(() => - (navigator as Navigator & { modelContextTesting: NativeTesting }).modelContextTesting - .listTools() + return page.evaluate(async () => + (await (document as Document & { modelContext: NativeModelContext }).modelContext.getTools()) .map((tool) => tool.name) .sort(), ); @@ -30,10 +30,12 @@ async function executeTool( input: Record = {}, ): Promise { const result = await page.evaluate( - ({ name, input }) => - ( - navigator as Navigator & { modelContextTesting: NativeTesting } - ).modelContextTesting.executeTool(name, JSON.stringify(input)), + async ({ name, input }) => { + const model = (document as Document & { modelContext: NativeModelContext }).modelContext; + const tool = (await model.getTools()).find((candidate) => candidate.name === name); + if (!tool) throw new Error(`WebMCP tool not found: ${name}`); + return model.executeTool(tool, JSON.stringify(input)); + }, { name, input }, ); expect(result).not.toBeNull(); @@ -59,10 +61,10 @@ function savedCount() { } let accounts: Record; let pageErrors: string[]; -test.beforeEach(async ({ page }) => { +test.beforeEach(async ({ page, deckPage }) => { pageErrors = []; page.on("pageerror", (error) => pageErrors.push(error.message)); - await page.goto("/deck"); + await deckPage.goTo(); await expect .poll(() => toolNames(page)) .toEqual([ @@ -91,6 +93,7 @@ test("creates temporary research, edits it, persists explicitly and reopens it o page, browser, baseURL, + deckPage, }) => { const created = await executeTool(page, "set_deck", { title: "WebMCPの反応", @@ -103,19 +106,16 @@ test("creates temporary research, edits it, persists explicitly and reopens it o const deck = decoded(created).deck; expect(deck.persisted).toBe(false); expect(savedCount()).toEqual({ count: 0 }); - await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible(); - await page - .getByRole("region", { name: "日本語", exact: true }) - .getByRole("button", { name: "Actions for 日本語" }) - .click(); - await page.getByRole("menuitem", { name: "Edit", exact: true }).click(); + await expect(deckPage.postText("alternate · Latest · all page 1")).toBeVisible(); + await deckPage.openColumnActions("日本語"); + await deckPage.editColumn.click(); const replaced = await executeTool(page, "set_deck", { deckId: deck.id, title: "更新した調査", columns: [deck.columns[1]], }); expect(replaced.isError).not.toBe(true); - await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(deckPage.dialogs).toHaveCount(0); expect(savedCount()).toEqual({ count: 0 }); expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true); expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true); @@ -127,19 +127,21 @@ test("creates temporary research, edits it, persists explicitly and reopens it o }); try { const device = await other.newPage(); - await device.goto("/deck"); - await expect(device.getByRole("heading", { level: 1 })).toHaveText("更新した調査"); - await expect( - device.getByText("alternate · Latest · all page 1", { exact: true }), - ).toBeVisible(); + const deviceDeck = new DeckPage(device); + await deviceDeck.goTo(); + await expect(deviceDeck.heading).toHaveText("更新した調査"); + await expect(deviceDeck.postText("alternate · Latest · all page 1")).toBeVisible(); } finally { await other.close(); } await page.reload(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("更新した調査"); + await expect(deckPage.heading).toHaveText("更新した調査"); }); -test("requires revisions for saved mutations and rejects stale overwrites", async ({ page }) => { +test("requires revisions for saved mutations and rejects stale overwrites", async ({ + page, + deckPage, +}) => { const deck = decoded( await executeTool(page, "set_deck", { title: "保存済み", columns: [] }), ).deck; @@ -173,7 +175,7 @@ test("requires revisions for saved mutations and rejects stale overwrites", asyn }) ).isError, ).toBe(true); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("変更"); + await expect(deckPage.heading).toHaveText("変更"); expect( ( await executeTool(page, "delete_deck", { @@ -195,6 +197,7 @@ test("requires revisions for saved mutations and rejects stale overwrites", asyn test("keeps temporary views independent and loses only unsaved views on reload", async ({ page, + deckPage, }) => { const first = decoded( await executeTool(page, "set_deck", { title: "一時の調査A", columns: [] }), @@ -203,7 +206,7 @@ test("keeps temporary views independent and loses only unsaved views on reload", await executeTool(page, "set_deck", { title: "一時の調査B", columns: [] }), ).deck; await executeTool(page, "select_deck", { deckId: first.id }); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("一時の調査A"); + await expect(deckPage.heading).toHaveText("一時の調査A"); await executeTool(page, "delete_deck", { deckId: second.id }); expect( decoded(await executeTool(page, "list_decks")).decks.some( @@ -212,10 +215,10 @@ test("keeps temporary views independent and loses only unsaved views on reload", ).toBe(false); expect(savedCount()).toEqual({ count: 0 }); await page.reload(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("New research"); + await expect(deckPage.heading).toHaveText("New research"); }); -test("rejects invalid input without changing the active view", async ({ page }) => { +test("rejects invalid input without changing the active view", async ({ page, deckPage }) => { await executeTool(page, "set_deck", { title: "残す調査", columns: [column("残す観点", accounts.e2e ?? "")], @@ -234,13 +237,14 @@ test("rejects invalid input without changing the active view", async ({ page }) expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe( true, ); - await expect(page.locator("[data-testid='deck-column'] h2")).toHaveText(["残す観点"]); + await expect(deckPage.columnTitles).toHaveText(["残す観点"]); } expect(savedCount()).toEqual({ count: 0 }); }); test("keeps failed temporary saves visible and allows retry without duplicate decks", async ({ page, + deckPage, }) => { const deck = decoded( await executeTool(page, "set_deck", { title: "未保存の調査", columns: [] }), @@ -249,21 +253,24 @@ test("keeps failed temporary saves visible and allows retry without duplicate de route.request().method() === "POST" ? route.abort() : route.continue(), ); expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).toBe(true); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("未保存の調査"); + await expect(deckPage.heading).toHaveText("未保存の調査"); expect(decoded(await executeTool(page, "get_deck")).deck.persisted).toBe(false); await page.unroute("**/_serverFn/**"); expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true); expect(savedCount()).toEqual({ count: 1 }); }); -test("reads and pages bound accounts without mixing sibling columns", async ({ page }) => { +test("reads and pages bound accounts without mixing sibling columns", async ({ + page, + deckPage, +}) => { const deck = decoded( await executeTool(page, "set_deck", { title: "接続を比較", columns: [column("通常", accounts.e2e ?? ""), column("別の接続", accounts.alternate ?? "")], }), ).deck; - await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible(); + await expect(deckPage.postText("alternate · Latest · all page 1")).toBeVisible(); const id = deck.columns[1].id; const loaded = decoded(await executeTool(page, "get_column_posts", { columnId: id })); expect(loaded.column.connectionId).toBe(accounts.alternate); @@ -275,9 +282,7 @@ test("reads and pages bound accounts without mixing sibling columns", async ({ p expect(more.posts.map((post: { text: string }) => post.text)).toEqual([ "alternate · Latest · all page 2", ]); - await expect( - page.getByRole("region", { name: "通常", exact: true }).locator("article"), - ).toHaveCount(1); + await expect(deckPage.columnPosts("通常")).toHaveCount(1); await executeTool(page, "set_deck", { title: "別の一時ビュー", columns: [] }); expect((await executeTool(page, "get_column_posts", { columnId: id })).isError).toBe(true); }); diff --git a/e2e/integrations/workspace.test.ts b/e2e/integrations/workspace.test.ts index 01eda00..55c4d11 100644 --- a/e2e/integrations/workspace.test.ts +++ b/e2e/integrations/workspace.test.ts @@ -1,20 +1,19 @@ import { expect, test } from "../fixtures"; -import { closeMobileSidebar, showResearch } from "../workspace"; test("keeps the sidebar, composer and deck reachable at compact widths", async ({ page, + deckPage, + researchPage, + workspacePage, a11y, }, testInfo) => { - await page.goto("/deck"); + await deckPage.goTo(); for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); - await showResearch(page); - const chat = page.getByRole("complementary", { name: "Research chat" }); + await workspacePage.showResearch(); + const chat = researchPage.panel; await expect(chat).toBeVisible(); - const composer = chat.getByRole("textbox", { - name: "Message", - exact: true, - }); + const composer = researchPage.composer; await expect(composer).toBeVisible(); const bounds = await composer.boundingBox(); if (!bounds) throw new Error("Composer must have a visible bounding box"); @@ -26,10 +25,10 @@ test("keeps the sidebar, composer and deck reachable at compact widths", async ( path: testInfo.outputPath(`workspace-${width}.png`), }); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); - await closeMobileSidebar(page); + await workspacePage.closeMobileSidebar(); } - await page.getByRole("button", { name: "Toggle sidebar" }).click(); - await expect(page.getByRole("complementary", { name: "Research chat" })).toBeHidden(); - await page.getByRole("button", { name: "Research chat", exact: true }).click(); - await expect(page.getByRole("complementary", { name: "Research chat" })).toBeVisible(); + await workspacePage.toggleSidebar.click(); + await expect(researchPage.panel).toBeHidden(); + await workspacePage.researchChat.click(); + await expect(researchPage.panel).toBeVisible(); }); diff --git a/e2e/models/AuthPage.ts b/e2e/models/AuthPage.ts new file mode 100644 index 0000000..0fed464 --- /dev/null +++ b/e2e/models/AuthPage.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from "@playwright/test"; + +export class AuthPage { + readonly name: Locator; + readonly email: Locator; + readonly password: Locator; + readonly confirmPassword: Locator; + readonly setupCode: Locator; + readonly signInButton: Locator; + readonly createAccountButton: Locator; + readonly welcome: Locator; + readonly error: Locator; + + constructor(readonly page: Page) { + this.name = page.getByLabel("Your name", { exact: true }); + this.email = page.getByLabel("Email address"); + this.password = page.getByLabel("Password", { exact: true }); + this.confirmPassword = page.getByLabel("Confirm password", { exact: true }); + this.setupCode = page.getByLabel("Setup code"); + this.signInButton = page.getByRole("button", { name: "Sign in", exact: true }); + this.createAccountButton = page.getByRole("button", { name: "Create account", exact: true }); + this.welcome = page.getByRole("heading", { name: "Welcome back." }); + this.error = page.getByRole("alert"); + } + + async goTo(mode: "login" | "setup" = "login") { + await this.page.goto(`/${mode}`, { waitUntil: "networkidle" }); + } + + async signIn(email: string, password: string) { + await this.email.fill(email); + await this.password.fill(password); + await this.signInButton.click(); + } + + async createAccount(input: { name: string; email: string; password: string; setupCode: string }) { + await this.name.fill(input.name); + await this.email.fill(input.email); + await this.password.fill(input.password); + await this.confirmPassword.fill(input.password); + await this.setupCode.fill(input.setupCode); + await this.createAccountButton.click(); + } +} diff --git a/e2e/models/ConnectedAccountsDialog.ts b/e2e/models/ConnectedAccountsDialog.ts new file mode 100644 index 0000000..ef12ed7 --- /dev/null +++ b/e2e/models/ConnectedAccountsDialog.ts @@ -0,0 +1,26 @@ +import type { Locator, Page } from "@playwright/test"; +import { WorkspacePage } from "./WorkspacePage"; + +export class ConnectedAccountsDialog { + readonly dialog: Locator; + readonly unconfiguredMastodon: Locator; + readonly authorizeMastodon: Locator; + readonly signOut: Locator; + + constructor(readonly page: Page) { + this.dialog = page.getByRole("dialog", { name: "Connected accounts" }); + this.unconfiguredMastodon = this.dialog.getByText( + "No Mastodon servers have been configured yet.", + ); + this.authorizeMastodon = this.dialog.getByRole("button", { name: "Authorize with Mastodon" }); + this.signOut = this.dialog.getByRole("button", { name: "Sign out", exact: true }); + } + + async open() { + await new WorkspacePage(this.page).openConnectedAccounts(); + } + + async close() { + await this.page.keyboard.press("Escape"); + } +} diff --git a/e2e/models/DeckPage.ts b/e2e/models/DeckPage.ts new file mode 100644 index 0000000..f7be8a6 --- /dev/null +++ b/e2e/models/DeckPage.ts @@ -0,0 +1,140 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { WorkspacePage } from "./WorkspacePage"; + +export class DeckPage { + get dialogs() { + return this.page.getByRole("dialog"); + } + readonly saveAsDeck: Locator; + readonly savedNotice: Locator; + readonly rename: Locator; + readonly deckName: Locator; + readonly saveName: Locator; + readonly createDeck: Locator; + readonly newDeckName: Locator; + readonly confirmCreate: Locator; + readonly deckProfile: Locator; + readonly heading: Locator; + readonly deleteDeck: Locator; + readonly confirmDelete: Locator; + readonly undo: Locator; + readonly columnNavigation: Locator; + readonly addColumnButton: Locator; + readonly columnDialog: Locator; + readonly columnName: Locator; + readonly editorProfile: Locator; + readonly columnType: Locator; + readonly primaryList: Locator; + readonly alternateList: Locator; + readonly listId: Locator; + readonly addAndSearch: Locator; + readonly saveChanges: Locator; + readonly editColumn: Locator; + readonly alert: Locator; + readonly reloadLatest: Locator; + readonly importLegacy: Locator; + readonly createDialog: Locator; + readonly newDeckTitleInput: Locator; + readonly columns: Locator; + readonly profileOptions: Locator; + readonly columnTitles: Locator; + readonly board: Locator; + + constructor(readonly page: Page) { + this.saveAsDeck = this.page.getByRole("button", { name: "Save as deck", exact: true }); + this.savedNotice = this.page.getByText("Saved to server · Shared across devices"); + this.rename = this.page.getByRole("button", { name: "Rename" }); + this.deckName = this.page.getByLabel("Research topic / deck name"); + this.saveName = this.page.getByRole("button", { name: "Save name" }); + this.createDeck = this.page.getByRole("button", { name: "Create deck", exact: true }); + this.newDeckName = this.page.getByLabel("New deck name"); + this.confirmCreate = this.page.getByRole("button", { name: "Create", exact: true }); + this.deckProfile = this.page.getByLabel("Deck profile", { exact: true }); + this.profileOptions = this.deckProfile.locator("option"); + this.heading = this.page.getByRole("heading", { level: 1 }); + this.deleteDeck = this.page.getByRole("button", { name: "Delete deck", exact: true }); + this.confirmDelete = this.page.getByRole("button", { name: "Delete", exact: true }); + this.undo = this.page.getByRole("button", { name: "Undo" }); + this.columnNavigation = this.page.getByRole("navigation", { name: "Columns" }); + this.addColumnButton = this.page.getByRole("button", { name: "Add column", exact: true }); + this.columnDialog = this.page.getByRole("dialog", { name: "Add column", exact: true }); + this.columnName = this.page.getByLabel("Column name", { exact: true }); + this.editorProfile = this.page.getByRole("combobox", { + name: "Connection profile", + exact: true, + }); + this.columnType = this.page.getByRole("combobox", { name: "Column type", exact: true }); + this.primaryList = this.page.getByRole("button", { name: "My E2E List", exact: true }); + this.alternateList = this.page.getByRole("button", { name: "Alternate List", exact: true }); + this.listId = this.page.getByLabel("List", { exact: true }); + this.addAndSearch = this.page.getByRole("button", { name: "Add and search" }); + this.saveChanges = this.page.getByRole("button", { name: "Save changes" }); + this.editColumn = this.page.getByRole("menuitem", { name: "Edit", exact: true }); + this.alert = this.page.getByRole("alert"); + this.reloadLatest = this.page.getByRole("button", { name: "Reload latest deck" }); + this.importLegacy = this.page.getByRole("button", { name: "Import legacy decks" }); + this.createDialog = this.page.getByRole("dialog", { name: "Create deck", exact: true }); + this.newDeckTitleInput = this.page.locator("#new-deck-title"); + this.columns = this.page.locator("[data-testid='deck-column']"); + this.columnTitles = this.page.locator("[data-testid='deck-column'] h2"); + this.board = this.page.locator("[data-testid='deck-board']"); + } + + column(title: string) { + return this.page.getByRole("region", { name: title, exact: true }); + } + columnPosts(title: string) { + return this.column(title).locator("article"); + } + columnPostText(title: string, text: string, exact = false) { + return this.column(title).getByText(text, { exact }); + } + columnError(title: string) { + return this.column(title).getByRole("alert"); + } + async loadMore(title: string) { + await this.column(title).getByRole("button", { name: "Load more" }).click(); + } + async retryColumn(title: string) { + await this.column(title).getByRole("button", { name: "Retry" }).click(); + } + async openColumnActions(title: string) { + await this.column(title) + .getByRole("button", { name: `Actions for ${title}` }) + .click(); + } + columnHeading(title: string) { + return this.columns.getByRole("heading", { name: title, exact: true }); + } + columnAction(name: string) { + return this.page.getByRole("menuitem", { name, exact: true }); + } + post(url: string) { + return this.page.locator(`[data-testid="research-post"][data-post-url="${url}"]`); + } + postText(text: string) { + return this.page.getByText(text, { exact: true }); + } + async goTo(search = "") { + await this.page.goto(`/deck${search}`); + } + async addColumn(title: string, query: string, profile = "e2e", kind = "search") { + const workspace = new WorkspacePage(this.page); + await workspace.showDeckManagement(); + await this.addColumnButton.click(); + const editor = this.page.getByRole("form", { name: "Add column", exact: true }); + await editor.getByLabel("Column name", { exact: true }).fill(title); + await editor + .getByRole("combobox", { name: "Connection profile", exact: true }) + .selectOption({ label: profile }); + await editor.getByRole("combobox", { name: "Column type", exact: true }).selectOption(kind); + await editor + .getByLabel(kind === "search" ? "Twitter search query" : kind === "user" ? "User" : "List", { + exact: true, + }) + .fill(query); + await editor.getByRole("button", { name: "Add and search" }).click(); + await workspace.closeMobileSidebar(); + await expect(this.column(title).locator("article").first()).toBeVisible(); + } +} diff --git a/e2e/models/HomePage.ts b/e2e/models/HomePage.ts new file mode 100644 index 0000000..5e8815f --- /dev/null +++ b/e2e/models/HomePage.ts @@ -0,0 +1,67 @@ +import type { Locator, Page } from "@playwright/test"; + +export class HomePage { + readonly page: Page; + readonly input: Locator; + readonly previewAction: Locator; + readonly applyAction: Locator; + readonly morningRoutine: Locator; + readonly finishRoutine: Locator; + readonly recordWeight: Locator; + readonly weight: Locator; + readonly saveMeasurement: Locator; + readonly todayPlan: Locator; + readonly dailyBrief: Locator; + readonly flowTitles: Locator; + + constructor(page: Page) { + this.page = page; + this.input = page.getByRole("textbox", { name: "Home input" }); + this.previewAction = page.getByRole("button", { name: "Preview action" }); + this.applyAction = page.getByRole("button", { name: "Apply", exact: true }); + this.morningRoutine = page.getByRole("button", { name: /Morning routine 0 of 3/ }); + this.finishRoutine = page.getByRole("button", { name: "Finish routine" }); + this.recordWeight = page.getByRole("button", { name: /Record weight A quick check-in/ }); + this.weight = page.getByRole("spinbutton", { name: "Weight", exact: true }); + this.saveMeasurement = page.getByRole("button", { name: "Save measurement" }); + this.todayPlan = page.getByRole("region", { name: "Today’s plan" }); + this.dailyBrief = page.getByRole("region", { name: "Daily brief" }); + this.flowTitles = page.getByTestId("execution-flow-title"); + } + recordedWeight(weight: string) { + return this.page.getByRole("button", { name: `Record weight Recorded · ${weight} kg` }); + } + greeting(name: string) { + return this.page.getByRole("heading", { name: `Good morning, ${name}` }); + } + async goTo(search = "") { + await this.page.goto(`/${search}`); + } + routineStep(name: string | RegExp) { + return this.page.getByRole("checkbox", { name }); + } + completeTask(name: string) { + return this.page.getByRole("button", { name: `Complete ${name}`, exact: true }); + } + restoreTask(name: string) { + return this.page.getByRole("button", { name: `Restore ${name}`, exact: true }); + } + completedTask(name: string) { + return this.page.getByRole("button", { name: `${name} Completed`, exact: true }); + } + conversation(name: string) { + return this.page.getByRole("link", { name: `Open conversation with ${name}` }); + } + async openBriefConversation(name: string) { + await this.dailyBrief.getByRole("button", { name: `Reply to ${name}` }).click(); + } + async previewCommand(command: string) { + await this.input.fill(command); + await this.previewAction.click(); + } + async recordMeasurement(weight: string) { + await this.recordWeight.click(); + await this.weight.fill(weight); + await this.saveMeasurement.click(); + } +} diff --git a/e2e/models/JournalPage.ts b/e2e/models/JournalPage.ts new file mode 100644 index 0000000..43cfd24 --- /dev/null +++ b/e2e/models/JournalPage.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from "@playwright/test"; + +export class JournalPage { + readonly page: Page; + readonly saveEntry: Locator; + readonly entryText: Locator; + readonly noteEntries: Locator; + readonly weightEntries: Locator; + readonly weightTab: Locator; + readonly weight: Locator; + readonly source: Locator; + readonly correctionDialog: Locator; + readonly summarizeEntries: Locator; + readonly summaryDialog: Locator; + readonly summary: Locator; + + constructor(page: Page) { + this.page = page; + this.saveEntry = page.getByRole("button", { name: "Save entry", exact: true }); + this.entryText = page.getByRole("textbox", { name: "Entry text" }); + this.noteEntries = page.getByRole("article", { name: "Note entry" }); + this.weightEntries = page.getByRole("article", { name: "Weight entry" }); + this.weightTab = page.getByRole("button", { name: "Weight", exact: true }); + this.weight = page.getByRole("spinbutton", { name: "Weight", exact: true }); + this.source = page.getByRole("textbox", { name: "Source" }); + this.correctionDialog = page.getByRole("dialog", { name: "Correct entry" }); + this.summarizeEntries = page.getByRole("button", { name: "Summarize entries", exact: true }); + this.summaryDialog = page.getByRole("dialog", { name: "Summarize entries" }); + this.summary = this.summaryDialog.getByRole("textbox", { name: "Entry summary" }); + } + async goTo(search = "") { + await this.page.goto(`/journal${search}`); + } + async correctWeight(weight: string) { + await this.weightEntries.getByRole("button", { name: "Correct" }).click(); + await this.correctionDialog + .getByRole("spinbutton", { name: "Weight", exact: true }) + .fill(weight); + await this.correctionDialog.getByRole("button", { name: "Add correction" }).click(); + } + async excludeFromSummary(name: string | RegExp) { + await this.summaryDialog.getByRole("checkbox", { name }).uncheck(); + } +} diff --git a/e2e/models/MessagesPage.ts b/e2e/models/MessagesPage.ts new file mode 100644 index 0000000..c203c00 --- /dev/null +++ b/e2e/models/MessagesPage.ts @@ -0,0 +1,72 @@ +import type { Locator, Page } from "@playwright/test"; + +export class MessagesPage { + readonly page: Page; + readonly searchInput: Locator; + readonly toolbarName: Locator; + readonly workspace: Locator; + readonly rows: Locator; + readonly reply: Locator; + readonly suggestions: Locator; + readonly scheduling: Locator; + readonly suggestedTimes: Locator; + readonly reviewReply: Locator; + readonly reviewDialog: Locator; + readonly trySending: Locator; + readonly editReply: Locator; + readonly leaveForLater: Locator; + readonly moveToInbox: Locator; + readonly backToMessages: Locator; + readonly inbox: Locator; + readonly outcome: Locator; + readonly dialogs: Locator; + + constructor(page: Page) { + this.page = page; + this.searchInput = page.getByRole("textbox", { name: "Search messages" }); + this.toolbarName = page.getByTestId("support-toolbar").locator("strong"); + this.workspace = page.getByTestId("support-workspace"); + this.rows = page.getByTestId("support-row"); + this.reply = page.getByRole("textbox", { name: "Reply", exact: true }); + this.suggestions = page.locator("[data-testid='conversation-suggestions'] summary:visible"); + this.scheduling = page.locator("[data-testid='conversation-scheduling'] summary:visible"); + this.suggestedTimes = page.getByRole("combobox", { name: "Suggested times" }); + this.reviewReply = page.getByRole("button", { name: "Review reply", exact: true }); + this.reviewDialog = page.getByRole("dialog", { name: "Review reply", exact: true }); + this.trySending = this.reviewDialog.getByRole("button", { name: "Try sending" }); + this.editReply = this.reviewDialog.getByRole("button", { name: "Back", exact: true }); + this.leaveForLater = page.getByRole("button", { name: "Leave for later" }); + this.moveToInbox = page.getByRole("button", { name: "Move to Inbox" }); + this.backToMessages = page.getByRole("button", { name: "Back to messages" }); + this.inbox = page.getByRole("button", { name: "Inbox", exact: true }); + this.outcome = page.getByTestId("support-outcome"); + this.dialogs = page.getByRole("dialog"); + } + rowName(index: number) { + return this.rows.nth(index).locator("strong"); + } + filter(view: "Inbox" | "Archive" | "Set aside" | "All" | "Awaiting reply") { + return this.page.getByRole("button", { name: view, exact: true }); + } + async goTo(search = "") { + await this.page.goto(`/support${search}`); + } + conversation(subject: string) { + return this.rows.filter({ hasText: subject }); + } + async chooseReplySuggestion(tone: string | RegExp) { + await this.suggestions.click(); + await this.page.getByRole("button", { name: tone }).click(); + } + async addSuggestedTime(slot: string) { + await this.scheduling.click(); + await this.suggestedTimes.selectOption(slot); + await this.page.getByRole("button", { name: "Add to reply" }).click(); + } + async selectView(view: "Inbox" | "Archive" | "Set aside" | "All" | "Awaiting reply") { + await this.filter(view).click(); + } + async showConversationList() { + if (await this.backToMessages.isVisible()) await this.backToMessages.click(); + } +} diff --git a/e2e/models/OnboardingPage.ts b/e2e/models/OnboardingPage.ts new file mode 100644 index 0000000..fb22efa --- /dev/null +++ b/e2e/models/OnboardingPage.ts @@ -0,0 +1,23 @@ +import type { Locator, Page } from "@playwright/test"; + +export class OnboardingPage { + readonly name: Locator; + readonly continueButton: Locator; + readonly openWorkspace: Locator; + + constructor(readonly page: Page) { + this.name = page.getByLabel("What should we call you?"); + this.continueButton = page.getByRole("button", { name: "Continue", exact: true }); + this.openWorkspace = page.getByRole("button", { name: "Open workspace" }); + } + + async goTo() { + await this.page.goto("/onboarding", { waitUntil: "networkidle" }); + } + + async complete() { + await this.page.waitForLoadState("networkidle"); + await this.continueButton.click(); + await this.openWorkspace.click(); + } +} diff --git a/e2e/models/ReaderPage.ts b/e2e/models/ReaderPage.ts new file mode 100644 index 0000000..49334ae --- /dev/null +++ b/e2e/models/ReaderPage.ts @@ -0,0 +1,71 @@ +import type { Locator, Page } from "@playwright/test"; + +export class ReaderPage { + readonly rows: Locator; + readonly searchInput: Locator; + readonly articleTitle: Locator; + readonly workspace: Locator; + readonly emptyState: Locator; + readonly forYouLabel: Locator; + readonly mockLabel: Locator; + readonly articleExplanation: Locator; + readonly legacyFilters: Locator; + readonly editInterestsButton: Locator; + readonly backButton: Locator; + readonly clearSearchButton: Locator; + + constructor(readonly page: Page) { + this.rows = page.getByTestId("inbox-article-row"); + this.searchInput = page.getByRole("textbox", { name: "Search articles" }); + this.articleTitle = page.getByTestId("inbox-article-title"); + this.workspace = page.getByTestId("inbox-workspace"); + this.emptyState = page.getByText("No articles found", { exact: true }); + this.forYouLabel = page.getByText("FOR YOU", { exact: true }); + this.mockLabel = page.getByText("Mock", { exact: true }); + this.articleExplanation = page.getByText("Why this article", { exact: true }); + this.legacyFilters = page.getByRole("button", { name: /For you|Read later|Unread only/ }); + this.editInterestsButton = page.getByRole("button", { name: "Edit interests", exact: true }); + this.backButton = page.getByRole("button", { name: "Back to articles" }); + this.clearSearchButton = page.getByRole("button", { name: "Clear search" }); + } + + async goTo(search = "") { + await this.page.goto(`/inbox${search}`, { waitUntil: "networkidle" }); + } + + article(title: string) { + return this.page.getByRole("article", { name: title, exact: true }); + } + + articleHeading(title: string) { + return this.article(title).getByRole("heading", { name: title, exact: true }); + } + + articleText(title: string, text: string) { + return this.article(title).getByText(text); + } + + rowTitle(index: number) { + return this.rows.nth(index).getByTestId("inbox-row-title"); + } + + async selectArticle(title: string) { + await this.rows.filter({ hasText: title }).click(); + } + + async openFirstArticle() { + await this.rows.first().click(); + } + + async backToArticles() { + if (await this.backButton.isVisible()) await this.backButton.click(); + } + + async search(query: string) { + await this.searchInput.fill(query); + } + + async clearSearch() { + await this.clearSearchButton.click(); + } +} diff --git a/e2e/models/ResearchPage.ts b/e2e/models/ResearchPage.ts new file mode 100644 index 0000000..75da1eb --- /dev/null +++ b/e2e/models/ResearchPage.ts @@ -0,0 +1,45 @@ +import type { Locator, Page } from "@playwright/test"; + +export class ResearchPage { + readonly panel: Locator; + readonly conversation: Locator; + readonly composer: Locator; + readonly send: Locator; + readonly newChat: Locator; + readonly unconfiguredNotice: Locator; + readonly bubbles: Locator; + readonly messageLists: Locator; + readonly messageTables: Locator; + + constructor(readonly page: Page) { + this.panel = this.page.getByRole("complementary", { name: "Research chat" }); + this.conversation = this.page.getByRole("log", { name: "Research conversation" }); + this.composer = this.panel.getByRole("textbox", { name: "Message", exact: true }); + this.send = this.panel.getByRole("button", { name: "Send", exact: true }); + this.newChat = this.page.getByRole("button", { name: "New chat", exact: true }); + this.unconfiguredNotice = this.panel.getByText( + "The connection to Codex on your home server is not configured.", + ); + this.bubbles = this.conversation.locator('[data-slot="bubble-content"]'); + this.messageLists = this.conversation.locator("ul"); + this.messageTables = this.conversation.getByRole("table"); + } + async goTo(search = "", options?: Parameters[1]) { + await this.page.goto(`/deck${search}`, options); + } + accountPicker(count: number) { + return this.page.getByRole("button", { name: `Connected accounts · ${count}` }); + } + account(name: string) { + return this.page.getByRole("checkbox", { name, exact: true }); + } + messageHeading(name: string) { + return this.conversation.getByRole("heading", { name, exact: true }); + } + emphasis(text: string) { + return this.conversation.locator("strong").filter({ hasText: text }); + } + citation(name: string) { + return this.conversation.getByRole("link", { name, exact: true }); + } +} diff --git a/e2e/models/VitalsPage.ts b/e2e/models/VitalsPage.ts new file mode 100644 index 0000000..42d65ed --- /dev/null +++ b/e2e/models/VitalsPage.ts @@ -0,0 +1,115 @@ +import type { Locator, Page } from "@playwright/test"; + +export class VitalsPage { + readonly bodyHeading: Locator; + readonly environmentHeading: Locator; + readonly systems: Locator; + readonly history: Locator; + readonly healthMenuButton: Locator; + readonly healthNavigation: Locator; + readonly bodySystems: Locator; + readonly trendMetrics: Locator; + readonly refreshedStatus: Locator; + readonly targetTemperature: Locator; + readonly coolButton: Locator; + readonly autoButton: Locator; + readonly purifierOnButton: Locator; + readonly purifierOffButton: Locator; + + constructor(readonly page: Page) { + this.bodyHeading = page.getByRole("heading", { name: "Body", exact: true }); + this.environmentHeading = page.getByRole("heading", { name: "Environment", exact: true }); + this.systems = page.getByTestId("vital-system-card"); + this.history = page.getByTestId("vital-history"); + this.healthMenuButton = page.getByRole("button", { name: "Open Health menu" }); + this.healthNavigation = page.getByRole("complementary", { name: "Health navigation" }); + this.bodySystems = page.getByRole("group", { name: "Body systems", exact: true }); + this.trendMetrics = page.getByRole("group", { name: "Trend metric" }); + this.refreshedStatus = page + .getByRole("status") + .filter({ hasText: "Sample readings refreshed" }); + this.targetTemperature = page.getByRole("slider", { name: "Target temperature" }); + this.coolButton = page.getByRole("button", { name: "Cool", exact: true }); + this.autoButton = page.getByRole("button", { name: "Auto", exact: true }); + this.purifierOnButton = page.getByRole("button", { name: "Air purifier · On" }); + this.purifierOffButton = page.getByRole("button", { name: "Air purifier · Off" }); + } + + async goTo(search = "") { + await this.page.goto(`/vitals${search}`, { waitUntil: "networkidle" }); + } + + async navigateTo(section: "Body" | "Environment") { + if (await this.healthMenuButton.isVisible()) await this.healthMenuButton.click(); + await this.healthNavigation.getByRole("button", { name: section, exact: true }).click(); + } + + async selectSystem(name: string) { + await this.bodySystems.getByRole("button", { name, exact: true }).click(); + } + + async openFirstSystem() { + await this.systems.first().click(); + } + + systemDialog(name: string) { + return this.page.getByRole("dialog", { name }); + } + + async closeSystem(name: string) { + await this.systemDialog(name) + .getByRole("button", { name: "Close", exact: true }) + .last() + .click(); + } + + async showLabHistory() { + await this.page.getByRole("button", { name: "Lab history", exact: true }).click(); + } + + async showSystems() { + await this.page.getByRole("button", { name: "Systems", exact: true }).click(); + } + + async selectTrendPeriod(period: string) { + await this.page.getByRole("button", { name: period, exact: true }).click(); + } + + async selectTrendMetric(name: string) { + await this.trendMetrics.getByRole("button", { name, exact: true }).click(); + } + + trendChart(period: string, metric: string) { + return this.page.getByRole("img", { + name: `${period} ${metric} simulated environmental readings`, + }); + } + + async openMetric(name: string) { + await this.page.getByTestId("vital-metric").filter({ hasText: name }).click(); + } + + metricDialog(name: string) { + return this.page.getByRole("dialog", { name }); + } + + async closeMetric(name: string) { + await this.metricDialog(name).getByRole("button", { name: "Back to overview" }).click(); + } + + async syncReadings() { + await this.page.getByRole("button", { name: "Sync now", exact: true }).click(); + } + + async openClimateControl() { + await this.page.getByRole("button", { name: "Climate control", exact: true }).click(); + } + + async enableCooling() { + await this.coolButton.click(); + } + + async turnOffPurifier() { + await this.purifierOnButton.click(); + } +} diff --git a/e2e/models/WorkspacePage.ts b/e2e/models/WorkspacePage.ts new file mode 100644 index 0000000..5ede121 --- /dev/null +++ b/e2e/models/WorkspacePage.ts @@ -0,0 +1,61 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +type Destination = "Home" | "Vitals" | "Messages" | "Journal" | "Reader" | "Research"; + +export class WorkspacePage { + readonly manageAccounts: Locator; + readonly toggleSidebar: Locator; + readonly mobileSidebar: Locator; + readonly closeSidebar: Locator; + readonly deckManager: Locator; + readonly researchChat: Locator; + readonly researchReady: Locator; + + constructor(readonly page: Page) { + this.manageAccounts = page.getByRole("button", { name: "Manage connected accounts" }); + this.toggleSidebar = page.getByRole("button", { name: "Toggle sidebar" }); + this.mobileSidebar = page.locator('[data-mobile="true"][data-sidebar="sidebar"]'); + this.closeSidebar = page.getByRole("button", { name: "Close sidebar", exact: true }); + this.deckManager = page.getByRole("button", { name: "Deck manager", exact: true }); + this.researchChat = page.getByRole("button", { name: "Research chat", exact: true }); + this.researchReady = page.getByRole("button", { name: "Rename", includeHidden: true }); + } + + navigationLink(destination: Destination) { + return this.page.getByRole("link", { name: destination, exact: true }).first(); + } + + async navigateTo(destination: Destination) { + const link = this.navigationLink(destination); + if (!(await link.isVisible())) await this.ensureSidebarOpen(); + await link.click(); + } + + async ensureSidebarOpen() { + if (!(await this.manageAccounts.isVisible())) await this.toggleSidebar.click(); + } + + async showDeckManagement() { + await expect(this.researchReady).toBeEnabled(); + await this.ensureSidebarOpen(); + await this.deckManager.click(); + } + + async showResearch() { + await expect(this.researchReady).toBeEnabled(); + await this.ensureSidebarOpen(); + await this.researchChat.click(); + } + + async closeMobileSidebar() { + if (await this.mobileSidebar.isVisible()) { + await this.closeSidebar.click(); + await expect(this.mobileSidebar).toBeHidden(); + } + } + + async openConnectedAccounts() { + await this.ensureSidebarOpen(); + await this.manageAccounts.click(); + } +} diff --git a/e2e/workspace.ts b/e2e/workspace.ts deleted file mode 100644 index 14f400e..0000000 --- a/e2e/workspace.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, type Page } from "@playwright/test"; - -export async function showDeckManagement(page: Page) { - await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); - const navigation = page.getByRole("button", { - name: "Deck manager", - exact: true, - }); - if (!(await navigation.isVisible())) { - await page.getByRole("button", { name: "Toggle sidebar" }).click(); - } - await navigation.click(); -} - -export async function showResearch(page: Page) { - await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled(); - const navigation = page.getByRole("button", { - name: "Research chat", - exact: true, - }); - if (!(await navigation.isVisible())) { - await page.getByRole("button", { name: "Toggle sidebar" }).click(); - } - await navigation.click(); -} - -export async function closeMobileSidebar(page: Page) { - const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]'); - if (await sheet.isVisible()) { - await page.getByRole("button", { name: "Close sidebar", exact: true }).click(); - await expect(sheet).toBeHidden(); - } -}