add more e2e scenarios

This commit is contained in:
swithek 2026-08-24 23:12:42 +02:00
parent 41975ebc71
commit 54beef4c3e
17 changed files with 1450 additions and 3 deletions

View File

@ -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<void> {
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.

48
e2e/helpers/comments.ts Normal file
View File

@ -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<void> {
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()
}

17
e2e/helpers/inbox.ts Normal file
View File

@ -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<void> {
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()
}

View File

@ -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<string> {
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<string> {
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

125
e2e/helpers/review.ts Normal file
View File

@ -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<void> {
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<void> {
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<void> {
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<Locator> {
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<void> {
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()
}

44
e2e/helpers/search.ts Normal file
View File

@ -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<Locator> {
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<void> {
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()
}

21
e2e/helpers/settings.ts Normal file
View File

@ -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<Locator> {
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
}

124
e2e/tests/comments.test.ts Normal file
View File

@ -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)
})
})

View File

@ -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)

View File

@ -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()
})
})

View File

@ -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()
})
})

25
e2e/tests/logout.test.ts Normal file
View File

@ -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 })
})
})

View File

@ -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$/)
})
})

475
e2e/tests/review.test.ts Normal file
View File

@ -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()
})
})

75
e2e/tests/search.test.ts Normal file
View File

@ -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()
})
})

125
e2e/tests/settings.test.ts Normal file
View File

@ -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()
})
})

View File

@ -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`)
})
})