From 54beef4c3e59f7088f8505f06f5d9b84a8148187 Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:12:42 +0200 Subject: [PATCH] add more e2e scenarios --- e2e/helpers/auth.ts | 63 +++- e2e/helpers/comments.ts | 48 ++++ e2e/helpers/inbox.ts | 17 ++ e2e/helpers/mailpit.ts | 23 ++ e2e/helpers/review.ts | 125 ++++++++ e2e/helpers/search.ts | 44 +++ e2e/helpers/settings.ts | 21 ++ e2e/tests/comments.test.ts | 124 ++++++++ e2e/tests/editor.test.ts | 95 +++++++ e2e/tests/invitation.test.ts | 27 ++ e2e/tests/login.test.ts | 75 +++++ e2e/tests/logout.test.ts | 25 ++ e2e/tests/password-reset.test.ts | 44 +++ e2e/tests/review.test.ts | 475 +++++++++++++++++++++++++++++++ e2e/tests/search.test.ts | 75 +++++ e2e/tests/settings.test.ts | 125 ++++++++ e2e/tests/signup.test.ts | 47 ++- 17 files changed, 1450 insertions(+), 3 deletions(-) create mode 100644 e2e/helpers/comments.ts create mode 100644 e2e/helpers/inbox.ts create mode 100644 e2e/helpers/review.ts create mode 100644 e2e/helpers/search.ts create mode 100644 e2e/helpers/settings.ts create mode 100644 e2e/tests/comments.test.ts create mode 100644 e2e/tests/invitation.test.ts create mode 100644 e2e/tests/logout.test.ts create mode 100644 e2e/tests/password-reset.test.ts create mode 100644 e2e/tests/review.test.ts create mode 100644 e2e/tests/search.test.ts create mode 100644 e2e/tests/settings.test.ts diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 3ecf103c..825d70d0 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -6,7 +6,7 @@ import { type Page, } from "@playwright/test" import { BASE_URL } from "./config" -import { fetchVerificationLink } from "./mailpit" +import { fetchPasswordResetLink, fetchVerificationLink } from "./mailpit" import { t } from "./i18n" import { visit } from "./page" @@ -102,6 +102,67 @@ export async function submitLoginForm( .click() } +// resetPassword walks the forgot-password flow end to end: it requests +// a reset link from the login page, follows the emailed link and +// chooses the new password. Asserted here rather than left to the +// caller: an expired or rejected token also lands back on the login +// page, and only the reset flag separates the two outcomes. +export async function resetPassword( + page: Page, + request: APIRequestContext, + email: string, + newPassword: string, +): Promise { + await visit(page, "/login") + + const emailField = page.getByPlaceholder( + t("onboarding.login.password-reset-form.email-placeholder"), + ) + await revealForm( + page.getByRole("button", { + name: t("onboarding.login.login-email-password"), + }), + page.getByPlaceholder( + t("onboarding.login.email-password-form.email-placeholder"), + ), + ) + await page + .getByRole("button", { + name: t("onboarding.login.forgot-password.placeholders.reset"), + }) + .click() + + await emailField.fill(email) + await page + .getByRole("button", { + name: t("onboarding.login.password-reset-form.continue"), + }) + .click() + await expect( + page.getByText(t("onboarding.login.password-reset-sent", { email })), + ).toBeVisible() + + await visit(page, await fetchPasswordResetLink(request, email)) + + // both placeholders contain the other's text, so they are matched + // exactly + await page + .getByPlaceholder(t("onboarding.reset-password.password-placeholder"), { + exact: true, + }) + .fill(newPassword) + await page + .getByPlaceholder(t("onboarding.reset-password.confirm-placeholder"), { + exact: true, + }) + .fill(newPassword) + await page + .getByRole("button", { name: t("onboarding.reset-password.continue") }) + .click() + + await expect(page).toHaveURL(`${BASE_URL}/login?reset=true`) +} + // signUpAndVerify creates an account that can log in. Verification is not // optional: the server refuses to sign in an unverified user, so this is // the setup any test needing an existing account starts from. diff --git a/e2e/helpers/comments.ts b/e2e/helpers/comments.ts new file mode 100644 index 00000000..9f5a67ec --- /dev/null +++ b/e2e/helpers/comments.ts @@ -0,0 +1,48 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import { contentEditor } from "./editor" +import { t } from "./i18n" + +// commentPopover is the floating comment-thread panel. It is rendered +// inside the editor container rather than teleported, and it carries no +// dialog role. +export function commentPopover(page: Page): Locator { + return page.locator(".content-editor div.z-popover") +} + +// commentComposer is the tiptap instance inside the popover that new +// comment text is typed into. Saved comment bodies are read-only tiptap +// instances in the same popover, so the editable one is the composer. +export function commentComposer(page: Page): Locator { + return commentPopover(page).locator('.ProseMirror[contenteditable="true"]') +} + +// addComment selects the whole page body and files a comment on it +// through the bubble menu, leaving the thread popover open. Asserts the +// comment was saved: the highlight mark only trades its pending id for +// a real one once the server has answered. +export async function addComment(page: Page, text: string): Promise { + await contentEditor(page).click() + await page.keyboard.press("ControlOrMeta+A") + + // the bubble menu mounts hidden and only gets data-visible once its + // positioning has settled, so the attribute gates the click + await page + .locator(".content-editor [data-visible]") + .getByRole("button", { name: t("editor.bubble-menu.comment-label") }) + .click() + + await commentComposer(page).click() + await page.keyboard.type(text) + await commentPopover(page) + .getByRole("button", { + name: t("editor.comment-thread.comment-button"), + exact: true, + }) + .click() + + await expect( + contentEditor(page).locator( + '[data-comment-id]:not([data-comment-id^="pending-"])', + ), + ).toBeVisible() +} diff --git a/e2e/helpers/inbox.ts b/e2e/helpers/inbox.ts new file mode 100644 index 00000000..4a7ac659 --- /dev/null +++ b/e2e/helpers/inbox.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from "@playwright/test" +import { t } from "./i18n" + +// openInbox opens the notification inbox from the sidebar. The inbox +// row is not a button — it is a plain sidebar row div — so it is +// reached through its sidebar slot. +export async function openInbox(page: Page): Promise { + await page + .locator('[data-sidebar="menu-button"]', { + hasText: t("sidebar.sections.top.inbox"), + }) + .click() + + await expect( + page.getByRole("button", { name: t("notification.read-all-button") }), + ).toBeVisible() +} diff --git a/e2e/helpers/mailpit.ts b/e2e/helpers/mailpit.ts index e9561ef2..b9dcbe80 100644 --- a/e2e/helpers/mailpit.ts +++ b/e2e/helpers/mailpit.ts @@ -7,6 +7,11 @@ const DELIVERY_TIMEOUT = 30_000 const VERIFICATION_LINK = /href="([^"]*\/api\/auth\/verify-email[^"]*)"/ const INVITE_LINK = /href="([^"]*\/accept-invite[^"]*)"/ +const PASSWORD_RESET_LINK = /href="([^"]*\/api\/auth\/reset-password[^"]*)"/ +// the account-exists notice links to the bare login page, which is what +// separates it from the verification email in the same inbox — that +// one's link only carries "/login" inside its callback parameter. +const ACCOUNT_EXISTS_LINK = /href="([^"]*\/login)"/ // fetchVerificationLink waits for the account-activation email and hands // back the link it carries. @@ -26,6 +31,24 @@ export function fetchInviteLink( return fetchLink(request, address, INVITE_LINK, "invitation") } +// fetchPasswordResetLink waits for the password reset email and hands +// back the link it carries. +export function fetchPasswordResetLink( + request: APIRequestContext, + address: string, +): Promise { + return fetchLink(request, address, PASSWORD_RESET_LINK, "password reset") +} + +// fetchAccountExistsLink waits for the notice a repeated signup sends to +// the address's real owner and hands back its login link. +export function fetchAccountExistsLink( + request: APIRequestContext, + address: string, +): Promise { + return fetchLink(request, address, ACCOUNT_EXISTS_LINK, "account exists") +} + // the inbox is searched for the message carrying the wanted link rather // than the newest one being taken: an address can receive more than one // message and nothing guarantees the order they land in. Every test diff --git a/e2e/helpers/review.ts b/e2e/helpers/review.ts new file mode 100644 index 00000000..67af35c0 --- /dev/null +++ b/e2e/helpers/review.ts @@ -0,0 +1,125 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import { contentEditor, waitForEditor } from "./editor" +import { t } from "./i18n" + +// the two branches a reviewable page carries. They are the branch names +// the product itself stores — core seeds "main" and the review workflow +// adds "draft". +type BranchName = "main" | "draft" + +// branchLabel is the label the navbar shows for a branch. Every branch +// name a test asserts comes through here, so the message key — which +// still spells the concept as a document mode — is written once. +export function branchLabel(branch: BranchName): string { + return t(`editor.navbar.document-modes.${branch}.title`) +} + +// branchSwitcher is the navbar dropdown trigger naming the open branch +// of a reviewable page. Non-reviewable pages render the read/edit toggle +// in its place, so its presence is what proves reviewability. +export function branchSwitcher(page: Page): Locator { + return page.getByRole("button", { + name: new RegExp(`^(${branchLabel("main")}|${branchLabel("draft")})$`), + }) +} + +// makeReviewable turns the open page into a reviewable one through the +// navbar options menu, which creates the draft branch and protects main. +// The page's current content must already be persisted if the draft is +// expected to start from it: the draft is seeded from the stored +// content, not the live document. +export async function makeReviewable(page: Page): Promise { + await page + .getByRole("button", { name: t("editor.navbar.open-document-options") }) + .click() + await page + .getByRole("menuitem", { + name: t("editor.navbar.document-options.review-workflow.enable-title"), + }) + .click() + + await expect(branchSwitcher(page)).toContainText(branchLabel("main")) +} + +// switchToBranch opens the given branch of a reviewable page through the +// navbar dropdown and waits for its editor to load. For the draft it +// also waits for the editor to become editable: the flip arrives with +// the branch data a beat after the switch, and keystrokes sent before it +// land in a read-only editor and are dropped. +export async function switchToBranch( + page: Page, + branch: BranchName, +): Promise { + await branchSwitcher(page).click() + await page.getByRole("menuitem", { name: branchLabel(branch) }).click() + + await expect(branchSwitcher(page)).toContainText(branchLabel(branch)) + await waitForEditor(page) + + if (branch === "draft") { + await expect(contentEditor(page)).toHaveAttribute("contenteditable", "true") + } +} + +// reviewActions is the split button carrying the draft's approve and +// merge actions. It only renders while the draft branch is open. +export function reviewActions(page: Page): Locator { + return page.locator('[data-slot="button-group"]') +} + +// mergeDraftIntoMain merges the open draft into main and waits for the +// automatic switch back to the main branch. The merge action first has +// to be picked in the split button's dropdown half, which has no label +// of its own and is reached as the group's menu-opening button. The +// draft's latest changes must already be persisted: the merge reads the +// stored draft content. +export async function mergeDraftIntoMain(page: Page): Promise { + const title = t("editor.name-editor.review-workflow.merge.title") + + await reviewActions(page).locator('button[aria-haspopup="menu"]').click() + await page.getByRole("menuitem", { name: title }).click() + await reviewActions(page) + .getByRole("button", { name: title, exact: true }) + .click() + + await expect( + page.getByText(t("editor.name-editor.review-workflow.merge.success")), + ).toBeVisible() + await expect(branchSwitcher(page)).toContainText(branchLabel("main")) + await waitForEditor(page) +} + +// openReviewerPopover opens the reviewers popover from its icon stack +// next to the page title and returns the popover content, which is +// teleported out of the page's main tree. +export async function openReviewerPopover(page: Page): Promise { + await page + .getByText(t("editor.name-editor.reviewers"), { exact: true }) + .click() + + const popover = page.locator('[data-slot="popover-content"]') + await expect(popover).toBeVisible() + + return popover +} + +// inviteReviewer requests a review from the given workspace member +// through the reviewers popover, and leaves the popover open. Asserts +// the request was accepted: the success toast only shows once the +// server has answered. +export async function inviteReviewer(page: Page, email: string): Promise { + const popover = await openReviewerPopover(page) + + await popover + .getByRole("button", { + name: t("editor.name-editor.reviewer-popover.invite-button"), + }) + .click() + await page.getByRole("menuitem", { name: email }).click() + + await expect( + page.getByText( + t("editor.name-editor.reviewer-popover.request-review-success.title"), + ), + ).toBeVisible() +} diff --git a/e2e/helpers/search.ts b/e2e/helpers/search.ts new file mode 100644 index 00000000..a12c8fdd --- /dev/null +++ b/e2e/helpers/search.ts @@ -0,0 +1,44 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import { t } from "./i18n" + +// openSearch opens the search dialog from the sidebar and returns the +// dialog. The search row is not a button — it is a plain sidebar row +// div — so it is reached through its sidebar slot. +export async function openSearch(page: Page): Promise { + await page + .locator('[data-sidebar="menu-button"]', { + hasText: t("sidebar.sections.top.search-button"), + }) + .click() + + const dialog = page.getByRole("dialog") + await expect( + dialog.getByPlaceholder(t("sidebar.search.input-placeholder")), + ).toBeVisible() + + return dialog +} + +// searchFor types the query into the open search dialog and waits for +// the expected result row. The results only refresh when the debounced +// query text changes, and a quick clear-and-retype of the same text is +// swallowed by the debounce as no change at all — so a search that +// raced the background indexing of freshly stored content would stay +// empty forever. Each retry therefore alternates between the query and +// the query minus its last character: both prefix-match the same +// results, and every attempt is a real change that triggers a fetch. +export async function searchFor( + dialog: Locator, + query: string, + expected: Locator, +): Promise { + const input = dialog.getByPlaceholder(t("sidebar.search.input-placeholder")) + let flip = false + + await expect(async () => { + flip = !flip + + await input.fill(flip ? query : query.slice(0, -1)) + await expect(expected).toBeVisible({ timeout: 2_000 }) + }).toPass() +} diff --git a/e2e/helpers/settings.ts b/e2e/helpers/settings.ts new file mode 100644 index 00000000..2c63998f --- /dev/null +++ b/e2e/helpers/settings.ts @@ -0,0 +1,21 @@ +import { expect, type Locator, type Page } from "@playwright/test" +import { t } from "./i18n" + +// openSettings opens the settings dialog from the workspace menu in the +// sidebar header and returns the dialog. +export async function openSettings( + page: Page, + workspaceName: string, +): Promise { + await page.getByRole("button", { name: workspaceName }).click() + await page + .getByRole("menuitem", { name: t("sidebar.header.settings") }) + .click() + + const dialog = page.getByRole("dialog") + await expect( + dialog.getByRole("heading", { name: t("settings.workspace.title") }), + ).toBeVisible() + + return dialog +} diff --git a/e2e/tests/comments.test.ts b/e2e/tests/comments.test.ts new file mode 100644 index 00000000..91a1fe8c --- /dev/null +++ b/e2e/tests/comments.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from "@playwright/test" +import { joinAsSecondUser } from "../helpers/collaboration" +import { + addComment, + commentComposer, + commentPopover, +} from "../helpers/comments" +import { + contentEditor, + createDocument, + documentPersisted, + waitForEditor, +} from "../helpers/editor" +import { t } from "../helpers/i18n" +import { visit } from "../helpers/page" +import { signUpWithWorkspace } from "../helpers/workspace" + +test.describe("comments", () => { + test("adds a comment to selected text", async ({ page, request }) => { + const { credentials } = await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Needs a second pair of eyes") + + await addComment(page, "Please rephrase this") + + await expect( + commentPopover(page).getByText("Please rephrase this"), + ).toBeVisible() + await expect( + commentPopover(page).getByText(credentials.email.split("@")[0] ?? ""), + ).toBeVisible() + await expect(contentEditor(page).locator(".comment-mark")).toHaveText( + "Needs a second pair of eyes", + ) + }) + + test("keeps a comment across a reload", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const url = page.url() + await contentEditor(page).click() + await page.keyboard.type("Commented text") + await addComment(page, "Still here after a reload") + await documentPersisted(page) + + await visit(page, url) + + await waitForEditor(page) + await contentEditor(page).locator(".comment-mark").click() + await expect( + commentPopover(page).getByText("Still here after a reload"), + ).toBeVisible() + }) + + test("shows a comment to a collaborator", async ({ + page, + request, + browser, + }) => { + // two signups, a verification each, an invitation and its + // acceptance run before the first assertion + test.slow() + + const { credentials } = await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Shared text for feedback") + const other = await joinAsSecondUser(browser, page, request) + + await addComment(page, "Feedback for the team") + + await contentEditor(other.page).locator(".comment-mark").click() + await expect( + commentPopover(other.page).getByText("Feedback for the team"), + ).toBeVisible() + await expect( + commentPopover(other.page).getByText( + credentials.email.split("@")[0] ?? "", + ), + ).toBeVisible() + + await other.context.close() + }) + + test("replies to a comment", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Discussion starter") + await addComment(page, "Opening take") + + await commentComposer(page).click() + await page.keyboard.type("Seconded") + await commentPopover(page) + .getByRole("button", { + name: t("editor.comment-thread.reply-button"), + exact: true, + }) + .click() + + await expect(commentPopover(page).getByText("Seconded")).toBeVisible() + }) + + test("removes the highlight when a comment is resolved", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Soon to be settled") + await addComment(page, "Handled already") + + await commentPopover(page) + .getByRole("button", { + name: t("editor.comment-thread.resolve-button"), + exact: true, + }) + .click() + + await expect(contentEditor(page).locator(".comment-mark")).toHaveCount(0) + }) +}) diff --git a/e2e/tests/editor.test.ts b/e2e/tests/editor.test.ts index 31cb145e..2de82804 100644 --- a/e2e/tests/editor.test.ts +++ b/e2e/tests/editor.test.ts @@ -171,6 +171,101 @@ test.describe("editor", () => { await expect(page).toHaveURL(/Welcome-to-Oxynote-[a-z0-9]{20}$/) }) + test("keeps the title across a reload", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + + await titleEditor(page).click() + await page.keyboard.press("ControlOrMeta+A") + await page.keyboard.type("Sticky Title") + await expect(page).toHaveURL(/Sticky-Title-[a-z0-9]{20}$/) + const url = page.url() + await documentPersisted(page) + + await visit(page, url) + + await waitForEditor(page) + await expect.poll(() => editorText(titleEditor(page))).toBe("Sticky Title") + }) + + test("keeps a code block across a reload", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const url = page.url() + + await contentEditor(page).click() + const menu = await openSlashMenu(page) + await page.keyboard.type("Code") + await expect(menu.getByRole("button")).toHaveCount(1) + await page.keyboard.press("Enter") + await page.keyboard.type("const persisted = true") + await documentPersisted(page) + + await visit(page, url) + + await waitForEditor(page) + await expect(contentEditor(page).locator("pre code")).toContainText( + "const persisted = true", + ) + }) + + test("duplicates a page with its content", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const url = page.url() + await contentEditor(page).click() + await page.keyboard.type("Copy me over") + // the duplicate is built from the stored content, so the text has + // to reach the server first + await documentPersisted(page) + + await page + .getByRole("button", { name: t("editor.navbar.open-document-options") }) + .click() + await page + .getByRole("menuitem", { + name: t("editor.navbar.document-options.duplicate.title"), + }) + .click() + + // the copy's name is the original's plus a timestamp, and the app + // navigates straight to it + await expect(page).not.toHaveURL(url) + await waitForEditor(page) + await expect + .poll(() => editorText(titleEditor(page))) + .toMatch(/^New Page \(/) + await expect + .poll(() => editorText(contentEditor(page))) + .toBe("Copy me over") + }) + + test("navigates between pages from the sidebar", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await titleEditor(page).click() + await page.keyboard.press("ControlOrMeta+A") + await page.keyboard.type("Second Page") + await expect(page).toHaveURL(/Second-Page-[a-z0-9]{20}$/) + + await sidebarDocument(page, "Welcome to Oxynote!").click() + + await expect(page).toHaveURL(/Welcome-to-Oxynote-[a-z0-9]{20}$/) + await waitForEditor(page) + await expect + .poll(() => editorText(titleEditor(page))) + .toBe("Welcome to Oxynote!") + + await sidebarDocument(page, "Second Page").click() + + await expect(page).toHaveURL(/Second-Page-[a-z0-9]{20}$/) + await waitForEditor(page) + await expect.poll(() => editorText(titleEditor(page))).toBe("Second Page") + }) + test("nests a sub page under its parent", async ({ page, request }) => { await signUpWithWorkspace(page, request) await createDocument(page) diff --git a/e2e/tests/invitation.test.ts b/e2e/tests/invitation.test.ts new file mode 100644 index 00000000..6ec41093 --- /dev/null +++ b/e2e/tests/invitation.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test" +import { joinAsSecondUser } from "../helpers/collaboration" +import { sidebarDocument } from "../helpers/editor" +import { signUpWithWorkspace } from "../helpers/workspace" + +test.describe("invitation", () => { + test("takes an invited teammate into the shared workspace", async ({ + page, + request, + browser, + }) => { + // two signups, a verification each, an invitation and its + // acceptance run before the first assertion + test.slow() + + const { workspace } = await signUpWithWorkspace(page, request) + + const other = await joinAsSecondUser(browser, page, request) + + await expect(other.page).toHaveURL(new RegExp(`/${workspace.slug}/`)) + await expect( + sidebarDocument(other.page, "Welcome to Oxynote!"), + ).toBeVisible() + + await other.context.close() + }) +}) diff --git a/e2e/tests/login.test.ts b/e2e/tests/login.test.ts index a0182a90..159d2771 100644 --- a/e2e/tests/login.test.ts +++ b/e2e/tests/login.test.ts @@ -3,8 +3,12 @@ import { newCredentials, signUpAndVerify, submitLoginForm, + submitSignupForm, } from "../helpers/auth" +import { waitForEditor } from "../helpers/editor" import { t } from "../helpers/i18n" +import { visit } from "../helpers/page" +import { signUpWithWorkspace } from "../helpers/workspace" test.describe("login", () => { test("takes a verified user to workspace creation", async ({ @@ -30,4 +34,75 @@ test.describe("login", () => { ).toBeVisible() await expect(page).toHaveURL(/\/login$/) }) + + test("rejects the wrong password for an existing account", async ({ + page, + request, + }) => { + const credentials = await signUpAndVerify(page, request) + + await submitLoginForm(page, { + email: credentials.email, + password: "e2e-Wr0ng!-password-x", + }) + + await expect( + page.getByText(t("onboarding.login.errors.invalid-credentials")), + ).toBeVisible() + await expect(page).toHaveURL(/\/login$/) + }) + + test("sends an unverified user back to email verification", async ({ + page, + }) => { + const credentials = newCredentials() + await submitSignupForm(page, credentials) + await expect(page).toHaveURL(/\/verify-email\?/, { timeout: 15_000 }) + + await submitLoginForm(page, credentials) + + // the server refuses the sign-in and re-sends the verification + // link, so the check-your-inbox page is accurate again + await expect(page).toHaveURL(/\/verify-email\?/, { timeout: 15_000 }) + await expect( + page.getByText( + t("onboarding.verify-email.sent-title", { + email: credentials.email, + }), + ), + ).toBeVisible() + }) + + test("keeps the user signed in across a reload", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + + await visit(page, "/") + + // the root path routes a signed-in user to their first document, + // which only works while the session still holds + await expect(page).toHaveURL(/Welcome-to-Oxynote-[a-z0-9]{20}$/, { + timeout: 15_000, + }) + await waitForEditor(page) + }) + + test("asks a signed-out visitor of a page to log in", async ({ + page, + request, + browser, + }) => { + await signUpWithWorkspace(page, request) + + const context = await browser.newContext() + const visitor = await context.newPage() + await visitor.goto(page.url()) + + await expect(visitor).toHaveURL(/\/login/, { timeout: 15_000 }) + await expect(visitor.getByText(t("onboarding.login.title"))).toBeVisible() + + await context.close() + }) }) diff --git a/e2e/tests/logout.test.ts b/e2e/tests/logout.test.ts new file mode 100644 index 00000000..0b076c68 --- /dev/null +++ b/e2e/tests/logout.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test" +import { t } from "../helpers/i18n" +import { signUpWithWorkspace } from "../helpers/workspace" + +test.describe("logout", () => { + test("returns to the login page and locks the workspace", async ({ + page, + request, + }) => { + const { workspace } = await signUpWithWorkspace(page, request) + const url = page.url() + + await page.getByRole("button", { name: workspace.name }).click() + await page + .getByRole("menuitem", { name: t("sidebar.header.log-out") }) + .click() + + await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 }) + + // the session is gone server-side, so the old page URL no longer + // opens + await page.goto(url) + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }) + }) +}) diff --git a/e2e/tests/password-reset.test.ts b/e2e/tests/password-reset.test.ts new file mode 100644 index 00000000..fd22c9de --- /dev/null +++ b/e2e/tests/password-reset.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test" +import { + resetPassword, + signUpAndVerify, + submitLoginForm, +} from "../helpers/auth" +import { t } from "../helpers/i18n" + +test.describe("password reset", () => { + test("lets the user in with the new password", async ({ page, request }) => { + const credentials = await signUpAndVerify(page, request) + const newPassword = "e2e-Rebooted-Passw0rd!" + + await resetPassword(page, request, credentials.email, newPassword) + + await expect( + page.getByText(t("onboarding.login.password-reset-success")), + ).toBeVisible() + + await submitLoginForm(page, { + email: credentials.email, + password: newPassword, + }) + await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }) + }) + + test("rejects the old password", async ({ page, request }) => { + const credentials = await signUpAndVerify(page, request) + + await resetPassword( + page, + request, + credentials.email, + "e2e-Rebooted-Passw0rd!", + ) + + await submitLoginForm(page, credentials) + + await expect( + page.getByText(t("onboarding.login.errors.invalid-credentials")), + ).toBeVisible() + await expect(page).toHaveURL(/\/login$/) + }) +}) diff --git a/e2e/tests/review.test.ts b/e2e/tests/review.test.ts new file mode 100644 index 00000000..d681c5c5 --- /dev/null +++ b/e2e/tests/review.test.ts @@ -0,0 +1,475 @@ +import { expect, test } from "@playwright/test" +import { joinAsSecondUser } from "../helpers/collaboration" +import { + contentEditor, + createDocument, + documentPersisted, + editorText, + titleEditor, + waitForEditor, +} from "../helpers/editor" +import { t } from "../helpers/i18n" +import { openInbox } from "../helpers/inbox" +import { visit } from "../helpers/page" +import { + branchLabel, + branchSwitcher, + inviteReviewer, + makeReviewable, + mergeDraftIntoMain, + openReviewerPopover, + reviewActions, + switchToBranch, +} from "../helpers/review" +import { signUpWithWorkspace } from "../helpers/workspace" + +test.describe("review workflow", () => { + // every test here layers branch creation, protection flips and the + // storage debounce on top of the usual signup-and-workspace setup — + // and the teammate tests run a second signup and an invitation on + // top of that. slow() triples the budget for the whole flow. + test.beforeEach(() => { + test.slow() + }) + + test("offers a main and a draft branch once the page is made reviewable", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + + await makeReviewable(page) + + await branchSwitcher(page).click() + await expect( + page.getByRole("menuitem", { name: branchLabel("main") }), + ).toBeVisible() + await expect( + page.getByRole("menuitem", { name: branchLabel("draft") }), + ).toBeVisible() + }) + + test("locks the main branch against direct edits", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + + await makeReviewable(page) + + await expect(contentEditor(page)).toHaveAttribute( + "contenteditable", + "false", + ) + await expect(titleEditor(page)).toHaveAttribute("contenteditable", "false") + }) + + test("keeps the draft editable while the main branch is locked", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await makeReviewable(page) + + await switchToBranch(page, "draft") + + await expect(contentEditor(page)).toHaveAttribute("contenteditable", "true") + + await contentEditor(page).click() + await page.keyboard.type("Written on the draft") + + await expect + .poll(() => editorText(contentEditor(page))) + .toBe("Written on the draft") + }) + + test("keeps draft edits out of the main branch until merged", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Base text") + await documentPersisted(page) + await makeReviewable(page) + + await switchToBranch(page, "draft") + await expect.poll(() => editorText(contentEditor(page))).toBe("Base text") + await contentEditor(page).click() + await page.keyboard.press("End") + await page.keyboard.type(" plus a draft change") + + await switchToBranch(page, "main") + + await expect.poll(() => editorText(contentEditor(page))).toBe("Base text") + }) + + test("marks text added on the draft in the diff", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Shared intro") + await documentPersisted(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await expect + .poll(() => editorText(contentEditor(page))) + .toBe("Shared intro") + await contentEditor(page).click() + await page.keyboard.press("End") + await page.keyboard.type(" and-fresh-words") + + await page.locator("#show-diff").click() + + await expect(page.locator(".diff-editor .diff-text-added")).toContainText( + "and-fresh-words", + ) + }) + + test("marks text removed from the draft in the diff", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("alpha omega") + await documentPersisted(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await expect.poll(() => editorText(contentEditor(page))).toBe("alpha omega") + + // deleting inside the same paragraph keeps the block identity, so + // the diff renders it as modified rather than as a removed block + // plus an added one. The caret is placed by clicking just past the + // last character — the paragraph is a full-width block, so a click + // near its right edge maps past the end of the line — and the + // click is retried until focus sticks: a late re-render of the + // freshly switched branch can swallow the first one. + const paragraph = contentEditor(page).getByText("alpha omega") + + await expect(async () => { + const box = await paragraph.boundingBox() + expect(box).not.toBeNull() + + await paragraph.click({ + position: { x: (box?.width ?? 2) - 1, y: (box?.height ?? 2) / 2 }, + }) + await expect(contentEditor(page)).toBeFocused() + }).toPass() + + // driven by the observed text rather than a fixed count of key + // presses: right after a branch switch the editor can swallow the + // first keystrokes while its collaboration plumbing settles, so + // each round deletes one character and checks what is left + await expect(async () => { + await page.keyboard.press("Backspace") + expect(await editorText(contentEditor(page))).toBe("alpha") + }).toPass() + + await page.locator("#show-diff").click() + + await expect(page.locator(".diff-editor .diff-text-removed")).toContainText( + "omega", + ) + }) + + test("shows the draft's title change in the diff", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await documentPersisted(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + + await titleEditor(page).click() + await page.keyboard.press("ControlOrMeta+A") + await page.keyboard.type("Retitled Draft") + + await page.locator("#show-diff").click() + + // the word diff marks each changed word in a span of its own, so + // the assertions pick one word per side + await expect( + page.locator(".diff-title .diff-text-added", { hasText: "Retitled" }), + ).toBeVisible() + await expect( + page.locator(".diff-title .diff-text-removed", { hasText: "Page" }), + ).toBeVisible() + }) + + test("merges the draft into the main branch", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("Base text") + await documentPersisted(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await expect.poll(() => editorText(contentEditor(page))).toBe("Base text") + await contentEditor(page).click() + await page.keyboard.press("End") + await page.keyboard.type(" with the draft merged in") + await documentPersisted(page) + + await mergeDraftIntoMain(page) + + await expect + .poll(() => editorText(contentEditor(page))) + .toBe("Base text with the draft merged in") + }) + + test("keeps the merged content after a reload", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const url = page.url() + await contentEditor(page).click() + await page.keyboard.type("Base text") + await documentPersisted(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await expect.poll(() => editorText(contentEditor(page))).toBe("Base text") + await contentEditor(page).click() + await page.keyboard.press("End") + await page.keyboard.type(" survives the merge") + await documentPersisted(page) + await mergeDraftIntoMain(page) + + await visit(page, url) + + await waitForEditor(page) + await expect + .poll(() => editorText(contentEditor(page))) + .toBe("Base text survives the merge") + }) + + test("records an approval and lists the approver", async ({ + page, + request, + }) => { + const { credentials } = await signUpWithWorkspace(page, request) + await createDocument(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + + await reviewActions(page) + .getByRole("button", { + name: t("editor.name-editor.review-workflow.approve.title"), + exact: true, + }) + .click() + + await expect( + page.getByText(t("editor.name-editor.review-workflow.approve.success")), + ).toBeVisible() + + const popover = await openReviewerPopover(page) + await expect( + popover.getByText( + t("editor.name-editor.reviewer-popover.approved-by-label"), + ), + ).toBeVisible() + await expect( + popover.getByText(credentials.email.split("@")[0] ?? ""), + ).toBeVisible() + }) + + test("revokes an approval", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await reviewActions(page) + .getByRole("button", { + name: t("editor.name-editor.review-workflow.approve.title"), + exact: true, + }) + .click() + await expect( + page.getByText(t("editor.name-editor.review-workflow.approve.success")), + ).toBeVisible() + + await reviewActions(page) + .getByRole("button", { + name: t("editor.name-editor.review-workflow.unapprove.title"), + exact: true, + }) + .click() + + await expect( + page.getByText(t("editor.name-editor.review-workflow.unapprove.success")), + ).toBeVisible() + await expect( + reviewActions(page).getByRole("button", { + name: t("editor.name-editor.review-workflow.approve.title"), + exact: true, + }), + ).toBeVisible() + }) + + test("returns the page to a single editable branch when disabled", async ({ + page, + request, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await makeReviewable(page) + + await page + .getByRole("button", { name: t("editor.navbar.open-document-options") }) + .click() + await page + .getByRole("menuitem", { + name: t("editor.navbar.document-options.review-workflow.disable-title"), + }) + .click() + + await expect(branchSwitcher(page)).toHaveCount(0) + await expect(contentEditor(page)).toHaveAttribute("contenteditable", "true") + }) + + test("requests a review from a teammate", async ({ + page, + request, + browser, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const other = await joinAsSecondUser(browser, page, request) + // the owner's member list predates the teammate, so a reload picks + // them up before the reviewer invite looks for them + await visit(page, page.url()) + await waitForEditor(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + + await inviteReviewer(page, other.credentials.email) + + const popover = page.locator('[data-slot="popover-content"]') + await expect( + popover.getByText(t("editor.name-editor.reviewer-popover.invited-label")), + ).toBeVisible() + await expect( + popover.getByText(other.credentials.email.split("@")[0] ?? ""), + ).toBeVisible() + + await other.context.close() + }) + + test("delivers the review request to the teammate's inbox", async ({ + page, + request, + browser, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const other = await joinAsSecondUser(browser, page, request) + // the owner's member list predates the teammate, so a reload picks + // them up before the reviewer invite looks for them + await visit(page, page.url()) + await waitForEditor(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await inviteReviewer(page, other.credentials.email) + + await openInbox(other.page) + + await expect( + other.page.getByText( + t("notification.messages.document-review-request-description"), + ), + ).toBeVisible() + + await other.context.close() + }) + + test("lets an invited teammate approve the draft", async ({ + page, + request, + browser, + }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const other = await joinAsSecondUser(browser, page, request) + // the owner's member list predates the teammate, so a reload picks + // them up before the reviewer invite looks for them + await visit(page, page.url()) + await waitForEditor(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await inviteReviewer(page, other.credentials.email) + + // the teammate's page predates the draft, so it reloads to pick + // the new branch up before switching to it + await visit(other.page, other.page.url()) + await waitForEditor(other.page) + await switchToBranch(other.page, "draft") + await reviewActions(other.page) + .getByRole("button", { + name: t("editor.name-editor.review-workflow.approve.title"), + exact: true, + }) + .click() + await expect( + other.page.getByText( + t("editor.name-editor.review-workflow.approve.success"), + ), + ).toBeVisible() + + // the owner's reviewer list refreshes over the websocket, so the + // approval arrives without any action on the owner's side + const popover = page.locator('[data-slot="popover-content"]') + await expect( + popover.getByText( + t("editor.name-editor.reviewer-popover.approved-by-label"), + ), + ).toBeVisible() + await expect( + popover.getByText(other.credentials.email.split("@")[0] ?? ""), + ).toBeVisible() + + await other.context.close() + }) + + test("withdraws a review invitation", async ({ page, request, browser }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + const other = await joinAsSecondUser(browser, page, request) + // the owner's member list predates the teammate, so a reload picks + // them up before the reviewer invite looks for them + await visit(page, page.url()) + await waitForEditor(page) + await makeReviewable(page) + await switchToBranch(page, "draft") + await inviteReviewer(page, other.credentials.email) + + const popover = page.locator('[data-slot="popover-content"]') + await popover + .getByRole("button", { + name: t( + "editor.name-editor.reviewer-popover.invite-remove-screen-reader-hint", + ), + }) + .click() + + await expect( + page.getByText( + t("editor.name-editor.reviewer-popover.invite-remove-success"), + ), + ).toBeVisible() + await expect( + popover.getByText(other.credentials.email.split("@")[0] ?? ""), + ).toHaveCount(0) + + await other.context.close() + }) +}) diff --git a/e2e/tests/search.test.ts b/e2e/tests/search.test.ts new file mode 100644 index 00000000..f53af9c9 --- /dev/null +++ b/e2e/tests/search.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test" +import { + contentEditor, + createDocument, + documentPersisted, + sidebarDocument, + titleEditor, + waitForEditor, +} from "../helpers/editor" +import { t } from "../helpers/i18n" +import { openSearch, searchFor } from "../helpers/search" +import { signUpWithWorkspace } from "../helpers/workspace" + +test.describe("search", () => { + // on top of the usual setup, a find waits out the storage debounce + // and the ten-second search-indexing pass before the first hit can + // appear. slow() triples the budget for that chain. + test.beforeEach(() => { + test.slow() + }) + + test("finds a page by its name", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await titleEditor(page).click() + await page.keyboard.press("ControlOrMeta+A") + await page.keyboard.type("Quarterly Roadmap") + await expect(page).toHaveURL(/Quarterly-Roadmap-[a-z0-9]{20}$/) + await documentPersisted(page) + + const dialog = await openSearch(page) + const result = dialog.locator("a", { hasText: "Quarterly Roadmap" }) + await searchFor(dialog, "Quarterly", result) + await result.click() + + await expect(page).toHaveURL(/Quarterly-Roadmap-[a-z0-9]{20}$/) + await waitForEditor(page) + }) + + test("finds a page by its content", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + await createDocument(page) + await contentEditor(page).click() + await page.keyboard.type("The xylophone brigade rehearses daily") + await documentPersisted(page) + // searching starts from another page, so that landing on the + // found one is observable in the URL + await sidebarDocument(page, "Welcome to Oxynote!").click() + await expect(page).toHaveURL(/Welcome-to-Oxynote-[a-z0-9]{20}$/) + + const dialog = await openSearch(page) + const result = dialog.locator("a", { hasText: "xylophone" }) + await searchFor(dialog, "xylophone", result) + await result.click() + + await expect(page).toHaveURL(/New-Page-[a-z0-9]{20}/) + }) + + test("reports when nothing matches", async ({ page, request }) => { + await signUpWithWorkspace(page, request) + + const dialog = await openSearch(page) + await dialog + .getByPlaceholder(t("sidebar.search.input-placeholder")) + .fill("zz-no-such-page-anywhere") + + await expect( + dialog.getByText( + t("sidebar.search.no-results", { + query: "zz-no-such-page-anywhere", + }), + ), + ).toBeVisible() + }) +}) diff --git a/e2e/tests/settings.test.ts b/e2e/tests/settings.test.ts new file mode 100644 index 00000000..944254ac --- /dev/null +++ b/e2e/tests/settings.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "@playwright/test" +import { newCredentials } from "../helpers/auth" +import { joinAsSecondUser } from "../helpers/collaboration" +import { waitForEditor } from "../helpers/editor" +import { t } from "../helpers/i18n" +import { visit } from "../helpers/page" +import { openSettings } from "../helpers/settings" +import { signUpWithWorkspace } from "../helpers/workspace" + +test.describe("settings", () => { + test("renames the workspace", async ({ page, request }) => { + const { workspace } = await signUpWithWorkspace(page, request) + + const dialog = await openSettings(page, workspace.name) + // the name field has no save button — it submits when it loses + // focus + const name = dialog.getByPlaceholder( + t("settings.workspace.name-placeholder"), + { exact: true }, + ) + await name.fill("Retitled-Team") + await name.blur() + + await expect( + page.getByText(t("settings.workspace.success-messages.name.title")), + ).toBeVisible() + + await page.keyboard.press("Escape") + await expect(page.getByRole("dialog")).toHaveCount(0) + await expect( + page.getByRole("button", { name: "Retitled-Team" }), + ).toBeVisible() + }) + + test("changes the username", async ({ page, request }) => { + const { workspace } = await signUpWithWorkspace(page, request) + const url = page.url() + + const dialog = await openSettings(page, workspace.name) + const username = dialog.getByPlaceholder( + t("settings.profile.username-placeholder"), + { exact: true }, + ) + await username.fill("Fresh-Nickname") + await username.blur() + + await expect( + page.getByText(t("settings.profile.success-messages.username.title")), + ).toBeVisible() + + // a reload proves the new name came back from the server rather + // than lingering in the form + await page.keyboard.press("Escape") + await visit(page, url) + await waitForEditor(page) + const reopened = await openSettings(page, workspace.name) + await expect( + reopened.getByPlaceholder(t("settings.profile.username-placeholder"), { + exact: true, + }), + ).toHaveValue("Fresh-Nickname") + }) + + test("lists a joined teammate among the members", async ({ + page, + request, + browser, + }) => { + // two signups, a verification each, an invitation and its + // acceptance run before the first assertion + test.slow() + + const { credentials, workspace } = await signUpWithWorkspace(page, request) + const other = await joinAsSecondUser(browser, page, request) + + const dialog = await openSettings(page, workspace.name) + + await expect( + dialog.getByRole("row").filter({ hasText: credentials.email }), + ).toBeVisible() + await expect( + dialog.getByRole("row").filter({ hasText: other.credentials.email }), + ).toBeVisible() + + await other.context.close() + }) + + test("shows a pending invitation among the members", async ({ + page, + request, + }) => { + const { workspace } = await signUpWithWorkspace(page, request) + const invitee = newCredentials() + + await openSettings(page, workspace.name) + await page + .getByRole("button", { + name: t("settings.workspace.invitation-button"), + exact: true, + }) + .click() + await page + .getByPlaceholder( + t("settings.action-modals.workspace-invitation.email-placeholder"), + ) + .fill(invitee.email) + await page + .getByRole("button", { + name: t("settings.action-modals.workspace-invitation.submit-button"), + }) + .click() + + await expect( + page.getByText( + t("settings.action-modals.workspace-invitation.success-message.title"), + ), + ).toBeVisible() + + const row = page.getByRole("row").filter({ hasText: invitee.email }) + await expect(row).toBeVisible() + await expect( + row.getByText(t("settings.workspace.invited-label")), + ).toBeVisible() + }) +}) diff --git a/e2e/tests/signup.test.ts b/e2e/tests/signup.test.ts index 1114c9cc..c6d31910 100644 --- a/e2e/tests/signup.test.ts +++ b/e2e/tests/signup.test.ts @@ -1,8 +1,15 @@ import { expect, test } from "@playwright/test" -import { newCredentials, submitSignupForm } from "../helpers/auth" +import { + newCredentials, + signUpAndVerify, + submitSignupForm, +} from "../helpers/auth" import { BASE_URL } from "../helpers/config" import { t } from "../helpers/i18n" -import { fetchVerificationLink } from "../helpers/mailpit" +import { + fetchAccountExistsLink, + fetchVerificationLink, +} from "../helpers/mailpit" test.describe("signup", () => { test("sends a verification link that activates the new account", async ({ @@ -32,4 +39,40 @@ test.describe("signup", () => { await expect(page).toHaveURL(`${BASE_URL}/login?verified=true`) await expect(page.getByText(t("onboarding.login.title"))).toBeVisible() }) + + test("answers a repeated signup as if the email were new", async ({ + page, + request, + }) => { + const credentials = await signUpAndVerify(page, request) + + await submitSignupForm(page, credentials) + + // the browser must not be able to probe which emails have + // accounts, so a duplicate gets the same check-your-inbox page a + // fresh signup gets + await expect(page).toHaveURL(/\/verify-email\?/, { timeout: 15_000 }) + await expect( + page.getByText( + t("onboarding.verify-email.sent-title", { + email: credentials.email, + }), + ), + ).toBeVisible() + }) + + test("warns the account owner about a repeated signup", async ({ + page, + request, + }) => { + const credentials = await signUpAndVerify(page, request) + + await submitSignupForm(page, credentials) + await expect(page).toHaveURL(/\/verify-email\?/, { timeout: 15_000 }) + + // the delivered notice is the only signal the real owner gets — + // the signup page itself reveals nothing + const link = await fetchAccountExistsLink(request, credentials.email) + expect(link).toBe(`${BASE_URL}/login`) + }) })