mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add more e2e scenarios
This commit is contained in:
parent
41975ebc71
commit
54beef4c3e
|
|
@ -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
48
e2e/helpers/comments.ts
Normal 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
17
e2e/helpers/inbox.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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
125
e2e/helpers/review.ts
Normal 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
44
e2e/helpers/search.ts
Normal 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
21
e2e/helpers/settings.ts
Normal 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
124
e2e/tests/comments.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
27
e2e/tests/invitation.test.ts
Normal file
27
e2e/tests/invitation.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
25
e2e/tests/logout.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
44
e2e/tests/password-reset.test.ts
Normal file
44
e2e/tests/password-reset.test.ts
Normal 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
475
e2e/tests/review.test.ts
Normal 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
75
e2e/tests/search.test.ts
Normal 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
125
e2e/tests/settings.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user