diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 000000000..15511badd --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,59 @@ +import { type APIRequestContext, test as base } from '@playwright/test'; +import { WikiFactory } from './helpers/factory'; + +const AUTH_FILE = 'e2e/.auth/user.json'; + +/** + * `test` with wiki fixtures that clean up after themselves. + * + * Import this instead of `@playwright/test` in any spec that needs a space: + * + * ```ts + * import { expect, test } from '../fixtures'; + * + * test('…', async ({ page, wiki }) => { + * const space = await wiki.space({ pages: [{ title: 'Alpha' }] }); + * await page.goto(space.url()); + * }); + * ``` + * + * Teardown runs in the fixture's own epilogue, so there is no `afterAll` to + * forget and a failing test cannot skip it. + */ +export const test = base.extend< + { wiki: WikiFactory }, + { wikiSuite: WikiFactory } +>({ + // Per-test: seeded in the test, gone when it ends. The default. + wiki: async ({ request }, use) => { + const factory = new WikiFactory(request); + try { + await use(factory); + } finally { + await factory.destroyAll(); + } + }, + + // Per-worker, for a describe whose tests share one seed. It builds its own + // API context: the built-in `request` fixture is test-scoped, and a worker + // fixture may not depend on one — nor would it still be alive by the time + // this epilogue runs. + wikiSuite: [ + async ({ playwright }, use, workerInfo) => { + const context: APIRequestContext = await playwright.request.newContext({ + baseURL: workerInfo.project.use.baseURL, + storageState: AUTH_FILE, + }); + const factory = new WikiFactory(context); + try { + await use(factory); + } finally { + await factory.destroyAll(); + await context.dispose(); + } + }, + { scope: 'worker' }, + ], +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/helpers/factory.ts b/e2e/helpers/factory.ts new file mode 100644 index 000000000..98fc82111 --- /dev/null +++ b/e2e/helpers/factory.ts @@ -0,0 +1,244 @@ +import type { APIRequestContext } from '@playwright/test'; +import { callMethod, createDoc, deleteDoc, getList } from './frappe'; +import { appUrl } from './routes'; +import type { WikiDocument, WikiSpace } from './wiki'; + +/** + * Every space this factory creates is routed under this prefix, so the sweeper + * in `global.teardown.ts` can delete leftovers from a killed run without any + * chance of touching a real space. + */ +export const E2E_ROUTE_PREFIX = 'e2e'; + +/** A page to seed. Unknown keys are passed through to the Wiki Document. */ +export interface PageSpec { + title: string; + content?: string; + is_group?: boolean; + is_published?: boolean; + children?: PageSpec[]; + [field: string]: unknown; +} + +/** A space to seed. Unknown keys are passed through to the Wiki Space. */ +export interface SpaceSpec { + route?: string; + space_name?: string; + is_published?: boolean; + pages?: PageSpec[]; + [field: string]: unknown; +} + +export interface SeededPage extends WikiDocument { + children: SeededPage[]; +} + +export interface SeededSpace extends WikiSpace { + /** The root group the Wiki Space created for itself, never a replacement. */ + rootGroup: string; + /** Seeded pages, in spec order, each with its own `children`. */ + pages: SeededPage[]; + /** Look up a seeded page at any depth by title. Throws if absent. */ + page(title: string): SeededPage; + /** `/wiki-app/spaces/`, plus any deeper segments. */ + url(...segments: string[]): string; +} + +let counter = 0; + +/** A route no other spec (or parallel run) can collide with. */ +export function uniqueRoute(slug = 'space'): string { + counter += 1; + return `${E2E_ROUTE_PREFIX}-${slug}-${counter}-${Date.now().toString(36)}`; +} + +/** Flatten a page tree into breadth-first levels, keeping each node's parent. */ +function levelsOf( + pages: PageSpec[], +): { spec: PageSpec; parent: PageSpec | null }[][] { + const levels: { spec: PageSpec; parent: PageSpec | null }[][] = []; + let current = pages.map((spec) => ({ + spec, + parent: null as PageSpec | null, + })); + while (current.length) { + levels.push(current); + current = current.flatMap(({ spec }) => + (spec.children ?? []).map((child) => ({ spec: child, parent: spec })), + ); + } + return levels; +} + +/** + * Creates wiki fixtures and remembers them so they can all be destroyed at once. + * + * Seeding costs `1 + depth + 1` requests — one to create the space, one bulk + * insert per level of the page tree, and one read-back — rather than one per + * document. Deeper levels cannot join the same bulk call: Wiki Document has no + * `autoname`, and `set_new_name` discards any name we supply, so a child's + * parent name is unknowable until its parent's insert returns. + */ +export class WikiFactory { + private routes: string[] = []; + private adopted: string[] = []; + + constructor(private request: APIRequestContext) {} + + /** + * Seed a space and its page tree. + * + * Routes are left for the server to derive from the title and the ancestor + * chain, so seeded pages carry the same routes the UI would have produced. + */ + async space(spec: SpaceSpec = {}): Promise { + const { pages = [], route, space_name, ...spaceFields } = spec; + const spaceRoute = route ?? uniqueRoute(); + + // Remembered before the create, so a request that times out server-side + // still leaves us able to find and delete what it made. + this.routes.push(spaceRoute); + + const space = await createDoc( + this.request, + 'Wiki Space', + { + route: spaceRoute, + space_name: space_name ?? spaceRoute, + is_published: spec.is_published ?? true, + ...spaceFields, + }, + ); + + const namesBySpec = new Map(); + for (const level of levelsOf(pages)) { + const docs = level.map(({ spec: pageSpec, parent }) => { + const { children, ...fields } = pageSpec; + return { + doctype: 'Wiki Document', + is_published: true, + content: `Content for ${pageSpec.title}`, + ...fields, + parent_wiki_document: parent + ? namesBySpec.get(parent) + : space.root_group, + }; + }); + const names = await callMethod( + this.request, + 'frappe.client.insert_many', + { docs }, + ); + for (const [i, { spec: pageSpec }] of level.entries()) { + namesBySpec.set(pageSpec, names[i]); + } + } + + const seeded = await this.readBack(namesBySpec, pages); + const byTitle = new Map(); + const index = (nodes: SeededPage[]) => { + for (const node of nodes) { + if (!byTitle.has(node.title)) byTitle.set(node.title, node); + index(node.children); + } + }; + index(seeded); + + return { + ...space, + route: spaceRoute, + rootGroup: space.root_group, + pages: seeded, + page(title: string) { + const found = byTitle.get(title); + if (!found) { + throw new Error( + `No seeded page titled "${title}" in space ${spaceRoute}. ` + + `Seeded: ${[...byTitle.keys()].join(', ') || '(none)'}`, + ); + } + return found; + }, + url: (...segments: string[]) => appUrl('spaces', space.name, ...segments), + }; + } + + /** + * One read to pick up what the server derived — routes, slugs, doc keys and + * auto-assigned sort orders — then rebuilt into the shape of the spec. + */ + private async readBack( + namesBySpec: Map, + pages: PageSpec[], + ): Promise { + const names = [...namesBySpec.values()]; + if (!names.length) return []; + + const rows = await getList(this.request, 'Wiki Document', { + fields: [ + 'name', + 'title', + 'route', + 'slug', + 'doc_key', + 'is_group', + 'is_published', + 'sort_order', + 'parent_wiki_document', + ], + filters: { name: ['in', names] }, + limit: 0, + }); + const byName = new Map(rows.map((row) => [row.name, row])); + + const build = (specs: PageSpec[]): SeededPage[] => + specs.map((spec) => ({ + ...(byName.get(namesBySpec.get(spec) as string) as WikiDocument), + children: build(spec.children ?? []), + })); + return build(pages); + } + + /** + * Take ownership of a space this factory did not create — one a test made + * through the UI — so it is destroyed along with the rest. Give the space an + * `uniqueRoute()` when creating it, and the global sweeper becomes a backstop + * for it too. + */ + adopt(spaceName: string): void { + this.adopted.push(spaceName); + } + + /** + * Destroy everything seeded through this factory. + * + * Spaces are resolved by route rather than by the create response, so a space + * the server made but whose response we never saw — a timed-out create, or a + * retry that seeded twice — is swept too. `Wiki Space.on_trash` cascades the + * document tree, revisions, sync logs and change requests, so a page a test + * created through the UI goes with it. + */ + async destroyAll(): Promise { + for (const name of this.adopted.splice(0)) { + await deleteDoc(this.request, 'Wiki Space', name).catch(() => {}); + } + for (const route of this.routes.splice(0)) { + await destroySpacesByRoute(this.request, route); + } + } +} + +/** Delete every Wiki Space on `route`. Safe to call for a route that has none. */ +export async function destroySpacesByRoute( + request: APIRequestContext, + route: string, +): Promise { + const found = await getList<{ name: string }>(request, 'Wiki Space', { + fields: ['name'], + filters: { route }, + limit: 0, + }).catch(() => []); + for (const space of found) { + await deleteDoc(request, 'Wiki Space', space.name); + } +} diff --git a/e2e/helpers/routes.ts b/e2e/helpers/routes.ts index 480484773..e3233a108 100644 --- a/e2e/helpers/routes.ts +++ b/e2e/helpers/routes.ts @@ -6,7 +6,7 @@ * test side. * * Kept in sync with the two other definitions of this route — there is no - * automatic mechanism, same as GENERAL_KEY / WIKI_HOME_TAB_KEY: + * automatic mechanism: * - `createWebHistory()` in frontend/src/router.js * - `APP_ROUTE` in wiki/frappe_wiki/doctype/wiki_document/wiki_document.py */ diff --git a/e2e/helpers/wiki.ts b/e2e/helpers/wiki.ts index 03cc010ba..c837db951 100644 --- a/e2e/helpers/wiki.ts +++ b/e2e/helpers/wiki.ts @@ -1,5 +1,7 @@ import { type APIRequestContext, type Page, expect } from '@playwright/test'; +import { uniqueRoute } from './factory'; import { createDoc, deleteDoc, getDoc, getList } from './frappe'; +import { SPACE_URL_RE, appUrl } from './routes'; /** * Tear down every Wiki Space with the given (test-unique) route. @@ -45,29 +47,124 @@ export async function publishChangeRequestFromReview(page: Page) { } /** - * The sidebar create actions live behind a single plus button ("Add") that - * opens a dropdown of New Page / New Group / External Link. Opens the menu - * and clicks the given option. + * The sidebar footer creates a page directly; the rarer kinds (group, external + * link, tab) sit behind the chevron beside it. Opens the right one either way. */ export async function clickSidebarAddOption( page: Page, option: 'New Page' | 'New Group' | 'External Link', ) { - await page.locator('button[title="Add"]').click(); + if (option === 'New Page') { + await newPageButton(page).click(); + return; + } + await page.locator('button[title="Add a group or link"]').click(); await page.getByRole('menuitem', { name: option }).click(); } /** - * Start creating a page from the sidebar, handling both the empty-space CTA - * ("Create First Page") and the Add dropdown. Leaves the create dialog open. + * The sidebar's own "New page" button, which renders whether or not the space + * already has pages. Scoped to the sidebar: an empty space draws a second + * "New page" in the content column (spec 02 phase 5), so the name alone is + * ambiguous there. The mobile tree lives in a drawer that is an aside too. + */ +export function newPageButton(page: Page) { + return page + .getByRole('complementary') + .getByRole('button', { name: 'New page', exact: true }) + .first(); +} + +/** + * Start creating a page from the sidebar. Leaves the create dialog open. */ export async function openNewPageDialog(page: Page) { - const createFirstPage = page.locator('button:has-text("Create First Page")'); - if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { - await createFirstPage.click(); - } else { - await clickSidebarAddOption(page, 'New Page'); + await newPageButton(page).click(); +} + +/** + * Build a space through the app's own New Space dialog, and hand it to the + * factory so it is torn down with the test. + * + * Most specs should take a space from the factory instead — it is a couple of + * requests rather than a browser round-trip. This exists for the specs whose + * subject *is* the app's own create path, or which need the draft store + * hydrated exactly the way the app hydrates it. + */ +export async function createSpaceViaDialog( + page: Page, + wiki: { adopt(spaceName: string): void }, + label = 'space', +) { + const route = uniqueRoute(label); + + await page.goto(appUrl('spaces')); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: 'New Space' }).click(); + await page.waitForSelector('[role="dialog"]', { state: 'visible' }); + await page.getByLabel('Space Name').fill(route); + await page.getByLabel('Route').fill(route); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Create' }) + .click(); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(SPACE_URL_RE); + + const spaceUrl = page.url(); + const spaceId = spaceUrl.split('/spaces/')[1].split(/[/?#]/)[0]; + wiki.adopt(spaceId); + return { spaceId, spaceUrl, route }; +} + +/** + * Create a page in `space` through the sidebar and open it in the editor. + * + * Every editor spec used to open with "click whatever space is listed first", + * which grew that space's tree on every run and left the pages behind. Seeding + * a space from the factory and authoring into it keeps the tree at one item and + * lets one space delete take the draft away again. + * + * A created page lands on the draft route, which can render before the + * change-request overlay is readable — locally that is queue lag rather than a + * product bug, and one reload settles it. + */ +export async function createDraftAndOpenEditor( + page: Page, + space: { url(...segments: string[]): string }, + title: string, + options: { waitForEditorApi?: boolean } = {}, +) { + await page.goto(space.url()); + await page.waitForLoadState('networkidle'); + + await openNewPageDialog(page); + await page.getByLabel('Title').fill(title); + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + const editor = page.locator('.ProseMirror').first(); + for (let attempt = 0; attempt < 3; attempt++) { + if (await editor.isVisible({ timeout: 5000 }).catch(() => false)) break; + await page.reload(); + await page.waitForLoadState('networkidle'); + await page + .locator('aside') + .getByText(title, { exact: false }) + .first() + .click(); } + await expect(editor).toBeVisible({ timeout: 10000 }); + + // The editor publishes its command API on the window once mounted; specs + // that drive it through page.evaluate need that to have happened. + if (options.waitForEditorApi !== false) { + await page.waitForFunction( + () => (window as { wikiEditor?: unknown }).wikiEditor !== undefined, + { timeout: 10000 }, + ); + } + return editor; } /** @@ -234,3 +331,31 @@ export async function cleanupTestWikiDocuments( } } } + +/** + * The doc key of the draft currently open in the editor. + * + * A page created through the sidebar first lands on a temp key + * (`/draft/tmp_…`) and is promoted to its real doc key once the create + * round-trips. Reading the segment straight after the create therefore yields a + * key no Wiki Document will ever carry — this waits for the promotion instead. + */ +export async function currentDraftDocKey(page: Page): Promise { + await page.waitForURL(/\/draft\/(?!tmp_)[^/?#]+/, { timeout: 15000 }); + const match = page.url().match(/\/draft\/([^/?#]+)/); + return decodeURIComponent(match?.[1] ?? ''); +} + +/** + * Flush the open editor's buffer. + * + * The editor header has no Save button — autosave owns the save path and the + * dirty dot on the title reports it — so a spec that needs the buffer on the + * server presses the manual-flush shortcut instead of waiting out the ten + * second autosave. Clicking the body first puts focus inside the editor, which + * is where the shortcut is bound. + */ +export async function saveEditor(page: Page) { + await page.locator('.ProseMirror').first().click(); + await page.keyboard.press('ControlOrMeta+s'); +} diff --git a/e2e/tests/accept-contributions.spec.ts b/e2e/tests/accept-contributions.spec.ts index 790c6ee6e..6583cb6ac 100644 --- a/e2e/tests/accept-contributions.spec.ts +++ b/e2e/tests/accept-contributions.spec.ts @@ -1,11 +1,6 @@ -import { expect, test } from '@playwright/test'; -import { createDoc, getDoc } from '../helpers/frappe'; -import { - type WikiSpace, - cleanupWikiSpacesByRoute, - createTestWikiDocument, - generateWikiTitle, -} from '../helpers/wiki'; +import { expect, test } from '../fixtures'; +import type { WikiFactory } from '../helpers/factory'; +import { generateWikiTitle } from '../helpers/wiki'; /** * Per-space "Accept Contributions" toggle — reader-side behavior. @@ -22,54 +17,26 @@ import { * (Administrator) auth state. */ test.describe('Accept Contributions toggle (reader)', () => { - let route: string; - - test.afterEach(async ({ request }) => { - if (route) await cleanupWikiSpacesByRoute(request, route); - route = ''; - }); - async function seedPublicPage( - request: Parameters[0], + wiki: WikiFactory, allowContributions: boolean, ): Promise { - route = `accept-contrib-${Date.now()}`; - const space = await createDoc( - request, - 'Wiki Space', - { - route, - space_name: route, - is_published: true, - allow_contributions: allowContributions ? 1 : 0, - // Guest Read makes the space publicly readable (anonymous reader path). - roles: [{ role: 'Guest', permission_level: 'Read' }], - }, - ); - - const doc = await createTestWikiDocument(request, { - title: generateWikiTitle('Accept Contrib'), - content: '# Heading\n\nReader body content.', - wiki_space: space.name, - parent_wiki_document: space.root_group, - is_published: true, + const title = generateWikiTitle('Accept Contrib'); + const space = await wiki.space({ + allow_contributions: allowContributions ? 1 : 0, + // Guest Read makes the space publicly readable (anonymous reader path). + roles: [{ role: 'Guest', permission_level: 'Read' }], + pages: [{ title, content: '# Heading\n\nReader body content.' }], }); - - // The controller computes the final stored route; read it back. - const stored = await getDoc<{ route: string }>( - request, - 'Wiki Document', - doc.name, - ); - return `/${stored.route}`; + return `/${space.page(title).route}`; } test('shows Edit to a public reader when contributions are on', async ({ - request, + wiki, browser, baseURL, }) => { - const url = await seedPublicPage(request, true); + const url = await seedPublicPage(wiki, true); // Explicitly logged-out context (the project default carries the admin // auth state) pointed at the same server. @@ -92,11 +59,11 @@ test.describe('Accept Contributions toggle (reader)', () => { test('hides Edit (Copy becomes primary) when contributions are off; other actions remain', async ({ page, - request, + wiki, browser, baseURL, }) => { - const url = await seedPublicPage(request, false); + const url = await seedPublicPage(wiki, false); // --- Read-only viewer (Guest) --- const guest = await browser.newContext({ diff --git a/e2e/tests/bubble-menu.spec.ts b/e2e/tests/bubble-menu.spec.ts index df048d4c5..65687d91a 100644 --- a/e2e/tests/bubble-menu.spec.ts +++ b/e2e/tests/bubble-menu.spec.ts @@ -1,6 +1,5 @@ -import { expect, test } from '@playwright/test'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; -import { openNewPageDialog } from '../helpers/wiki'; +import { expect, test } from '../fixtures'; +import { createDraftAndOpenEditor } from '../helpers/wiki'; /** * Regression: the selection bubble menu must never render on top of the sticky @@ -9,38 +8,15 @@ import { openNewPageDialog } from '../helpers/wiki'; * instead of overlapping the toolbar. See WikiBubbleMenu.vue. */ test.describe('Editor bubble menu placement', () => { - async function createPageAndOpenEditor( - page: import('@playwright/test').Page, - pageTitle: string, - ) { - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); - await page.waitForLoadState('networkidle'); - - await openNewPageDialog(page); - - await page.getByLabel('Title').fill(pageTitle); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Save' }) - .click(); - await page.waitForLoadState('networkidle'); - - await page.locator('aside').getByText(pageTitle, { exact: true }).click(); - - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); - return editor; - } - test('bubble menu flips below a first-line selection instead of covering the toolbar', async ({ page, + wiki, }) => { - await createPageAndOpenEditor(page, `bubble-menu-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `bubble-menu-${Date.now()}`, + ); // Put text on the very first line and select it — this sits directly under // the sticky toolbar, the exact case that used to overlap. diff --git a/e2e/tests/callout-rich-text.spec.ts b/e2e/tests/callout-rich-text.spec.ts index 0fb6b84cc..24960a71d 100644 --- a/e2e/tests/callout-rich-text.spec.ts +++ b/e2e/tests/callout-rich-text.spec.ts @@ -1,166 +1,336 @@ -import { expect, test } from '@playwright/test'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; -import { openNewPageDialog } from '../helpers/wiki'; - -test.describe('Callout Rich Text Editing', () => { - /** - * Helper: navigate to a space and create a new page, returning the editor locator. - */ - async function createPageAndOpenEditor( - page: import('@playwright/test').Page, - pageTitle: string, - ) { - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); - await page.waitForLoadState('networkidle'); - - await openNewPageDialog(page); - - await page.getByLabel('Title').fill(pageTitle); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Save' }) - .click(); - await page.waitForLoadState('networkidle'); +import { expect, test } from '../fixtures'; +import { createDraftAndOpenEditor } from '../helpers/wiki'; - await page.locator('aside').getByText(pageTitle, { exact: true }).click(); +/** + * Covers the callout as a *container* node: its body is content in the main + * document (`content: 'block+'` + NodeViewContent), not a markdown string in an + * attribute edited by a nested editor. So the assertions here are that the main + * editor's own formatting reaches inside a callout, and that block children — + * lists, code blocks, headings — survive the markdown round-trip. + */ - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); - return editor; +declare global { + interface Window { + wikiEditor: { + commands: { + setContent: ( + content: string, + options?: { contentType?: string }, + ) => void; + focus: () => void; + }; + getMarkdown: () => string; + getJSON: () => { + type: string; + content?: { + type: string; + attrs?: Record; + content?: { type: string }[]; + }[]; + }; + }; } +} - test('callout should round-trip inline markdown (bold, italic, links)', async ({ +const BOLD = process.platform === 'darwin' ? 'Meta+b' : 'Control+b'; +const MOD_ENTER = + process.platform === 'darwin' ? 'Meta+Enter' : 'Control+Enter'; +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + +test.describe('Callout rich text', () => { + test('a callout body parses into block children and round-trips', async ({ page, + wiki, }) => { - const pageTitle = `callout-rt-${Date.now()}`; - await createPageAndOpenEditor(page, pageTitle); + await createDraftAndOpenEditor(page, await wiki.space(), 'Callout rt'); const result = await page.evaluate(() => { - const ed = document.querySelector('.ProseMirror') as HTMLElement & { - editor?: { - commands: { - setContent: (c: string, o?: object) => void; - }; - getMarkdown: () => string; - getHTML: () => string; - getJSON: () => { - type: string; - content: { type: string; attrs?: Record }[]; - }; - }; - }; - const editor = ed?.editor; - if (!editor) return { error: 'editor not found' }; + const source = [ + ':::note[Test]', + 'This has **bold** and *italic* and [a link](https://example.com)', + '', + '- one', + '- two', + ':::', + ].join('\n'); - // Set content with a callout using markdown syntax - const calloutContent = - 'This has **bold** and *italic* and [a link](https://example.com)'; - editor.commands.setContent(`:::note[Test]\n${calloutContent}\n:::`, { + window.wikiEditor.commands.setContent(source, { contentType: 'markdown', }); + const md1 = window.wikiEditor.getMarkdown(); - const md1 = editor.getMarkdown(); + window.wikiEditor.commands.setContent(md1, { contentType: 'markdown' }); + const md2 = window.wikiEditor.getMarkdown(); - // Round-trip: parse the output back - editor.commands.setContent(md1, { contentType: 'markdown' }); - const md2 = editor.getMarkdown(); - const json = editor.getJSON(); - - // Find the callout block in the JSON - const calloutNode = json.content?.find( - (n: { type: string }) => n.type === 'calloutBlock', - ); + const callout = window.wikiEditor + .getJSON() + .content?.find((n) => n.type === 'calloutBlock'); return { md1, - md2, roundTrip: md1 === md2, - calloutContent: calloutNode?.attrs?.content, - hasCallout: !!calloutNode, + title: callout?.attrs?.title, + childTypes: callout?.content?.map((child) => child.type), }; }); - expect(result).not.toHaveProperty('error'); - expect(result.hasCallout).toBe(true); + // The body is child nodes, not a string attribute. + expect(result.childTypes).toEqual(['paragraph', 'bulletList']); + expect(result.title).toBe('Test'); - // Content should preserve inline markdown - expect(result.calloutContent).toContain('**bold**'); - expect(result.calloutContent).toContain('*italic*'); - expect(result.calloutContent).toContain('[a link](https://example.com)'); + // Every mark and the list survive serialization back to the fence. + expect(result.md1).toContain('**bold**'); + expect(result.md1).toContain('*italic*'); + expect(result.md1).toContain('[a link](https://example.com)'); + expect(result.md1).toContain('- one'); + expect(result.md1).toMatch(/:::note\[Test\][\s\S]*:::/); - // Round-trip should be stable expect(result.roundTrip).toBe(true); }); - test('callout view mode should render formatted HTML preview', async ({ + test('the main editor formats text typed inside a callout', async ({ page, + wiki, }) => { - const pageTitle = `callout-preview-${Date.now()}`; - await createPageAndOpenEditor(page, pageTitle); + await createDraftAndOpenEditor(page, await wiki.space(), 'Callout typing'); - // Set content with a callout using markdown syntax await page.evaluate(() => { - const ed = document.querySelector('.ProseMirror') as HTMLElement & { - editor?: { - commands: { setContent: (c: string, o?: object) => void }; - }; - }; - ed?.editor?.commands.setContent( - ':::tip\nUse **bold** for emphasis and *italic* for style\n:::', - { contentType: 'markdown' }, - ); + window.wikiEditor.commands.setContent(':::tip\nlead\n:::', { + contentType: 'markdown', + }); }); - // The callout should render in view mode (not editing) with formatted text - const calloutContent = page.locator( - '.callout-block-wrapper .callout-content-text', + // Type into the callout body the way an author would — no double-click, + // no sub-editor: it is ordinary editable content. + const body = page.locator('.callout-content p').first(); + await expect(body).toBeVisible({ timeout: 5000 }); + await body.click(); + await page.keyboard.press('End'); + await page.keyboard.type(' '); + + // Toggle the editor's own bold shortcut, then type: the mark has to apply + // to input inside the callout, which is only true if the body belongs to + // the main editor. + await page.keyboard.press(BOLD); + await page.keyboard.type('emphasis'); + + await expect(page.locator('.callout-content strong')).toHaveText( + 'emphasis', ); - await expect(calloutContent).toBeVisible({ timeout: 5000 }); - // Check that bold and italic are rendered as HTML - const html = await calloutContent.innerHTML(); - expect(html).toContain('bold'); - expect(html).toContain('italic'); + const markdown = await page.evaluate(() => window.wikiEditor.getMarkdown()); + expect(markdown).toContain(':::tip'); + expect(markdown).toContain('**emphasis**'); }); - test('callout sub-editor should appear on double-click', async ({ page }) => { - const pageTitle = `callout-edit-${Date.now()}`; - await createPageAndOpenEditor(page, pageTitle); + test('the slash menu inserts a callout you can type straight into', async ({ + page, + wiki, + }) => { + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + 'Callout slash', + ); + + await editor.click(); + await page.keyboard.type('/tip'); + await expect( + page.locator('.slash-commands-list').getByText('Tip', { exact: true }), + ).toBeVisible({ timeout: 5000 }); + await page.keyboard.press('Enter'); + + // setCallout seeds an empty paragraph, so the body is immediately + // writable — no placeholder to double-click first. + await expect(page.locator('.callout-content')).toBeVisible({ + timeout: 5000, + }); + await page.locator('.callout-content p').first().click(); + await page.keyboard.type('written in place'); + + const markdown = await page.evaluate(() => window.wikiEditor.getMarkdown()); + expect(markdown).toContain(':::tip\nwritten in place\n:::'); + }); + + test('the title is editable in place', async ({ page, wiki }) => { + await createDraftAndOpenEditor(page, await wiki.space(), 'Callout title'); - // Set content with a callout using markdown syntax await page.evaluate(() => { - const ed = document.querySelector('.ProseMirror') as HTMLElement & { - editor?: { - commands: { setContent: (c: string, o?: object) => void }; - }; - }; - ed?.editor?.commands.setContent(':::note\nSome content here\n:::', { + window.wikiEditor.commands.setContent(':::note\nbody\n:::', { contentType: 'markdown', }); }); - // Double-click the callout content area to enter edit mode - const calloutContent = page - .locator('.callout-block-wrapper .callout-content-text') - .first(); - await expect(calloutContent).toBeVisible({ timeout: 5000 }); - await calloutContent.dblclick(); + const title = page.locator('input.callout-title'); + await expect(title).toBeVisible({ timeout: 5000 }); + // A real value, not a placeholder: the author can select and delete it. + await expect(title).toHaveValue('Note'); + + await title.click(); + await page.keyboard.press(SELECT_ALL); + await page.keyboard.type('Heads up'); - // The sub-editor (a nested ProseMirror instance) and toolbar should appear - const subEditor = page.locator( - '.callout-block-wrapper .callout-sub-editor-content', + const markdown = await page.evaluate(() => window.wikiEditor.getMarkdown()); + expect(markdown).toContain(':::note[Heads up]'); + }); + + test('an emptied title falls back to the type default', async ({ + page, + wiki, + }) => { + await createDraftAndOpenEditor( + page, + await wiki.space(), + 'Callout emptytitle', ); - await expect(subEditor).toBeVisible({ timeout: 5000 }); - // Toolbar buttons (B, I, Link) should be visible - const toolbar = page.locator( - '.callout-block-wrapper .flex.items-center.gap-0\\.5', + await page.evaluate(() => { + window.wikiEditor.commands.setContent(':::danger[Boom]\nbody\n:::', { + contentType: 'markdown', + }); + }); + + const title = page.locator('input.callout-title'); + await expect(title).toHaveValue('Boom'); + + await title.click(); + await page.keyboard.press(SELECT_ALL); + await page.keyboard.press('Backspace'); + await page.locator('.callout-content p').first().click(); + + // The published page always prints a title, so an emptied one means the + // default rather than no header at all. + await expect(title).toHaveValue('Danger'); + const markdown = await page.evaluate(() => window.wikiEditor.getMarkdown()); + // Back to the default means back to a bare fence. + expect(markdown).toContain(':::danger\n'); + expect(markdown).not.toContain('[Danger]'); + }); + + test('the cursor can leave a callout that ends the document', async ({ + page, + wiki, + }) => { + await createDraftAndOpenEditor(page, await wiki.space(), 'Callout exit'); + + await page.evaluate(() => { + window.wikiEditor.commands.setContent(':::note\nbody\n:::', { + contentType: 'markdown', + }); + }); + + await page.locator('.callout-content p').first().click(); + await page.keyboard.press('End'); + await page.keyboard.press(MOD_ENTER); + await page.keyboard.type('outside'); + + const markdown = await page.evaluate(() => window.wikiEditor.getMarkdown()); + // The typed text landed after the closing fence, not inside it. + expect(markdown).toMatch(/:::\n\noutside/); + }); + + test('backspace removes an empty callout', async ({ page, wiki }) => { + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + 'Callout backspace', + ); + + await editor.click(); + await page.keyboard.type('/note'); + await expect( + page.locator('.slash-commands-list').getByText('Note', { exact: true }), + ).toBeVisible({ timeout: 5000 }); + await page.keyboard.press('Enter'); + await expect(page.locator('.callout-content')).toBeVisible({ + timeout: 5000, + }); + + await page.locator('.callout-content p').first().click(); + await page.keyboard.press('Backspace'); + + await expect(page.locator('.callout-content')).toHaveCount(0); + const markdown = await page.evaluate(() => window.wikiEditor.getMarkdown()); + expect(markdown).not.toContain(':::'); + }); + + test('block nodes are reachable inside a callout', async ({ page, wiki }) => { + await createDraftAndOpenEditor(page, await wiki.space(), 'Callout blocks'); + + const childTypes = await page.evaluate(() => { + const source = [ + ':::caution', + '## Heading', + '', + '```js', + 'const a = 1;', + '```', + ':::', + ].join('\n'); + + window.wikiEditor.commands.setContent(source, { + contentType: 'markdown', + }); + + const callout = window.wikiEditor + .getJSON() + .content?.find((n) => n.type === 'calloutBlock'); + return callout?.content?.map((child) => child.type); + }); + + // A heading and a fenced block inside a callout were unreachable while + // the body was a string; degrading either to a bare paragraph is the + // regression this guards. + expect(childTypes).toEqual(['heading', 'codeBlock']); + }); + + test('the published page renders the callout body as real markup', async ({ + page, + wiki, + }) => { + const space = await wiki.space({ + pages: [ + { + title: 'Callout Page', + content: [ + ':::tip[Careful]', + 'Body with **bold** text.', + '', + '- first item', + '- second item', + ':::', + '', + ].join('\n'), + }, + ], + }); + const doc = space.page('Callout Page'); + + await page.goto(`/${doc.route}`); + await page.waitForLoadState('networkidle'); + + const callout = page.locator('#wiki-content aside.callout.callout-tip'); + await expect(callout).toBeVisible({ timeout: 10000 }); + + // Alert's banner structure: header row, then a full-width body. The old + // markup nested the body beside the title in a .callout-body cell. + await expect(callout.locator('.callout-header .callout-title')).toHaveText( + 'Careful', + ); + await expect(callout.locator('.callout-body')).toHaveCount(0); + await expect(callout.locator('.callout-header svg')).toBeVisible(); + + // The body is rendered markdown, not escaped text. + await expect(callout.locator('.callout-content strong')).toHaveText('bold'); + await expect(callout.locator('.callout-content li')).toHaveCount(2); + + // Neutral surface — the type shows only in the icon's colour. + const surface = await callout.evaluate( + (el) => getComputedStyle(el).backgroundColor, ); - await expect(toolbar).toBeVisible(); + const iconColor = await callout + .locator('.callout-icon') + .evaluate((el) => getComputedStyle(el).color); + expect(surface).not.toBe(iconColor); }); }); diff --git a/e2e/tests/change-request-flow.spec.ts b/e2e/tests/change-request-flow.spec.ts index 253d4b352..2d63169cf 100644 --- a/e2e/tests/change-request-flow.spec.ts +++ b/e2e/tests/change-request-flow.spec.ts @@ -1,4 +1,6 @@ -import { type Page, expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import type { WikiFactory } from '../helpers/factory'; import { callMethod, getList } from '../helpers/frappe'; import { APP_BASE, @@ -6,7 +8,13 @@ import { SPACE_URL_RE, appUrl, } from '../helpers/routes'; -import { clickSidebarAddOption, openNewPageDialog } from '../helpers/wiki'; +import { + clickSidebarAddOption, + createSpaceViaDialog, + currentDraftDocKey, + openNewPageDialog, + saveEditor, +} from '../helpers/wiki'; interface WikiDocumentRoute { route: string; @@ -50,30 +58,16 @@ async function clickReviewMenuItem(page: Page, name: string) { * Create a fresh space with a single draft page. Returns identifiers the * caller needs to navigate back to the space and address the page. */ -async function createSpaceWithDraftPage(page: Page, label: string) { - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); +async function createSpaceWithDraftPage( + page: Page, + wiki: WikiFactory, + label: string, +) { + const { spaceUrl } = await createSpaceViaDialog(page, wiki, label); - const timestamp = Date.now(); - const spaceName = `${label} ${timestamp}`; - const spaceRoute = `${label.toLowerCase().replace(/\s+/g, '-')}-${timestamp}`; const pageTitle = `${label .toLowerCase() - .replace(/\s+/g, '-')}-page-${timestamp}`; - - await page.getByLabel('Space Name').fill(spaceName); - await page.getByLabel('Route').fill(spaceRoute); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(SPACE_URL_RE); - const spaceUrl = page.url(); - + .replace(/\s+/g, '-')}-page-${Date.now()}`; await openNewPageDialog(page); await page.getByLabel('Title').fill(pageTitle); await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); @@ -82,7 +76,7 @@ async function createSpaceWithDraftPage(page: Page, label: string) { await page.locator('aside').getByText(pageTitle, { exact: true }).click(); await page.waitForURL(/\/draft\/[^/?#]+/); - return { spaceUrl, spaceName, pageTitle }; + return { spaceUrl, pageTitle }; } /** Set the open editor's content via the exposed wikiEditor and save. */ @@ -95,8 +89,7 @@ async function setEditorContentAndSave(page: Page, content: string) { await page.evaluate((c) => { window.wikiEditor.commands.setContent(c, { contentType: 'markdown' }); }, content); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); await page.waitForTimeout(500); } @@ -117,32 +110,17 @@ test.describe('Change Request Flow', () => { test('should add a page, edit existing page, merge, and verify live content', async ({ page, request, + wiki, }) => { - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - - // Create a new space - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); - const timestamp = Date.now(); - const spaceName = `CR Flow Space ${timestamp}`; - const spaceRoute = `cr-flow-space-${timestamp}`; const pageTitle = `cr-flow-page-${timestamp}`; const initialContent = `Initial content ${timestamp}`; const updatedContent = `Updated content ${timestamp}`; - - await page.getByLabel('Space Name').fill(spaceName); - const routeInput = page.getByLabel('Route'); - await routeInput.fill(spaceRoute); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(SPACE_URL_RE); - - const spaceUrl = page.url(); + const { spaceUrl } = await createSpaceViaDialog( + page, + wiki, + 'cr-flow-space', + ); // Create a new page draft @@ -158,9 +136,7 @@ test.describe('Change Request Flow', () => { // Open the new draft page from the tree await page.locator('aside').getByText(pageTitle, { exact: true }).click(); await page.waitForURL(/\/draft\/[^/?#]+/); - const draftMatch = page.url().match(/\/draft\/([^/?#]+)/); - expect(draftMatch).toBeTruthy(); - const docKey = decodeURIComponent(draftMatch?.[1] ?? ''); + const docKey = await currentDraftDocKey(page); const editor = page .locator('.ProseMirror, [contenteditable="true"]') .first(); @@ -174,8 +150,7 @@ test.describe('Change Request Flow', () => { contentType: 'markdown', }); }, initialContent); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); await page.waitForTimeout(500); // Submit for review and merge @@ -215,8 +190,7 @@ test.describe('Change Request Flow', () => { contentType: 'markdown', }); }, `${initialContent}\n\n${updatedContent}`); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); await page.waitForTimeout(500); await page.getByRole('button', { name: 'Submit for Review' }).click(); @@ -249,28 +223,14 @@ test.describe('Change Request Flow', () => { test('should merge multiple change requests with added folders and pages', async ({ page, request, + wiki, }) => { - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - - // Create a new space - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); - const timestamp = Date.now(); - const spaceName = `CR Multi Space ${timestamp}`; - const spaceRoute = `cr-multi-space-${timestamp}`; - - await page.getByLabel('Space Name').fill(spaceName); - await page.getByLabel('Route').fill(spaceRoute); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(SPACE_URL_RE); - - const spaceUrl = page.url(); + const { spaceUrl } = await createSpaceViaDialog( + page, + wiki, + 'cr-multi-space', + ); const spaceId = spaceUrl.split(`${appUrl('spaces')}/`)[1]; const createGroup = async (title: string) => { @@ -348,10 +308,10 @@ test.describe('Change Request Flow', () => { const cr1Url = await submitChangeRequestForSpace(); - // Change request 2 (created after CR1 is submitted) - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - await page.getByText(spaceName, { exact: true }).click(); + // Change request 2 (created after CR1 is submitted). Navigate by id: + // a space's name is on the page twice now -- the sidebar lists it and + // the overview row repeats it -- so clicking by text is ambiguous. + await page.goto(appUrl('spaces', spaceId)); await page.waitForLoadState('networkidle'); const cr2GroupA = `CR2 Folder A ${timestamp}`; @@ -406,24 +366,14 @@ test.describe('Change Request Flow', () => { test('should label reordered pages when reordering within a group', async ({ page, request, + wiki, }) => { - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - const timestamp = Date.now(); - const spaceName = `CR Reorder Space ${timestamp}`; - const spaceRoute = `cr-reorder-space-${timestamp}`; - - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); - await page.getByLabel('Space Name').fill(spaceName); - await page.getByLabel('Route').fill(spaceRoute); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(SPACE_URL_RE); + const { spaceUrl } = await createSpaceViaDialog( + page, + wiki, + 'cr-reorder-space', + ); const spaceId = page.url().split(`${appUrl('spaces')}/`)[1]; @@ -584,10 +534,12 @@ test.describe('Change Request Flow', () => { .locator('aside [data-slot="row"]') .filter({ has: page.getByText(movedTitle, { exact: true }) }) .first(); + // The tree marks a change with a dot, not a word, so the state is read + // off the dot's label. await expect( - movedRow.getByText('Reordered', { exact: true }), + movedRow.getByLabel('Reordered', { exact: true }), ).toBeVisible(); - await expect(movedRow.getByText('Modified', { exact: true })).toHaveCount( + await expect(movedRow.getByLabel('Modified', { exact: true })).toHaveCount( 0, ); @@ -599,7 +551,7 @@ test.describe('Change Request Flow', () => { await page.waitForLoadState('networkidle'); const changeCard = page - .locator('div.border.border-outline-gray-2.rounded-lg.overflow-hidden') + .locator('div.border.border-outline-gray-2.rounded-6.overflow-hidden') .filter({ has: page.getByText(movedTitle, { exact: true }) }) .first(); await expect( @@ -623,27 +575,16 @@ test.describe('Change Request Flow', () => { test('should navigate to published page after merging from space editor', async ({ page, request, + wiki, }) => { - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - const timestamp = Date.now(); - const spaceName = `CR Merge Nav Space ${timestamp}`; - const spaceRoute = `cr-merge-nav-${timestamp}`; const pageTitle = `merge-nav-page-${timestamp}`; const pageContent = `Merge nav content ${timestamp}`; - - // Create a new space - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); - await page.getByLabel('Space Name').fill(spaceName); - await page.getByLabel('Route').fill(spaceRoute); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await page.waitForLoadState('networkidle'); - await expect(page).toHaveURL(SPACE_URL_RE); + const { spaceUrl } = await createSpaceViaDialog( + page, + wiki, + 'cr-merge-nav-space', + ); // Create a new page draft @@ -673,8 +614,7 @@ test.describe('Change Request Flow', () => { contentType: 'markdown', }); }, pageContent); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); await page.waitForTimeout(500); // One-click self-serve publish from the editor: the Merge button walks @@ -702,9 +642,10 @@ test.describe('Change Request Flow', () => { test('two-person path: submit -> assign -> approve -> merge', async ({ page, request, + wiki, }) => { const content = `Two-person content ${Date.now()}`; - await createSpaceWithDraftPage(page, 'CR Two Person'); + await createSpaceWithDraftPage(page, wiki, 'CR Two Person'); await setEditorContentAndSave(page, content); const crName = await submitForReviewFromEditor(page); @@ -754,11 +695,12 @@ test.describe('Change Request Flow', () => { expect(merged[0]?.status).toBe('Merged'); }); - test('request changes -> revise -> resubmit', async ({ page }) => { + test('request changes -> revise -> resubmit', async ({ page, wiki }) => { const content = `Request-changes content ${Date.now()}`; const feedback = `Please expand the intro ${Date.now()}`; const { spaceUrl, pageTitle } = await createSpaceWithDraftPage( page, + wiki, 'CR Request Changes', ); await setEditorContentAndSave(page, content); @@ -792,10 +734,10 @@ test.describe('Change Request Flow', () => { }); }); - test('reject is terminal', async ({ page, request }) => { + test('reject is terminal', async ({ page, request, wiki }) => { const content = `Reject content ${Date.now()}`; const reason = `Out of scope ${Date.now()}`; - await createSpaceWithDraftPage(page, 'CR Reject'); + await createSpaceWithDraftPage(page, wiki, 'CR Reject'); await setEditorContentAndSave(page, content); const crName = await submitForReviewFromEditor(page); @@ -834,9 +776,10 @@ test.describe('Change Request Flow', () => { test('assigning from the list opens the dialog without navigating', async ({ page, + wiki, }) => { const content = `Assign nav content ${Date.now()}`; - await createSpaceWithDraftPage(page, 'CR Assign Nav'); + await createSpaceWithDraftPage(page, wiki, 'CR Assign Nav'); await setEditorContentAndSave(page, content); await submitForReviewFromEditor(page); @@ -862,9 +805,10 @@ test.describe('Change Request Flow', () => { test('assigned-to-me tab lists CRs assigned to the current user', async ({ page, request, + wiki, }) => { const content = `Assigned inbox content ${Date.now()}`; - await createSpaceWithDraftPage(page, 'CR Assigned Inbox'); + await createSpaceWithDraftPage(page, wiki, 'CR Assigned Inbox'); await setEditorContentAndSave(page, content); const crName = await submitForReviewFromEditor(page); @@ -898,11 +842,12 @@ test.describe('Change Request Flow', () => { test('my-change-requests tab renders draft rows without a router param error', async ({ page, + wiki, }) => { const content = `My-tab draft content ${Date.now()}`; // A Draft CR owned by the current user — no submit, so it stays Draft and // lands in the "My Change Requests" tab. - await createSpaceWithDraftPage(page, 'CR My Tab'); + await createSpaceWithDraftPage(page, wiki, 'CR My Tab'); await setEditorContentAndSave(page, content); // A Draft row routes to the space editor, which needs the `wiki_space` @@ -932,12 +877,14 @@ test.describe('Change Request Flow', () => { test('inline preview renders code blocks with syntax highlighting (TipTap viewer)', async ({ page, + wiki, }) => { // A fenced Python block — the inline Preview must render it through the same // read-only TipTap viewer the editor uses, which highlights via lowlight. const code = '```python\nimport os\nprint(os.getcwd())\n```'; const { pageTitle } = await createSpaceWithDraftPage( page, + wiki, 'CR Preview Highlight', ); await setEditorContentAndSave(page, code); @@ -962,9 +909,12 @@ test.describe('Change Request Flow', () => { await expect(page.getByText('auto', { exact: true })).toHaveCount(0); }); - test('back from review returns to the originating tab', async ({ page }) => { + test('back from review returns to the originating tab', async ({ + page, + wiki, + }) => { const content = `Nav back content ${Date.now()}`; - await createSpaceWithDraftPage(page, 'CR Nav Back'); + await createSpaceWithDraftPage(page, wiki, 'CR Nav Back'); await setEditorContentAndSave(page, content); const crName = await submitForReviewFromEditor(page); @@ -985,9 +935,14 @@ test.describe('Change Request Flow', () => { test('merging from the editor keeps the tree up and the banner steady', async ({ page, + wiki, }) => { const content = `Merge UX content ${Date.now()}`; - const { pageTitle } = await createSpaceWithDraftPage(page, 'CR Merge UX'); + const { pageTitle } = await createSpaceWithDraftPage( + page, + wiki, + 'CR Merge UX', + ); await setEditorContentAndSave(page, content); const treeItem = page @@ -1033,9 +988,10 @@ test.describe('Change Request Flow', () => { test('author can withdraw an in-review CR back to Draft from the menu', async ({ page, + wiki, }) => { const content = `Withdraw content ${Date.now()}`; - await createSpaceWithDraftPage(page, 'CR Withdraw'); + await createSpaceWithDraftPage(page, wiki, 'CR Withdraw'); await setEditorContentAndSave(page, content); await submitForReviewFromEditor(page); diff --git a/e2e/tests/editor-toc.spec.ts b/e2e/tests/editor-toc.spec.ts index 6f8a799b3..d2115f92b 100644 --- a/e2e/tests/editor-toc.spec.ts +++ b/e2e/tests/editor-toc.spec.ts @@ -1,9 +1,7 @@ -import { type Page, expect, test } from '@playwright/test'; -import { SPACE_URL_RE, appUrl } from '../helpers/routes'; -import { - cleanupWikiSpacesByRoute, - clickSidebarAddOption, -} from '../helpers/wiki'; +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import type { WikiFactory } from '../helpers/factory'; +import { createDraftAndOpenEditor } from '../helpers/wiki'; /** * The editor's "On this page" rail mirrors the public reader's TOC, but is @@ -11,14 +9,17 @@ import { * specs cover the two things that makes possible: entries that track the * document as it is typed, and click-to-scroll inside the editor. * - * The rail collapses to a strip below 900px of editor width, so the narrow - * case is exercised by resizing the viewport rather than by a phone project — - * the breakpoint is on the editor element, not the device. + * The rail collapses to a strip below 1008px of editor width — the 768px prose + * column plus the gutter the rail is given — so the narrow case is exercised by + * resizing the viewport rather than by a phone project: the breakpoint is on + * the editor element, not the device. */ -// The breakpoint is on the editor element, which loses ~520px to the app nav -// and the page tree — hence the gap between these two viewports. +// The breakpoint is on the editor element, which loses ~260px to the space +// sidebar — hence the gap between these viewports. LAPTOP is the tightest +// window that still earns the rail (1280 - 261 = 1019 >= 1008). const WIDE = { width: 1440, height: 900 }; +const LAPTOP = { width: 1280, height: 800 }; const NARROW = { width: 1000, height: 900 }; const PHONE = { width: 375, height: 667 }; @@ -47,74 +48,22 @@ async function seedEditor(page: Page, html: string) { expect(applied).toBe(true); } -async function createSpaceWithPage(page: Page, stamp: number) { - const spaceRoute = `editor-toc-${stamp}`; - +/** A space of its own, with one page open in the editor. */ +async function createSpaceWithPage(page: Page, wiki: WikiFactory) { await page.setViewportSize(WIDE); - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); - await page.getByLabel('Space Name').fill(spaceRoute); - await page.getByLabel('Route').fill(spaceRoute); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await expect(page).toHaveURL(SPACE_URL_RE); - await page.waitForLoadState('networkidle'); - - const createFirstPage = page.locator('button:has-text("Create First Page")'); - if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { - await createFirstPage.click(); - } else { - await clickSidebarAddOption(page, 'New Page'); - } - const pageTitle = `TOC Page ${stamp}`; - await page.getByLabel('Title').fill(pageTitle); - await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); - await page.waitForLoadState('networkidle'); - - await openPageFromTree(page, pageTitle); - return spaceRoute; -} - -/** - * Open a page from the tree and wait for the editor. - * - * Creating a page can land on the draft route before the change-request - * overlay is readable ("Draft not found"), which locally is a queue-lag - * artefact rather than a product bug — one reload settles it. - */ -async function openPageFromTree(page: Page, pageTitle: string) { - const treeItem = page.locator('aside').getByText(pageTitle, { exact: false }); - const editor = page.locator('.ProseMirror'); - - for (let attempt = 0; attempt < 3; attempt++) { - if (await editor.isVisible({ timeout: 5000 }).catch(() => false)) return; - await page.reload(); - await page.waitForLoadState('networkidle'); - await treeItem.first().click(); - } - - await expect(editor).toBeVisible({ timeout: 10000 }); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `TOC Page ${Date.now()}`, + ); } test.describe('Editor table of contents', () => { - const createdRoutes: string[] = []; - - test.afterEach(async ({ request }) => { - while (createdRoutes.length) { - const route = createdRoutes.pop() as string; - await cleanupWikiSpacesByRoute(request, route).catch(() => {}); - } - }); - test('lists h2/h3 headings and follows the document as it is typed', async ({ page, + wiki, }) => { - createdRoutes.push(await createSpaceWithPage(page, Date.now())); + await createSpaceWithPage(page, wiki); await seedEditor(page, SEED_HTML); const rail = page.locator('[data-testid="editor-toc-rail"]'); @@ -144,8 +93,11 @@ test.describe('Editor table of contents', () => { ]); }); - test('clicking an entry scrolls that heading into view', async ({ page }) => { - createdRoutes.push(await createSpaceWithPage(page, Date.now())); + test('clicking an entry scrolls that heading into view', async ({ + page, + wiki, + }) => { + await createSpaceWithPage(page, wiki); await seedEditor(page, SEED_HTML); const rail = page.locator('[data-testid="editor-toc-rail"]'); @@ -174,10 +126,31 @@ test.describe('Editor table of contents', () => { await expect(usage).toHaveClass(/text-ink-gray-9/); }); + test('keeps the rail on a 1280px laptop', async ({ page, wiki }) => { + await createSpaceWithPage(page, wiki); + await seedEditor(page, SEED_HTML); + + await page.setViewportSize(LAPTOP); + + const rail = page.locator('[data-testid="editor-toc-rail"]'); + await expect(rail).toBeVisible(); + await expect(page.locator('[data-testid="editor-toc-strip"]')).toHaveCount( + 0, + ); + + // The rail is given its own gutter, so it must not sit over the prose. + const railBox = await rail.boundingBox(); + const proseBox = await page.locator('.ProseMirror').boundingBox(); + expect(railBox).not.toBeNull(); + expect(proseBox).not.toBeNull(); + expect(proseBox.x + proseBox.width).toBeLessThanOrEqual(railBox.x); + }); + test('falls back to a collapsible strip when the editor is too narrow', async ({ page, + wiki, }) => { - createdRoutes.push(await createSpaceWithPage(page, Date.now())); + await createSpaceWithPage(page, wiki); await seedEditor(page, SEED_HTML); await expect(page.locator('[data-testid="editor-toc-rail"]')).toBeVisible(); @@ -206,8 +179,9 @@ test.describe('Editor table of contents', () => { test('the strip works on a phone without widening the page', async ({ page, + wiki, }) => { - createdRoutes.push(await createSpaceWithPage(page, Date.now())); + await createSpaceWithPage(page, wiki); await seedEditor(page, SEED_HTML); await page.setViewportSize(PHONE); diff --git a/e2e/tests/external-link.spec.ts b/e2e/tests/external-link.spec.ts index 9bc03da3c..6ee067a51 100644 --- a/e2e/tests/external-link.spec.ts +++ b/e2e/tests/external-link.spec.ts @@ -1,12 +1,8 @@ -import { expect, test } from '@playwright/test'; -import { - APP_BASE, - CHANGE_REQUEST_URL_RE, - spaceLinkSelector, -} from '../helpers/routes'; +import { expect, test } from '../fixtures'; import { clickSidebarAddOption, - publishChangeRequestFromReview, + createDraftAndOpenEditor, + saveEditor, } from '../helpers/wiki'; /** @@ -17,16 +13,12 @@ import { test.describe('External Links', () => { test('should create an external link and verify it appears with link icon', async ({ page, + wiki, }) => { await page.setViewportSize({ width: 1100, height: 900 }); - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Click the External Link button in the toolbar @@ -57,17 +49,12 @@ test.describe('External Links', () => { test('should show external link with link icon after merge and open edit dialog on click', async ({ page, + wiki, }) => { await page.setViewportSize({ width: 1100, height: 900 }); - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - const spaceHref = await spaceLink.getAttribute('href'); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Create an external link @@ -85,17 +72,19 @@ test.describe('External Links', () => { await page.waitForLoadState('networkidle'); // Submit for review and merge - await page.getByRole('button', { name: 'Submit for Review' }).click(); - await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page).toHaveURL(CHANGE_REQUEST_URL_RE, { - timeout: 10000, + // Submit for Review lives in the editor header, and adding an external + // link opens no editor. The sidebar's Merge is the manager's one-click + // path -- it walks the CR through submit, approve and merge. + const mergeButton = page.getByRole('button', { + name: 'Merge', + exact: true, }); - await publishChangeRequestFromReview(page); - - // Navigate back to space to verify the external link is in the tree after merge - if (spaceHref) { - await page.goto(spaceHref); - } + await expect(mergeButton).toBeVisible({ timeout: 10000 }); + await mergeButton.click(); + await expect( + page.locator('text=Change request merged').first(), + ).toBeVisible({ timeout: 15000 }); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Verify the external link appears in the sidebar after merge @@ -131,17 +120,12 @@ test.describe('External Links', () => { test('should show external link in public sidebar with link icon', async ({ page, + wiki, }) => { await page.setViewportSize({ width: 1100, height: 900 }); - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - const spaceHref = await spaceLink.getAttribute('href'); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Create an external link @@ -158,40 +142,33 @@ test.describe('External Links', () => { .click(); await page.waitForLoadState('networkidle'); - // Also create a regular page so we can access the public view - await clickSidebarAddOption(page, 'New Page'); - + // Also create a regular page so the space has a public page to open. + // The shared helper is what settles the draft route: creating a second + // item back to back can leave the tree click landing before the first + // draft has finished mounting. const pageTitle = `test-page-${Date.now()}`; - await page.getByLabel('Title').fill(pageTitle); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Save' }) - .click(); - await page.waitForLoadState('networkidle'); - - // Open the page and add content - await page.locator('aside').getByText(pageTitle, { exact: true }).click(); - await page.waitForURL(/\/draft\/[^/?#]+/); - - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); + const editor = await createDraftAndOpenEditor(page, space, pageTitle, { + waitForEditorApi: false, + }); await editor.click(); await page.keyboard.type('Test page content.'); - await page.click('button:has-text("Save")'); + await saveEditor(page); await page.waitForLoadState('networkidle'); // Submit and merge both items - await page.getByRole('button', { name: 'Submit for Review' }).click(); - await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page).toHaveURL(CHANGE_REQUEST_URL_RE, { - timeout: 10000, + // Submit for Review lives in the editor header, and adding an external + // link opens no editor. The sidebar's Merge is the manager's one-click + // path -- it walks the CR through submit, approve and merge. + const mergeButton = page.getByRole('button', { + name: 'Merge', + exact: true, }); - await publishChangeRequestFromReview(page); - - // Navigate back to space and click on the page to get public view - if (spaceHref) { - await page.goto(spaceHref); - } + await expect(mergeButton).toBeVisible({ timeout: 10000 }); + await mergeButton.click(); + await expect( + page.locator('text=Change request merged').first(), + ).toBeVisible({ timeout: 15000 }); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Click on the page diff --git a/e2e/tests/generated-og-image.spec.ts b/e2e/tests/generated-og-image.spec.ts index 190d5edfe..871cf5421 100644 --- a/e2e/tests/generated-og-image.spec.ts +++ b/e2e/tests/generated-og-image.spec.ts @@ -1,10 +1,4 @@ -import { expect, test } from '@playwright/test'; -import { createDoc, getDoc } from '../helpers/frappe'; -import { - type WikiSpace, - cleanupWikiSpacesByRoute, - createTestWikiDocument, -} from '../helpers/wiki'; +import { expect, test } from '../fixtures'; /** * Auto-generated OG (meta) images. @@ -22,41 +16,20 @@ import { * template work and must never regress. */ test.describe('Generated OG image', () => { - const route = `generated-og-${Date.now()}`; let pageUrl: string; - test.beforeAll(async ({ request }) => { - const space = await createDoc( - request, - 'Wiki Space', - { - route, - space_name: route, - is_published: true, - // Guest Read makes the card reachable by an anonymous scraper. - roles: [{ role: 'Guest', permission_level: 'Read' }], - }, - ); - - const doc = await createTestWikiDocument(request, { - title: 'Generated OG Page', - content: '# Heading\n\nReader body content.', - is_published: true, - wiki_space: space.name, - parent_wiki_document: space.root_group, + test.beforeAll(async ({ wikiSuite }) => { + const space = await wikiSuite.space({ + // Guest Read makes the card reachable by an anonymous scraper. + roles: [{ role: 'Guest', permission_level: 'Read' }], + pages: [ + { + title: 'Generated OG Page', + content: '# Heading\n\nReader body content.', + }, + ], }); - - // The controller computes the final stored route; read it back. - const stored = await getDoc<{ route: string }>( - request, - 'Wiki Document', - doc.name, - ); - pageUrl = `/${stored.route}`; - }); - - test.afterAll(async ({ request }) => { - await cleanupWikiSpacesByRoute(request, route); + pageUrl = `/${space.page('Generated OG Page').route}`; }); test('the public page advertises a card the endpoint can actually serve', async ({ diff --git a/e2e/tests/git-sync-edit-on-github.spec.ts b/e2e/tests/git-sync-edit-on-github.spec.ts index 22aeed376..47fa6752a 100644 --- a/e2e/tests/git-sync-edit-on-github.spec.ts +++ b/e2e/tests/git-sync-edit-on-github.spec.ts @@ -1,11 +1,4 @@ -import { expect, test } from '@playwright/test'; -import { createDoc } from '../helpers/frappe'; -import { appUrl } from '../helpers/routes'; -import { - type WikiDocument, - type WikiSpace, - cleanupWikiSpacesByRoute, -} from '../helpers/wiki'; +import { expect, test } from '../fixtures'; /** * TB2 — "Edit on GitHub" on a synced page. @@ -20,47 +13,31 @@ test.describe('Git-synced space — Edit on GitHub (TB2)', () => { const REPO = 'frappe/wiki'; const BRANCH = 'main'; - let route: string; - - test.afterEach(async ({ request }) => { - if (route) await cleanupWikiSpacesByRoute(request, route); - route = ''; - }); - test('menu item opens the source file in GitHub editor', async ({ page, - request, + wiki, }) => { - route = `git-sync-edit-${Date.now()}`; - const space = await createDoc( - request, - 'Wiki Space', - { - route, - space_name: route, - is_published: true, - git_synced: 1, - repo_full_name: REPO, - branch: BRANCH, - last_sync_status: 'Success', - last_sync_time: '2026-01-01 00:00:00', - }, - ); - - // A nested leaf page with a repo-relative source_path. + // last_sync_time is set so SpaceDetails treats the space as already + // synced and skips the auto initial-sync (which would hit GitHub). + // The leaf carries a repo-relative source_path — the GitHub trip's input. const leafSourcePath = 'docs/guides/setup.md'; const leafTitle = `Setup ${Date.now()}`; - await createDoc(request, 'Wiki Document', { - title: leafTitle, - route: `${route}/setup`, - content: '# Setup\n\nFrom the repo.', - wiki_space: space.name, - parent_wiki_document: space.root_group, - is_published: true, - source_path: leafSourcePath, + const space = await wiki.space({ + git_synced: 1, + repo_full_name: REPO, + branch: BRANCH, + last_sync_status: 'Success', + last_sync_time: '2026-01-01 00:00:00', + pages: [ + { + title: leafTitle, + content: '# Setup\n\nFrom the repo.', + source_path: leafSourcePath, + }, + ], }); - await page.goto(appUrl('spaces', space.name)); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Open the synced page. @@ -79,10 +56,11 @@ test.describe('Git-synced space — Edit on GitHub (TB2)', () => { }; }); - await page.getByRole('button', { name: 'More actions' }).click(); - const editItem = page.getByRole('menuitem', { name: 'Edit on GitHub' }); - await expect(editItem).toBeVisible(); - await editItem.click(); + // A synced page carries the GitHub trip in its header, not behind the + // page menu — the menu holds only edit actions the page cannot offer. + const editButton = page.getByRole('button', { name: 'Edit on GitHub' }); + await expect(editButton).toBeVisible(); + await editButton.click(); const opened = await page.evaluate( // @ts-expect-error test-only hook diff --git a/e2e/tests/git-sync-readonly.spec.ts b/e2e/tests/git-sync-readonly.spec.ts index 40e04948b..e919c0e36 100644 --- a/e2e/tests/git-sync-readonly.spec.ts +++ b/e2e/tests/git-sync-readonly.spec.ts @@ -1,11 +1,4 @@ -import { expect, test } from '@playwright/test'; -import { createDoc } from '../helpers/frappe'; -import { appUrl } from '../helpers/routes'; -import { - type WikiSpace, - cleanupWikiSpacesByRoute, - createTestWikiDocument, -} from '../helpers/wiki'; +import { expect, test } from '../fixtures'; /** * TB1b-ii — a git-synced Wiki Space renders read-only in the authoring SPA. @@ -20,45 +13,28 @@ test.describe('Git-synced space (read-only)', () => { const REPO = 'frappe/wiki'; const BRANCH = 'main'; - let route: string; - - test.afterEach(async ({ request }) => { - if (route) await cleanupWikiSpacesByRoute(request, route); - route = ''; - }); - test('renders read-only with no editing affordances', async ({ page, - request, + wiki, }) => { - route = `git-sync-ro-${Date.now()}`; // last_sync_time is set so SpaceDetails treats the space as already // synced and skips the auto initial-sync (which would hit GitHub). - const space = await createDoc( - request, - 'Wiki Space', - { - route, - space_name: route, - is_published: true, - git_synced: 1, - repo_full_name: REPO, - branch: BRANCH, - last_sync_status: 'Success', - last_sync_time: '2026-01-01 00:00:00', - }, - ); - const pageTitle = `Synced Page ${Date.now()}`; - await createTestWikiDocument(request, { - title: pageTitle, - content: '# Synced Heading\n\nThis content comes from the repo.', - wiki_space: space.name, - parent_wiki_document: space.root_group, - is_published: true, + const space = await wiki.space({ + git_synced: 1, + repo_full_name: REPO, + branch: BRANCH, + last_sync_status: 'Success', + last_sync_time: '2026-01-01 00:00:00', + pages: [ + { + title: pageTitle, + content: '# Synced Heading\n\nThis content comes from the repo.', + }, + ], }); - await page.goto(appUrl('spaces', space.name)); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Synced banner (shared SpaceChromeBar): the repo link marks it as synced @@ -69,7 +45,9 @@ test.describe('Git-synced space (read-only)', () => { await expect(page.getByRole('button', { name: 'Sync now' })).toBeVisible(); // No create / mutation affordances in the sidebar. - await expect(page.locator('button[title="Add"]')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'New page', exact: true }), + ).toHaveCount(0); // Open the synced page and confirm the viewer is non-editable. await page.locator('aside').getByText(pageTitle, { exact: true }).click(); diff --git a/e2e/tests/global.teardown.ts b/e2e/tests/global.teardown.ts new file mode 100644 index 000000000..55645e838 --- /dev/null +++ b/e2e/tests/global.teardown.ts @@ -0,0 +1,31 @@ +import { test as teardown } from '@playwright/test'; +import { E2E_ROUTE_PREFIX } from '../helpers/factory'; +import { deleteDoc, getList } from '../helpers/frappe'; + +/** + * Sweep spaces a killed or crashed run left behind. + * + * The factory destroys what it seeds, so this only ever has work to do when a + * run died between seeding and teardown. Scoped to the `e2e-` route prefix, so + * it cannot reach a real space. + */ +teardown('sweep leftover e2e spaces', async ({ request }) => { + const leftovers = await getList<{ name: string; route: string }>( + request, + 'Wiki Space', + { + fields: ['name', 'route'], + filters: { route: ['like', `${E2E_ROUTE_PREFIX}-%`] }, + limit: 0, + }, + ).catch(() => []); + + for (const space of leftovers) { + try { + await deleteDoc(request, 'Wiki Space', space.name); + console.log(`swept leftover space ${space.route}`); + } catch (error) { + console.warn(`failed to sweep ${space.route}:`, error); + } + } +}); diff --git a/e2e/tests/iframe-embed.spec.ts b/e2e/tests/iframe-embed.spec.ts index 4f2c7c492..e9f4f963e 100644 --- a/e2e/tests/iframe-embed.spec.ts +++ b/e2e/tests/iframe-embed.spec.ts @@ -1,6 +1,5 @@ -import { expect, test } from '@playwright/test'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; -import { openNewPageDialog } from '../helpers/wiki'; +import { expect, test } from '../fixtures'; +import { createDraftAndOpenEditor } from '../helpers/wiki'; /** * Covers the iframe embed extension added for frappe/wiki#599. @@ -45,45 +44,16 @@ declare global { } } -/** - * Create a draft page and open the editor. Mirrors the helper in - * image-viewer.spec.ts — duplicated here rather than exported so changes - * to one test don't ripple into others. - */ -async function createDraftAndOpenEditor( - page: import('@playwright/test').Page, - title: string, -) { - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); - await page.waitForLoadState('networkidle'); - - await openNewPageDialog(page); - - await page.getByLabel('Title').fill(title); - await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); - await page.waitForLoadState('networkidle'); - - await page.locator('aside').getByText(title, { exact: true }).click(); - - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); - - await page.waitForFunction(() => window.wikiEditor !== undefined, { - timeout: 10000, - }); - return editor; -} - test.describe('Iframe embed extension', () => { test('parses a YouTube iframe HTML block from markdown into a node', async ({ page, + wiki, }) => { - await createDraftAndOpenEditor(page, `iframe-parse-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `iframe-parse-${Date.now()}`, + ); const result = await page.evaluate((html) => { window.wikiEditor.commands.setContent(html, { contentType: 'markdown' }); @@ -107,8 +77,15 @@ test.describe('Iframe embed extension', () => { expect(result.height).toBe('315'); }); - test('renders the iframe preview inside the editor', async ({ page }) => { - await createDraftAndOpenEditor(page, `iframe-preview-${Date.now()}`); + test('renders the iframe preview inside the editor', async ({ + page, + wiki, + }) => { + await createDraftAndOpenEditor( + page, + await wiki.space(), + `iframe-preview-${Date.now()}`, + ); await page.evaluate((html) => { window.wikiEditor.commands.setContent(html, { contentType: 'markdown' }); @@ -123,8 +100,13 @@ test.describe('Iframe embed extension', () => { test('round-trips iframe markdown without mutating the src', async ({ page, + wiki, }) => { - await createDraftAndOpenEditor(page, `iframe-roundtrip-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `iframe-roundtrip-${Date.now()}`, + ); const { md1, md2 } = await page.evaluate((html) => { window.wikiEditor.commands.setContent(html, { contentType: 'markdown' }); @@ -172,8 +154,12 @@ test.describe('Iframe embed extension', () => { }, text); } - test('turns a pasted YouTube link into an embed', async ({ page }) => { - await createDraftAndOpenEditor(page, `iframe-paste-${Date.now()}`); + test('turns a pasted YouTube link into an embed', async ({ page, wiki }) => { + await createDraftAndOpenEditor( + page, + await wiki.space(), + `iframe-paste-${Date.now()}`, + ); await pasteText(page, 'https://www.youtube.com/watch?v=QDia3e12czc'); @@ -195,8 +181,13 @@ test.describe('Iframe embed extension', () => { // entire payload is the URL should embed. test('leaves a pasted sentence containing a link as text', async ({ page, + wiki, }) => { - await createDraftAndOpenEditor(page, `iframe-paste-inline-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `iframe-paste-inline-${Date.now()}`, + ); await pasteText( page, @@ -216,8 +207,13 @@ test.describe('Iframe embed extension', () => { test('accepts the full iframe tag in the /embed URL input', async ({ page, + wiki, }) => { - await createDraftAndOpenEditor(page, `iframe-slash-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `iframe-slash-${Date.now()}`, + ); // Insert an empty placeholder via the extension command (skips the // slash-menu fuzzy-find noise and tests the URL input directly). diff --git a/e2e/tests/image-viewer.spec.ts b/e2e/tests/image-viewer.spec.ts index b3b8c3b08..d0d36fb08 100644 --- a/e2e/tests/image-viewer.spec.ts +++ b/e2e/tests/image-viewer.spec.ts @@ -1,13 +1,12 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import type { SeededSpace } from '../helpers/factory'; import { getList } from '../helpers/frappe'; +import { CHANGE_REQUEST_URL_RE } from '../helpers/routes'; import { - APP_BASE, - CHANGE_REQUEST_URL_RE, - spaceLinkSelector, -} from '../helpers/routes'; -import { + currentDraftDocKey, openNewPageDialog, publishChangeRequestFromReview, + saveEditor, } from '../helpers/wiki'; interface WikiDocumentRoute { @@ -34,16 +33,12 @@ declare global { async function createAndPublishPage( page: import('@playwright/test').Page, request: import('@playwright/test').APIRequestContext, + space: SeededSpace, title: string, markdownContent: string, ): Promise { await page.setViewportSize({ width: 1100, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); await openNewPageDialog(page); @@ -68,9 +63,7 @@ async function createAndPublishPage( const match = window.location.pathname.match(/\/draft\/([^/?#]+)/); return match && !decodeURIComponent(match[1]).startsWith('tmp_'); }); - const draftMatch = page.url().match(/\/draft\/([^/?#]+)/); - expect(draftMatch).toBeTruthy(); - const docKey = decodeURIComponent(draftMatch?.[1] ?? ''); + const docKey = await currentDraftDocKey(page); const editor = page.locator('.ProseMirror, [contenteditable="true"]'); await expect(editor).toBeVisible({ timeout: 10000 }); @@ -87,7 +80,7 @@ async function createAndPublishPage( await editor.click(); await page.waitForTimeout(500); - await page.getByRole('button', { name: 'Save', exact: true }).click(); + await saveEditor(page); await page.waitForLoadState('networkidle'); const submitButton = page.getByRole('button', { name: 'Submit for Review' }); @@ -112,6 +105,7 @@ test.describe('Image Viewer / Lightbox', () => { test('should open lightbox when clicking a prose image and close on overlay click', async ({ page, request, + wiki, }) => { const pageTitle = `lightbox-test-${Date.now()}`; const markdown = `## Image Test @@ -125,6 +119,7 @@ Some text after the image.`; const publicUrl = await createAndPublishPage( page, request, + await wiki.space(), pageTitle, markdown, ); @@ -174,7 +169,11 @@ Some text after the image.`; await publicPage.close(); }); - test('should close lightbox on Escape key', async ({ page, request }) => { + test('should close lightbox on Escape key', async ({ + page, + request, + wiki, + }) => { const pageTitle = `lightbox-esc-test-${Date.now()}`; const markdown = `## Escape Key Test @@ -183,6 +182,7 @@ Some text after the image.`; const publicUrl = await createAndPublishPage( page, request, + await wiki.space(), pageTitle, markdown, ); @@ -211,6 +211,7 @@ Some text after the image.`; test('should wire up images loaded via SPA navigation', async ({ page, request, + wiki, }) => { // Create two pages — one with an image, one without const pageTitle1 = `lightbox-spa-1-${Date.now()}`; @@ -224,15 +225,20 @@ Just some text, no images here.`; ![SPA test image](https://placehold.co/600x400/png)`; + // A space each: both are reached by URL, never through prev/next, and + // publishing twice into one space leaves the second editor holding a + // change request the first merge has already moved past. const publicUrl1 = await createAndPublishPage( page, request, + await wiki.space(), pageTitle1, markdown1, ); const publicUrl2 = await createAndPublishPage( page, request, + await wiki.space(), pageTitle2, markdown2, ); diff --git a/e2e/tests/link-persistence.spec.ts b/e2e/tests/link-persistence.spec.ts index 944b12de9..129200aa5 100644 --- a/e2e/tests/link-persistence.spec.ts +++ b/e2e/tests/link-persistence.spec.ts @@ -1,13 +1,10 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; import { getList } from '../helpers/frappe'; -import { - APP_BASE, - CHANGE_REQUEST_URL_RE, - spaceLinkSelector, -} from '../helpers/routes'; +import { APP_BASE, CHANGE_REQUEST_URL_RE } from '../helpers/routes'; import { openNewPageDialog, publishChangeRequestFromReview, + saveEditor, } from '../helpers/wiki'; interface WikiDocument { @@ -22,14 +19,10 @@ test.describe('Link Persistence Tests', () => { test('should save links as markdown to the database', async ({ page, request, + wiki, }) => { - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Create a new page @@ -83,8 +76,7 @@ test.describe('Link Persistence Tests', () => { await expect(editorLink).toHaveText('Example Website'); // Save the draft - const saveButton = page.locator('button:has-text("Save")'); - await saveButton.click(); + await saveEditor(page); await page.waitForLoadState('networkidle'); await page.waitForTimeout(3000); // Wait for DB commit diff --git a/e2e/tests/local-first-store.spec.ts b/e2e/tests/local-first-store.spec.ts index af7c26fdb..451343d0b 100644 --- a/e2e/tests/local-first-store.spec.ts +++ b/e2e/tests/local-first-store.spec.ts @@ -1,8 +1,19 @@ -import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { type WikiFactory, uniqueRoute } from '../helpers/factory'; import { callMethod } from '../helpers/frappe'; import { delayMethod, failMethod } from '../helpers/mock'; import { SPACE_URL_RE, appUrl } from '../helpers/routes'; -import { openNewPageDialog } from '../helpers/wiki'; +import { openNewPageDialog, saveEditor } from '../helpers/wiki'; + +/** + * The sidebar's sync alert only speaks when something is in flight or wrong; + * a settled draft shows nothing at all. "Saved" is therefore the absence of + * the alert, not a label to wait for. + */ +async function expectSyncSettled(page: Page, timeout = 5000) { + await expect(page.getByTestId('sync-state-alert')).toBeHidden({ timeout }); +} interface DraftNode { docKey: string; @@ -43,10 +54,16 @@ declare global { const CR_METHOD_PREFIX = 'wiki.frappe_wiki.doctype.wiki_change_request.wiki_change_request'; -async function createSpaceViaUI( - page: import('@playwright/test').Page, - { name, route }: { name: string; route: string }, -) { +/** + * Build a space through the New Space dialog. + * + * These specs need the store hydrated exactly the way the app hydrates it, so + * the dialog is load-bearing here and the API factory cannot stand in. The + * factory still adopts the result, so the space is torn down with the test. + */ +async function createSpaceViaUI(page: Page, wiki: WikiFactory) { + const route = uniqueRoute('local-first'); + const name = route; await page.goto(appUrl('spaces')); await page.waitForLoadState('networkidle'); await page.getByRole('button', { name: 'New Space' }).click(); @@ -67,13 +84,11 @@ async function createSpaceViaUI( timeout: 10000, }); const spaceId = page.url().split(`${appUrl('spaces')}/`)[1]; + wiki.adopt(spaceId); return { spaceId }; } -async function createPageViaUI( - page: import('@playwright/test').Page, - title: string, -) { +async function createPageViaUI(page: Page, title: string) { await openNewPageDialog(page); await page.getByLabel('Title').fill(title); await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); @@ -82,14 +97,13 @@ async function createPageViaUI( test.describe('Local-first draft workspace', () => { test('delayed apply_cr_operations create: page appears immediately and content survives promotion', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Delay Create Space ${timestamp}`; - const spaceRoute = `delay-create-space-${timestamp}`; const pageTitle = `delay-create-page-${timestamp}`; const typedContent = `Typed before backend confirmed ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); // Inject 2.5s of latency on apply_cr_operations so the optimistic UI // is observable for the full duration before the temp key is promoted. @@ -140,22 +154,19 @@ test.describe('Local-first draft workspace', () => { // Promotion triggers a save against the real key. Let that intercepted // request finish before unregistering its delayed route handler. - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 6000, - }); + await expectSyncSettled(page, 6000); await unroute(); }); test('failed apply_cr_operations save: content stays visible and submit is blocked', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Fail Update Space ${timestamp}`; - const spaceRoute = `fail-update-space-${timestamp}`; const pageTitle = `fail-update-page-${timestamp}`; const typedContent = `Should survive failed save ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); @@ -193,8 +204,7 @@ test.describe('Local-first draft workspace', () => { contentType: 'markdown', }); }, typedContent); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); // Sync-state badge should report failure. await expect(page.getByText('Sync failed')).toBeVisible({ @@ -214,14 +224,13 @@ test.describe('Local-first draft workspace', () => { test('Reload latest after a failed save clears the conflict and re-enables Submit', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Reload Latest Space ${timestamp}`; - const spaceRoute = `reload-latest-space-${timestamp}`; const pageTitle = `reload-latest-page-${timestamp}`; const typedContent = `Will fail to save ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); @@ -252,8 +261,7 @@ test.describe('Local-first draft workspace', () => { contentType: 'markdown', }); }, typedContent); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); // The save fails; banner surfaces it and Reload latest appears. await expect(page.getByText('Sync failed')).toBeVisible({ timeout: 5000 }); @@ -271,34 +279,33 @@ test.describe('Local-first draft workspace', () => { await unroute(); await reloadButton.click(); - // Sync-failed banner clears and the recovery button hides — but - // Submit MUST stay disabled because the editor's DOM still holds - // the user's unsaved typed content. Unblocking here would let the - // user submit a CR that doesn't contain what they see on screen. + // Sync-failed banner clears and the recovery button hides — but the + // editor's DOM still holds the user's unsaved typed content, so the + // workspace must keep reporting it. Submitting from here flushes it + // first; what must never happen is the state going quiet while the + // screen holds text the CR does not. await expect(page.getByText('Sync failed')).toBeHidden({ timeout: 5000 }); await expect(reloadButton).toBeHidden(); - await expect(submitButton).toBeDisabled(); - - // Resolving the typed content (Save now succeeds because the - // mock is gone and operation_version is fresh) is what finally - // re-enables Submit. - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ + await expect(page.getByText('Unsaved changes')).toBeVisible({ timeout: 5000, }); + + // Resolving the typed content (Save now succeeds because the + // mock is gone and operation_version is fresh) settles the state. + await saveEditor(page); + await expectSyncSettled(page, 5000); await expect(submitButton).toBeEnabled(); }); - test('typing in editor disables Submit until the change is flushed to the CR', async ({ + test('typing in editor reports unsaved content until the change is flushed to the CR', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Dirty Editor Space ${timestamp}`; - const spaceRoute = `dirty-editor-space-${timestamp}`; const pageTitle = `dirty-editor-page-${timestamp}`; const typedContent = `Must not be dropped by submit ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); @@ -335,34 +342,30 @@ test.describe('Local-first draft workspace', () => { }, typedContent); await editor.click(); - // The fix: Submit must be disabled while the editor has unsaved - // typed content the store hasn't received yet. Without it, the user - // can submit a stale backend CR and silently lose the latest text. - await expect(submitButton).toBeDisabled(); - - // Flushing via manual Save lands the content and re-enables Submit. - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ + // The fix: the workspace must report unsaved editor content the store + // hasn't received yet. Submitting flushes it first — what must never + // happen is a submit that silently loses the latest text. + await expect(page.getByText('Unsaved changes')).toBeVisible({ timeout: 5000, }); + + // Flushing by hand lands the content and settles the sync state. + await saveEditor(page); + await expectSyncSettled(page, 5000); await expect(submitButton).toBeEnabled(); }); - test('navigating away from dirty content auto-saves it and re-enables Submit', async ({ + test('navigating away from dirty content auto-saves it', async ({ page, request, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Navigate Dirty Space ${timestamp}`; - const spaceRoute = `navigate-dirty-space-${timestamp}`; const firstTitle = `navigate-first-page-${timestamp}`; const secondTitle = `navigate-second-page-${timestamp}`; const typedContent = `Must survive document navigation ${timestamp}`; - const { spaceId } = await createSpaceViaUI(page, { - name: spaceName, - route: spaceRoute, - }); + const { spaceId } = await createSpaceViaUI(page, wiki); const draft = await callMethod<{ name: string }>( request, `${CR_METHOD_PREFIX}.get_or_create_draft_change_request`, @@ -415,13 +418,13 @@ test.describe('Local-first draft workspace', () => { }); }, typedContent); await editor.click(); - await expect(submitButton).toBeDisabled(); + await expect(page.getByText('Unsaved changes')).toBeVisible({ + timeout: 5000, + }); // Navigating away flushes the dirty buffer to the server. await page.locator('aside').getByText(secondTitle, { exact: true }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 5000, - }); + await expectSyncSettled(page, 5000); await expect(submitButton).toBeEnabled(); await page.locator('aside').getByText(firstTitle, { exact: true }).click(); @@ -429,17 +432,16 @@ test.describe('Local-first draft workspace', () => { await expect(submitButton).toBeEnabled(); }); - test('typing then undoing back to saved content re-enables Submit without a redundant save', async ({ + test('typing then undoing back to saved content clears the unsaved state without a redundant save', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Undo Editor Space ${timestamp}`; - const spaceRoute = `undo-editor-space-${timestamp}`; const pageTitle = `undo-editor-page-${timestamp}`; const baselineContent = `Baseline ${timestamp}`; const transientContent = `Transient typing ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); @@ -466,52 +468,42 @@ test.describe('Local-first draft workspace', () => { contentType: 'markdown', }); }, baselineContent); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 5000, - }); + await saveEditor(page); + await expectSyncSettled(page, 5000); - const submitButton = page.getByRole('button', { - name: 'Submit for Review', - }); - await expect(submitButton).toBeEnabled(); - - // Type something new — Submit should go disabled. + // Type something new — the buffer diverges from the last saved snapshot. await page.evaluate((content) => { window.wikiEditor.commands.setContent(content, { contentType: 'markdown', }); }, transientContent); await editor.click(); - await expect(submitButton).toBeDisabled(); + await expect(page.getByText('Unsaved changes')).toBeVisible({ + timeout: 5000, + }); // Revert back to the saved content. No save is issued; the derived - // local snapshot converges with the baseline and the banner gate - // releases on its own. + // local snapshot converges with the baseline and the unsaved state + // clears on its own. await page.evaluate((content) => { window.wikiEditor.commands.setContent(content, { contentType: 'markdown', }); }, baselineContent); await editor.click(); - await expect(submitButton).toBeEnabled(); + await expectSyncSettled(page, 5000); }); test('dirty content on an existing published page survives browser refresh', async ({ page, request, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Published Persist Space ${timestamp}`; - const spaceRoute = `published-persist-space-${timestamp}`; const pageTitle = `published-persist-page-${timestamp}`; const typedContent = `Existing page survives refresh ${timestamp}`; - const { spaceId } = await createSpaceViaUI(page, { - name: spaceName, - route: spaceRoute, - }); + const { spaceId } = await createSpaceViaUI(page, wiki); const initialDraft = await callMethod<{ name: string }>( request, `${CR_METHOD_PREFIX}.get_or_create_draft_change_request`, @@ -569,10 +561,8 @@ test.describe('Local-first draft workspace', () => { timeout: 5000, }); - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 5000, - }); + await saveEditor(page); + await expectSyncSettled(page, 5000); const submitButton = page.getByRole('button', { name: 'Submit for Review', }); @@ -581,14 +571,13 @@ test.describe('Local-first draft workspace', () => { test('dirty editor content survives a browser refresh via IndexedDB', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Persist Space ${timestamp}`; - const spaceRoute = `persist-space-${timestamp}`; const pageTitle = `persist-page-${timestamp}`; const typedContent = `Survives a refresh ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); @@ -627,7 +616,7 @@ test.describe('Local-first draft workspace', () => { // Navigate back to the draft page. The editor should reopen on the // same content the user last typed, and the banner should report - // "Unsaved changes" with Submit still gated. + // "Unsaved changes". await page.locator('aside').getByText(pageTitle, { exact: true }).click(); const restoredEditor = page .locator('.ProseMirror, [contenteditable="true"]') @@ -638,30 +627,24 @@ test.describe('Local-first draft workspace', () => { timeout: 5000, }); - const submitButton = page.getByRole('button', { - name: 'Submit for Review', - }); - await expect(submitButton).toBeDisabled(); - - // Saving the restored draft clears the IDB entry and re-enables - // Submit, just like a normal first-save. - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 5000, - }); - await expect(submitButton).toBeEnabled(); + // Saving the restored draft clears the IDB entry and settles the sync + // state, just like a normal first-save. + await saveEditor(page); + await expectSyncSettled(page, 5000); + await expect( + page.getByRole('button', { name: 'Submit for Review' }), + ).toBeEnabled(); }); test('a persisted draft identical to the server self-heals instead of gating Submit', async ({ page, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Phantom Draft Space ${timestamp}`; - const spaceRoute = `phantom-draft-space-${timestamp}`; const pageTitle = `phantom-draft-page-${timestamp}`; const savedContent = `Already on the server ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); @@ -688,11 +671,8 @@ test.describe('Local-first draft workspace', () => { contentType: 'markdown', }); }, savedContent); - await editor.click(); - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 5000, - }); + await saveEditor(page); + await expectSyncSettled(page, 5000); // Plant a persisted IndexedDB draft whose content is byte-identical to // what the server already holds — a phantom with no real unsaved @@ -776,14 +756,13 @@ test.describe('Local-first draft workspace', () => { test('a restored draft matching normalized server markdown self-heals after editor mount', async ({ page, request, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Normalized Draft Space ${timestamp}`; - const spaceRoute = `normalized-draft-space-${timestamp}`; const pageTitle = `normalized-draft-page-${timestamp}`; const rawServerContent = `Line A ${timestamp}\nLine B`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); await page.waitForFunction( @@ -858,15 +837,14 @@ test.describe('Local-first draft workspace', () => { test('saving again while the first save is in flight persists the latest content', async ({ page, request, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Queued Save Space ${timestamp}`; - const spaceRoute = `queued-save-space-${timestamp}`; const pageTitle = `queued-save-page-${timestamp}`; const firstContent = `First save ${timestamp}`; const latestContent = `Latest save ${timestamp}`; - await createSpaceViaUI(page, { name: spaceName, route: spaceRoute }); + await createSpaceViaUI(page, wiki); await createPageViaUI(page, pageTitle); await page.locator('aside').getByText(pageTitle, { exact: true }).click(); await page.waitForFunction( @@ -891,7 +869,7 @@ test.describe('Local-first draft workspace', () => { contentType: 'markdown', }); }, firstContent); - await page.getByRole('button', { name: 'Save' }).click(); + await saveEditor(page); await expect(page.getByText('Saving…')).toBeVisible(); await page.evaluate((content) => { @@ -901,9 +879,7 @@ test.describe('Local-first draft workspace', () => { }, latestContent); await page.keyboard.press('Control+s'); - await expect(page.getByText('All changes saved')).toBeVisible({ - timeout: 8000, - }); + await expectSyncSettled(page, 8000); await unroute(); const { crName, docKey } = await page.evaluate(() => { @@ -925,19 +901,15 @@ test.describe('Local-first draft workspace', () => { test('delayed reorder: visual order stays stable across slow sync', async ({ page, request, + wiki, }) => { const timestamp = Date.now(); - const spaceName = `Delay Reorder Space ${timestamp}`; - const spaceRoute = `delay-reorder-space-${timestamp}`; const groupTitle = `Reorder Group ${timestamp}`; const pageTitles = ['1', '2', '3', '4'].map( (n) => `Reorder Page ${n} ${timestamp}`, ); - const { spaceId } = await createSpaceViaUI(page, { - name: spaceName, - route: spaceRoute, - }); + const { spaceId } = await createSpaceViaUI(page, wiki); // Seed a group with 4 pages directly via the existing CR APIs so the // test focuses on the reorder behaviour, not creation. diff --git a/e2e/tests/markdown-breaks.spec.ts b/e2e/tests/markdown-breaks.spec.ts index 7872af9ed..4aa59a6be 100644 --- a/e2e/tests/markdown-breaks.spec.ts +++ b/e2e/tests/markdown-breaks.spec.ts @@ -1,7 +1,6 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; import { getList } from '../helpers/frappe'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; -import { openNewPageDialog } from '../helpers/wiki'; +import { createDraftAndOpenEditor, saveEditor } from '../helpers/wiki'; interface WikiDocument { name: string; @@ -12,50 +11,16 @@ interface WikiDocument { } test.describe('Markdown Line Breaks', () => { - /** - * Helper: navigate to a space and create a new page, returning the editor locator. - */ - async function createPageAndOpenEditor( - page: import('@playwright/test').Page, - pageTitle: string, - ) { - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); - await page.waitForLoadState('networkidle'); - - await openNewPageDialog(page); - - await page.getByLabel('Title').fill(pageTitle); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Save' }) - .click(); - await page.waitForLoadState('networkidle'); - - const pageTitleInput = page.getByRole('textbox', { name: 'Page title' }); - const openedCreatedPage = await pageTitleInput - .inputValue({ timeout: 2000 }) - .then((value) => value === pageTitle) - .catch(() => false); - if (!openedCreatedPage) { - await page.locator('aside').getByText(pageTitle, { exact: true }).click(); - } - await expect(pageTitleInput).toHaveValue(pageTitle, { timeout: 10000 }); - - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); - return editor; - } - test('editor should round-trip single line breaks (soft breaks)', async ({ page, + wiki, }) => { const pageTitle = `md-breaks-soft-${Date.now()}`; - const editor = await createPageAndOpenEditor(page, pageTitle); + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + pageTitle, + ); // Use the Tiptap editor API to set markdown content with single newlines const result = await page.evaluate(() => { @@ -91,9 +56,16 @@ test.describe('Markdown Line Breaks', () => { expect(result.roundTrip).toBe(true); }); - test('editor should round-trip consecutive blank lines', async ({ page }) => { + test('editor should round-trip consecutive blank lines', async ({ + page, + wiki, + }) => { const pageTitle = `md-breaks-blank-${Date.now()}`; - const editor = await createPageAndOpenEditor(page, pageTitle); + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + pageTitle, + ); const result = await page.evaluate(() => { const ed = document.querySelector('.ProseMirror') as HTMLElement & { @@ -129,9 +101,14 @@ test.describe('Markdown Line Breaks', () => { test('editor should round-trip multiple consecutive blank lines', async ({ page, + wiki, }) => { const pageTitle = `md-breaks-multi-${Date.now()}`; - const editor = await createPageAndOpenEditor(page, pageTitle); + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + pageTitle, + ); const result = await page.evaluate(() => { const ed = document.querySelector('.ProseMirror') as HTMLElement & { @@ -164,9 +141,14 @@ test.describe('Markdown Line Breaks', () => { test('editor should round-trip mixed content: headings, breaks, and soft breaks', async ({ page, + wiki, }) => { const pageTitle = `md-breaks-mixed-${Date.now()}`; - const editor = await createPageAndOpenEditor(page, pageTitle); + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + pageTitle, + ); const result = await page.evaluate(() => { const ed = document.querySelector('.ProseMirror') as HTMLElement & { @@ -201,9 +183,14 @@ test.describe('Markdown Line Breaks', () => { test('standard paragraph breaks should not create empty paragraphs', async ({ page, + wiki, }) => { const pageTitle = `md-breaks-standard-${Date.now()}`; - const editor = await createPageAndOpenEditor(page, pageTitle); + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + pageTitle, + ); const result = await page.evaluate(() => { const ed = document.querySelector('.ProseMirror') as HTMLElement & { @@ -232,9 +219,14 @@ test.describe('Markdown Line Breaks', () => { test('blank lines should persist through save and reload', async ({ page, + wiki, }) => { const pageTitle = `md-breaks-persist-${Date.now()}`; - const editor = await createPageAndOpenEditor(page, pageTitle); + const editor = await createDraftAndOpenEditor( + page, + await wiki.space(), + pageTitle, + ); const inputMarkdown = 'First paragraph\n\n\n\nSecond paragraph\n\nLine A\nLine B'; @@ -252,7 +244,7 @@ test.describe('Markdown Line Breaks', () => { }, inputMarkdown); // Save the draft - await page.click('button:has-text("Save")'); + await saveEditor(page); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); diff --git a/e2e/tests/markdown-paste.spec.ts b/e2e/tests/markdown-paste.spec.ts index 0637c971b..163d7888d 100644 --- a/e2e/tests/markdown-paste.spec.ts +++ b/e2e/tests/markdown-paste.spec.ts @@ -1,6 +1,5 @@ -import { expect, test } from '@playwright/test'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; -import { openNewPageDialog } from '../helpers/wiki'; +import { expect, test } from '../fixtures'; +import { createDraftAndOpenEditor } from '../helpers/wiki'; /** * Regression coverage for frappe/wiki#609: @@ -12,46 +11,6 @@ import { openNewPageDialog } from '../helpers/wiki'; * pages) the default ProseMirror handler keeps the rich formatting untouched. */ test.describe('Markdown Paste (#609)', () => { - /** - * Navigate to a space and create a new page, returning the editor locator. - * Mirrors the harness used by markdown-breaks.spec.ts. - */ - async function createPageAndOpenEditor( - page: import('@playwright/test').Page, - pageTitle: string, - ) { - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); - await page.waitForLoadState('networkidle'); - - await openNewPageDialog(page); - - await page.getByLabel('Title').fill(pageTitle); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Save' }) - .click(); - await page.waitForLoadState('networkidle'); - - const pageTitleInput = page.getByRole('textbox', { name: 'Page title' }); - const openedCreatedPage = await pageTitleInput - .inputValue({ timeout: 2000 }) - .then((value) => value === pageTitle) - .catch(() => false); - if (!openedCreatedPage) { - await page.locator('aside').getByText(pageTitle, { exact: true }).click(); - } - await expect(pageTitleInput).toHaveValue(pageTitle, { timeout: 10000 }); - - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); - return editor; - } - /** * Fire a real `paste` ClipboardEvent at the ProseMirror DOM node so the * editor's own `handlePaste` runs — exactly the path a user's Cmd+V takes. @@ -91,9 +50,11 @@ test.describe('Markdown Paste (#609)', () => { test('pasting plain-text markdown renders it (headings, bold, list)', async ({ page, + wiki, }) => { - const editor = await createPageAndOpenEditor( + const editor = await createDraftAndOpenEditor( page, + await wiki.space(), `md-paste-${Date.now()}`, ); await editor.click(); @@ -117,9 +78,11 @@ test.describe('Markdown Paste (#609)', () => { test('pasting rich HTML keeps its formatting (does not re-parse as markdown)', async ({ page, + wiki, }) => { - const editor = await createPageAndOpenEditor( + const editor = await createDraftAndOpenEditor( page, + await wiki.space(), `md-paste-html-${Date.now()}`, ); await editor.click(); diff --git a/e2e/tests/mermaid.spec.ts b/e2e/tests/mermaid.spec.ts index c2ae155ce..b670b8327 100644 --- a/e2e/tests/mermaid.spec.ts +++ b/e2e/tests/mermaid.spec.ts @@ -1,13 +1,5 @@ -import { expect, test } from '@playwright/test'; -import { updateDoc } from '../helpers/frappe'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; -import { - createTestWikiDocument, - createTestWikiSpace, - deleteTestWikiDocument, - deleteTestWikiSpace, - openNewPageDialog, -} from '../helpers/wiki'; +import { expect, test } from '../fixtures'; +import { createDraftAndOpenEditor } from '../helpers/wiki'; /** * Covers the Mermaid diagram feature: the editor node (parse + live preview + @@ -35,45 +27,16 @@ declare global { } } -/** - * Create a draft page and open the editor. Mirrors the helper in - * iframe-embed.spec.ts — duplicated here rather than exported so changes to one - * test don't ripple into others. - */ -async function createDraftAndOpenEditor( - page: import('@playwright/test').Page, - title: string, -) { - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); - await page.waitForLoadState('networkidle'); - - await openNewPageDialog(page); - - await page.getByLabel('Title').fill(title); - await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); - await page.waitForLoadState('networkidle'); - - await page.locator('aside').getByText(title, { exact: true }).click(); - - const editor = page.locator('.ProseMirror, [contenteditable="true"]'); - await expect(editor).toBeVisible({ timeout: 10000 }); - - await page.waitForFunction(() => window.wikiEditor !== undefined, { - timeout: 10000, - }); - return editor; -} - test.describe('Mermaid diagrams', () => { test('parses a ```mermaid fence into a node and previews it as SVG', async ({ page, + wiki, }) => { - await createDraftAndOpenEditor(page, `mermaid-edit-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `mermaid-edit-${Date.now()}`, + ); const code = await page.evaluate((md) => { window.wikiEditor.commands.setContent(md, { contentType: 'markdown' }); @@ -91,8 +54,15 @@ test.describe('Mermaid diagrams', () => { }); }); - test('round-trips the mermaid fence without drift', async ({ page }) => { - await createDraftAndOpenEditor(page, `mermaid-roundtrip-${Date.now()}`); + test('round-trips the mermaid fence without drift', async ({ + page, + wiki, + }) => { + await createDraftAndOpenEditor( + page, + await wiki.space(), + `mermaid-roundtrip-${Date.now()}`, + ); const { md1, md2 } = await page.evaluate((md) => { window.wikiEditor.commands.setContent(md, { contentType: 'markdown' }); @@ -112,8 +82,13 @@ test.describe('Mermaid diagrams', () => { test('renders multiple diagrams independently without colliding', async ({ page, + wiki, }) => { - await createDraftAndOpenEditor(page, `mermaid-multi-${Date.now()}`); + await createDraftAndOpenEditor( + page, + await wiki.space(), + `mermaid-multi-${Date.now()}`, + ); const twoDiagrams = '```mermaid\nflowchart TD\n A[Start] --> B[End]\n```\n\n' + @@ -135,46 +110,23 @@ test.describe('Mermaid diagrams', () => { test('renders the diagram as inline SVG on the public page', async ({ page, - request, + wiki, }) => { - const spaceRoute = `mermaid-space-${Date.now()}`; - const space = await createTestWikiSpace(request, { - route: spaceRoute, - is_published: true, - }); - const rootGroup = await createTestWikiDocument(request, { - title: 'Root', - route: `${spaceRoute}/root`, - is_group: true, - is_published: true, - }); - await updateDoc(request, 'Wiki Space', space.name, { - root_group: rootGroup.name, - }); - const doc = await createTestWikiDocument(request, { - title: 'Mermaid Page', - route: `${spaceRoute}/diagram`, - content: MERMAID_MARKDOWN, - is_published: true, - parent_wiki_document: rootGroup.name, + const space = await wiki.space({ + pages: [{ title: 'Mermaid Page', content: MERMAID_MARKDOWN }], }); + const doc = space.page('Mermaid Page'); + + await page.goto(`/${doc.route}`); + await page.waitForLoadState('networkidle'); + + // Server emits the fence as
, not a code block.
+		const container = page.locator('#wiki-content .mermaid').first();
+		await expect(container).toBeAttached({ timeout: 10000 });
 
-		try {
-			await page.goto(`/${doc.route}`);
-			await page.waitForLoadState('networkidle');
-
-			// Server emits the fence as 
, not a code block.
-			const container = page.locator('#wiki-content .mermaid').first();
-			await expect(container).toBeAttached({ timeout: 10000 });
-
-			// mermaid-renderer.js lazy-loads Mermaid and hydrates it into an SVG.
-			await expect(
-				page.locator('#wiki-content .mermaid svg').first(),
-			).toBeVisible({ timeout: 15000 });
-		} finally {
-			await deleteTestWikiDocument(request, doc.name).catch(() => {});
-			await deleteTestWikiDocument(request, rootGroup.name).catch(() => {});
-			await deleteTestWikiSpace(request, space.name).catch(() => {});
-		}
+		// mermaid-renderer.js lazy-loads Mermaid and hydrates it into an SVG.
+		await expect(
+			page.locator('#wiki-content .mermaid svg').first(),
+		).toBeVisible({ timeout: 15000 });
 	});
 });
diff --git a/e2e/tests/mobile-view.spec.ts b/e2e/tests/mobile-view.spec.ts
index 7b1c8fd6b..149d61f71 100644
--- a/e2e/tests/mobile-view.spec.ts
+++ b/e2e/tests/mobile-view.spec.ts
@@ -1,17 +1,13 @@
-import {
-	type APIRequestContext,
-	type Page,
-	expect,
-	test,
-} from '@playwright/test';
+import type { APIRequestContext, Page } from '@playwright/test';
+import { expect, test } from '../fixtures';
+import type { WikiFactory } from '../helpers/factory';
 import { callMethod, getList } from '../helpers/frappe';
+import { APP_BASE, CHANGE_REQUEST_URL_RE } from '../helpers/routes';
 import {
-	APP_BASE,
-	CHANGE_REQUEST_URL_RE,
-	SPACE_URL_RE,
-	appUrl,
-} from '../helpers/routes';
-import { openNewPageDialog } from '../helpers/wiki';
+	createDraftAndOpenEditor,
+	currentDraftDocKey,
+	saveEditor,
+} from '../helpers/wiki';
 
 interface WikiDocumentRoute {
 	route: string;
@@ -34,48 +30,17 @@ const mobileViewport = { width: 375, height: 667 };
 async function createPublishedTestPage(
 	page: Page,
 	request: APIRequestContext,
+	wiki: WikiFactory,
 	title: string,
 	content?: string,
 ): Promise {
-	// Create a dedicated space for this test
-	await page.goto(appUrl('spaces'));
-	await page.waitForLoadState('networkidle');
-
-	const timestamp = Date.now();
-	const spaceName = `mobile-view-space-${timestamp}`;
-	const spaceRoute = `mobile-view-space-${timestamp}`;
-
-	await page.getByRole('button', { name: 'New Space' }).click();
-	await page.waitForSelector('[role="dialog"]', { state: 'visible' });
-	await page.getByLabel('Space Name').fill(spaceName);
-	await page.getByLabel('Route').fill(spaceRoute);
-	await page
-		.getByRole('dialog')
-		.getByRole('button', { name: 'Create' })
-		.click();
-	await page.waitForLoadState('networkidle');
-	await expect(page).toHaveURL(SPACE_URL_RE);
-
-	// Create a new page
-
-	await openNewPageDialog(page);
-
-	await page.getByLabel('Title').fill(title);
-	await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
-	await page.waitForLoadState('networkidle');
-
-	// Open the newly created page from the sidebar tree
-	await page.locator('aside').getByText(title, { exact: true }).click();
-	await page.waitForURL(/\/draft\/[^/?#]+/);
-	const draftMatch = page.url().match(/\/draft\/([^/?#]+)/);
-	if (!draftMatch) {
-		throw new Error('Draft doc key not found in URL');
-	}
-	const docKey = decodeURIComponent(draftMatch[1]);
-
-	// Wait for editor
-	const editor = page.locator('.ProseMirror, [contenteditable="true"]');
-	await expect(editor).toBeVisible({ timeout: 10000 });
+	const editor = await createDraftAndOpenEditor(
+		page,
+		await wiki.space(),
+		title,
+		{ waitForEditorApi: false },
+	);
+	const docKey = await currentDraftDocKey(page);
 
 	// Add content if provided
 	if (content) {
@@ -91,7 +56,7 @@ async function createPublishedTestPage(
 	}
 
 	// Save the draft
-	await page.click('button:has-text("Save")');
+	await saveEditor(page);
 	await page.waitForLoadState('networkidle');
 
 	// Submit for review and merge the page
@@ -143,11 +108,17 @@ test.describe('Mobile View', () => {
 		test('should display mobile header on small viewport', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first (at desktop size for admin)
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `mobile-header-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Now switch to mobile and visit the public page
 			await page.setViewportSize(mobileViewport);
@@ -176,11 +147,17 @@ test.describe('Mobile View', () => {
 		test('should display wiki space name in mobile header', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `mobile-space-name-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile and visit public page
 			await page.setViewportSize(mobileViewport);
@@ -200,11 +177,17 @@ test.describe('Mobile View', () => {
 		test('should open bottom sheet when menu button is clicked', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `bottom-sheet-open-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile and visit the public page
 			await page.setViewportSize(mobileViewport);
@@ -226,11 +209,17 @@ test.describe('Mobile View', () => {
 		test('should close bottom sheet when overlay is clicked', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `bottom-sheet-overlay-close-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -253,11 +242,17 @@ test.describe('Mobile View', () => {
 		test('should close bottom sheet when close button is clicked', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `bottom-sheet-close-button-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -283,11 +278,17 @@ test.describe('Mobile View', () => {
 		test('should display sidebar navigation in bottom sheet', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `bottom-sheet-nav-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -312,11 +313,17 @@ test.describe('Mobile View', () => {
 		test('should close bottom sheet when navigation link is clicked', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `bottom-sheet-nav-click-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -344,11 +351,17 @@ test.describe('Mobile View', () => {
 		test('should have drag handle for swipe-to-dismiss', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `bottom-sheet-drag-handle-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -371,6 +384,7 @@ test.describe('Mobile View', () => {
 		test('should have TOC container in mobile header structure', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page with headings at desktop viewport
 			await page.setViewportSize({ width: 1100, height: 900 });
@@ -386,6 +400,7 @@ Content for second section.`;
 			const publicUrl = await createPublishedTestPage(
 				page,
 				request,
+				wiki,
 				pageTitle,
 				tocContent,
 			);
@@ -410,6 +425,7 @@ Content for second section.`;
 		test('should render headings with anchor links on mobile', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page with headings
 			await page.setViewportSize({ width: 1100, height: 900 });
@@ -425,6 +441,7 @@ Getting started content.`;
 			const publicUrl = await createPublishedTestPage(
 				page,
 				request,
+				wiki,
 				pageTitle,
 				tocContent,
 			);
@@ -454,11 +471,17 @@ Getting started content.`;
 		test('should have theme toggle button in mobile header', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `theme-toggle-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -475,11 +498,17 @@ Getting started content.`;
 		test('should open search when search button is clicked', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `search-button-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Switch to mobile
 			await page.setViewportSize(mobileViewport);
@@ -501,11 +530,17 @@ Getting started content.`;
 		test('should hide mobile header on desktop viewport', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first (at desktop)
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `responsive-breakpoints-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Start at mobile viewport
 			await page.setViewportSize(mobileViewport);
@@ -531,11 +566,17 @@ Getting started content.`;
 		test('should show mobile header on tablet viewport', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Create a test page first
 			await page.setViewportSize({ width: 1100, height: 900 });
 			const pageTitle = `tablet-viewport-test-${Date.now()}`;
-			const publicUrl = await createPublishedTestPage(page, request, pageTitle);
+			const publicUrl = await createPublishedTestPage(
+				page,
+				request,
+				wiki,
+				pageTitle,
+			);
 
 			// Tablet viewport (below lg breakpoint of 1024px)
 			await page.setViewportSize({ width: 768, height: 1024 });
diff --git a/e2e/tests/ordering.spec.ts b/e2e/tests/ordering.spec.ts
index 5aa86a770..6decb8bf3 100644
--- a/e2e/tests/ordering.spec.ts
+++ b/e2e/tests/ordering.spec.ts
@@ -1,11 +1,8 @@
-import { expect, test } from '@playwright/test';
-import { callMethod, updateDoc } from '../helpers/frappe';
-import { appUrl } from '../helpers/routes';
-import {
-	clickSidebarAddOption,
-	createTestWikiDocument,
-	createTestWikiSpace,
-} from '../helpers/wiki';
+import type { Page } from '@playwright/test';
+import { expect, test } from '../fixtures';
+import type { PageSpec, SeededSpace } from '../helpers/factory';
+import { callMethod } from '../helpers/frappe';
+import { clickSidebarAddOption } from '../helpers/wiki';
 
 /**
  * E2E tests for wiki document ordering functionality.
@@ -14,185 +11,95 @@ import {
  * 2. Reordering documents persists after page refresh
  * 3. Order is consistent between admin and public-facing views
  */
+
+/**
+ * A group per name, each holding one published page — a bare group is hidden
+ * from the public sidebar, so the child is what makes the order observable
+ * there.
+ */
+function groupsWithAPageEach(names: string[]): PageSpec[] {
+	return names.map((name) => ({
+		title: name,
+		is_group: true,
+		children: [{ title: `${name} Page` }],
+	}));
+}
+
+/** The order the named groups appear in, read off rendered text. */
+async function orderIn(page: Page, selector: string, names: string[]) {
+	const text = await page.locator(selector).innerText();
+	return names
+		.filter((name) => text.includes(name))
+		.sort((a, b) => text.indexOf(a) - text.indexOf(b));
+}
+
+/** Move `names[from]` to the head of its siblings, through the reorder API. */
+async function moveToFront(
+	request: Parameters[0],
+	space: SeededSpace,
+	names: string[],
+	from: number,
+) {
+	const siblings = names.map((name) => space.page(name).name);
+	const [moved] = siblings.splice(from, 1);
+	await callMethod(request, 'wiki.api.wiki_space.reorder_wiki_documents', {
+		doc_name: moved,
+		new_parent: space.rootGroup,
+		new_index: 0,
+		siblings: JSON.stringify([moved, ...siblings]),
+	});
+}
+
 test.describe('Wiki Document Ordering', () => {
 	test('new document should appear at bottom of sidebar', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		// Create a test space with 5 folders via API
-		const spaceName = `ordering-test-${Date.now()}`;
-		const space = await createTestWikiSpace(request, {
-			route: spaceName,
-			is_published: true,
-		});
-
-		// Create root group for the space
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceName}/root`,
-			is_group: true,
-			is_published: true,
-		});
+		const names = ['Q1', 'Q2', 'Q3', 'Q4', 'Q5'];
+		const space = await wiki.space({ pages: groupsWithAPageEach(names) });
 
-		// Update space with root_group
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-
-		// Create 5 folders (Q1-Q5) under root
-		const folders: string[] = [];
-		for (let i = 1; i <= 5; i++) {
-			const folder = await createTestWikiDocument(request, {
-				title: `Q${i}`,
-				route: `${spaceName}/q${i}`,
-				is_group: true,
-				is_published: true,
-				parent_wiki_document: rootGroup.name,
-			});
-			folders.push(folder.name);
-
-			// Create a child page so folder shows in public view
-			await createTestWikiDocument(request, {
-				title: `Page in Q${i}`,
-				route: `${spaceName}/q${i}/page`,
-				is_group: false,
-				is_published: true,
-				parent_wiki_document: folder.name,
-			});
-		}
-
-		// Navigate to the wiki space admin
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
-
-		// Get initial order from sidebar - wait for tree to load
 		await page.waitForSelector('aside >> text=Q1', { timeout: 10000 });
 
-		const getSidebarOrder = async () => {
-			// Get all text from the sidebar tree area
-			const sidebarText = await page.locator('aside').innerText();
-			// Extract Q1, Q2, etc. from the text in order they appear
-			const matches = sidebarText.match(/Q\d+/g) || [];
-			// Remove duplicates while preserving order
-			return [...new Set(matches)];
-		};
-
-		const initialOrder = await getSidebarOrder();
+		expect(await orderIn(page, 'aside', names)).toEqual(names);
 
-		// Verify Q1-Q5 are in order
-		expect(initialOrder).toEqual(['Q1', 'Q2', 'Q3', 'Q4', 'Q5']);
-
-		// Create a new folder Q6 via UI
 		await clickSidebarAddOption(page, 'New Group');
-
-		// Fill in the title
 		await page.getByLabel('Title').fill('Q6');
 		await page
 			.getByRole('dialog')
 			.getByRole('button', { name: 'Save' })
 			.click();
 		await page.waitForLoadState('networkidle');
+		await expect(page.locator('aside').getByText('Q6')).toBeVisible({
+			timeout: 10000,
+		});
 
-		// Wait a moment for the tree to update
-		await page.waitForTimeout(1000);
-
-		// Get the new order - Q6 should be at the bottom
-		const orderAfterCreate = await getSidebarOrder();
-
-		// Q6 should appear at the end, not at the beginning
-		expect(orderAfterCreate[orderAfterCreate.length - 1]).toBe('Q6');
-		expect(orderAfterCreate).toEqual(['Q1', 'Q2', 'Q3', 'Q4', 'Q5', 'Q6']);
+		// Q6 lands at the end, not the beginning.
+		expect(await orderIn(page, 'aside', [...names, 'Q6'])).toEqual([
+			...names,
+			'Q6',
+		]);
 	});
 
 	test('reorder should persist after page refresh', async ({
 		page,
+		wiki,
 		request,
 	}) => {
-		// Create a test space with folders via API
-		const spaceName = `reorder-test-${Date.now()}`;
-		const space = await createTestWikiSpace(request, {
-			route: spaceName,
-			is_published: true,
-		});
-
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceName}/root`,
-			is_group: true,
-			is_published: true,
-		});
-
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
+		const names = ['Folder1', 'Folder2', 'Folder3', 'Folder4', 'Folder5'];
+		const space = await wiki.space({ pages: groupsWithAPageEach(names) });
 
-		// Create 5 folders
-		const folders: string[] = [];
-		for (let i = 1; i <= 5; i++) {
-			const folder = await createTestWikiDocument(request, {
-				title: `Folder${i}`,
-				route: `${spaceName}/folder${i}`,
-				is_group: true,
-				is_published: true,
-				parent_wiki_document: rootGroup.name,
-			});
-			folders.push(folder.name);
-
-			await createTestWikiDocument(request, {
-				title: `Page in Folder${i}`,
-				route: `${spaceName}/folder${i}/page`,
-				is_group: false,
-				is_published: true,
-				parent_wiki_document: folder.name,
-			});
-		}
-
-		// Navigate to the wiki space admin
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
-
-		// Wait for tree to load and get folder order from sidebar
 		await page.waitForSelector('aside >> text=Folder1', { timeout: 10000 });
+		expect(await orderIn(page, 'aside', names)).toEqual(names);
 
-		const getSidebarFolderOrder = async () => {
-			const sidebarText = await page.locator('aside').innerText();
-			const matches = sidebarText.match(/Folder\d+/g) || [];
-			return [...new Set(matches)];
-		};
-
-		const initialOrder = await getSidebarFolderOrder();
-		expect(initialOrder).toEqual([
-			'Folder1',
-			'Folder2',
-			'Folder3',
-			'Folder4',
-			'Folder5',
-		]);
-
-		// Reorder via API: Move Folder5 to first position
-		const newSiblingsOrder = [
-			folders[4],
-			folders[0],
-			folders[1],
-			folders[2],
-			folders[3],
-		]; // Folder5, Folder1, Folder2, Folder3, Folder4
-
-		await callMethod(request, 'wiki.api.wiki_space.reorder_wiki_documents', {
-			doc_name: folders[4], // Folder5
-			new_parent: rootGroup.name,
-			new_index: 0,
-			siblings: JSON.stringify(newSiblingsOrder),
-		});
+		await moveToFront(request, space, names, 4);
 
-		// Refresh the page
 		await page.reload();
 		await page.waitForLoadState('networkidle');
-
-		// Verify the order persisted
-		const orderAfterRefresh = await getSidebarFolderOrder();
-
-		expect(orderAfterRefresh).toEqual([
+		expect(await orderIn(page, 'aside', names)).toEqual([
 			'Folder5',
 			'Folder1',
 			'Folder2',
@@ -203,197 +110,47 @@ test.describe('Wiki Document Ordering', () => {
 
 	test('order should be consistent between admin and public views', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		// Create a test space
-		const spaceName = `consistency-test-${Date.now()}`;
-		const space = await createTestWikiSpace(request, {
-			route: spaceName,
-			is_published: true,
-		});
+		const names = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'];
+		const space = await wiki.space({ pages: groupsWithAPageEach(names) });
 
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceName}/root`,
-			is_group: true,
-			is_published: true,
-		});
-
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-
-		// Create 5 folders with specific order
-		const folderNames = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'];
-		const folders: string[] = [];
-
-		for (const name of folderNames) {
-			const folder = await createTestWikiDocument(request, {
-				title: name,
-				route: `${spaceName}/${name.toLowerCase()}`,
-				is_group: true,
-				is_published: true,
-				parent_wiki_document: rootGroup.name,
-			});
-			folders.push(folder.name);
-
-			// Create a published page inside each folder
-			await createTestWikiDocument(request, {
-				title: `${name} Page`,
-				route: `${spaceName}/${name.toLowerCase()}/page`,
-				is_group: false,
-				is_published: true,
-				parent_wiki_document: folder.name,
-			});
-		}
-
-		// Check admin view order
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
 		await page.waitForSelector('aside >> text=Alpha', { timeout: 10000 });
+		const adminOrder = await orderIn(page, 'aside', names);
 
-		const getAdminOrder = async () => {
-			const sidebarText = await page.locator('aside').innerText();
-			// Extract folder names in order they appear
-			const order: string[] = [];
-			for (const name of folderNames) {
-				if (sidebarText.includes(name) && !order.includes(name)) {
-					order.push(name);
-				}
-			}
-			// Sort by position in sidebarText
-			order.sort((a, b) => sidebarText.indexOf(a) - sidebarText.indexOf(b));
-			return order;
-		};
-
-		const adminOrder = await getAdminOrder();
-
-		// Navigate to public view
-		await page.goto(`/${spaceName}/alpha/page`);
+		await page.goto(`/${space.page('Alpha Page').route}`);
 		await page.waitForLoadState('networkidle');
-		// Wait for sidebar to render
-		await page.waitForTimeout(1000);
-
-		// Get order from public sidebar - get full page text and parse it
-		const getPublicOrder = async () => {
-			// The sidebar is on the left, get text from the whole page
-			const pageText = await page.locator('body').innerText();
-			const order: string[] = [];
-			for (const name of folderNames) {
-				if (pageText.includes(name) && !order.includes(name)) {
-					order.push(name);
-				}
-			}
-			order.sort((a, b) => pageText.indexOf(a) - pageText.indexOf(b));
-			return order;
-		};
-
-		const publicOrder = await getPublicOrder();
+		await expect(page.locator('.wiki-sidebar')).toBeVisible();
+		const publicOrder = await orderIn(page, 'body', names);
 
-		// Both orders should match
 		expect(publicOrder).toEqual(adminOrder);
-		expect(publicOrder).toEqual(folderNames);
+		expect(publicOrder).toEqual(names);
 	});
 
 	test('drag and drop reorder should update public view', async ({
 		page,
+		wiki,
 		request,
 	}) => {
-		// Create a test space
-		const spaceName = `dragdrop-test-${Date.now()}`;
-		const space = await createTestWikiSpace(request, {
-			route: spaceName,
-			is_published: true,
-		});
-
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceName}/root`,
-			is_group: true,
-			is_published: true,
-		});
-
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-
-		// Create 3 folders for simpler drag test
-		const folderNames = ['First', 'Second', 'Third'];
-		const folders: string[] = [];
+		const names = ['First', 'Second', 'Third'];
+		const reordered = ['Third', 'First', 'Second'];
+		const space = await wiki.space({ pages: groupsWithAPageEach(names) });
 
-		for (const name of folderNames) {
-			const folder = await createTestWikiDocument(request, {
-				title: name,
-				route: `${spaceName}/${name.toLowerCase()}`,
-				is_group: true,
-				is_published: true,
-				parent_wiki_document: rootGroup.name,
-			});
-			folders.push(folder.name);
-
-			await createTestWikiDocument(request, {
-				title: `${name} Content`,
-				route: `${spaceName}/${name.toLowerCase()}/content`,
-				is_group: false,
-				is_published: true,
-				parent_wiki_document: folder.name,
-			});
-		}
-
-		// Navigate to admin view
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
 		await page.waitForSelector('aside >> text=First', { timeout: 10000 });
 
-		// Get initial order
-		const getOrder = async () => {
-			const sidebarText = await page.locator('aside').innerText();
-			const order: string[] = [];
-			for (const name of folderNames) {
-				if (sidebarText.includes(name) && !order.includes(name)) {
-					order.push(name);
-				}
-			}
-			order.sort((a, b) => sidebarText.indexOf(a) - sidebarText.indexOf(b));
-			return order;
-		};
-
-		// Reorder via API: Move "Third" to first position
-		const newOrder = [folders[2], folders[0], folders[1]]; // Third, First, Second
-
-		await callMethod(request, 'wiki.api.wiki_space.reorder_wiki_documents', {
-			doc_name: folders[2],
-			new_parent: rootGroup.name,
-			new_index: 0,
-			siblings: JSON.stringify(newOrder),
-		});
+		await moveToFront(request, space, names, 2);
 
-		// Refresh admin view
 		await page.reload();
 		await page.waitForLoadState('networkidle');
+		expect(await orderIn(page, 'aside', names)).toEqual(reordered);
 
-		const adminOrderAfter = await getOrder();
-		expect(adminOrderAfter).toEqual(['Third', 'First', 'Second']);
-
-		// Check public view
-		await page.goto(`/${spaceName}/third/content`);
+		await page.goto(`/${space.page('Third Page').route}`);
 		await page.waitForLoadState('networkidle');
-		// Wait for sidebar to render
-		await page.waitForTimeout(1000);
-
-		const getPublicOrder = async () => {
-			const pageText = await page.locator('body').innerText();
-			const order: string[] = [];
-			for (const name of folderNames) {
-				if (pageText.includes(name) && !order.includes(name)) {
-					order.push(name);
-				}
-			}
-			order.sort((a, b) => pageText.indexOf(a) - pageText.indexOf(b));
-			return order;
-		};
-
-		const publicOrder = await getPublicOrder();
-		expect(publicOrder).toEqual(['Third', 'First', 'Second']);
+		await expect(page.locator('.wiki-sidebar')).toBeVisible();
+		expect(await orderIn(page, 'body', names)).toEqual(reordered);
 	});
 });
diff --git a/e2e/tests/page-actions-ai-url.spec.ts b/e2e/tests/page-actions-ai-url.spec.ts
index 39615577e..7124f0fe1 100644
--- a/e2e/tests/page-actions-ai-url.spec.ts
+++ b/e2e/tests/page-actions-ai-url.spec.ts
@@ -1,13 +1,12 @@
-import { expect, test } from '@playwright/test';
+import { expect, test } from '../fixtures';
 import { getList } from '../helpers/frappe';
-import {
-	APP_BASE,
-	CHANGE_REQUEST_URL_RE,
-	spaceLinkSelector,
-} from '../helpers/routes';
+import { CHANGE_REQUEST_URL_RE } from '../helpers/routes';
 import {
 	clickSidebarAddOption,
+	currentDraftDocKey,
+	openNewPageDialog,
 	publishChangeRequestFromReview,
+	saveEditor,
 } from '../helpers/wiki';
 
 interface WikiDocumentRoute {
@@ -43,29 +42,19 @@ test.describe('Page actions – AI link URL', () => {
 	test('Open in ChatGPT uses the current page URL after sidebar navigation', async ({
 		page,
 		request,
+		wiki,
 	}) => {
 		await page.setViewportSize({ width: 1100, height: 900 });
 
-		await page.goto(APP_BASE);
-		await page.waitForLoadState('networkidle');
-
-		const spaceLink = page.locator(spaceLinkSelector()).first();
-		await expect(spaceLink).toBeVisible({ timeout: 5000 });
-		await spaceLink.click();
+		const space = await wiki.space();
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
 
 		const editor = page.locator('.ProseMirror, [contenteditable="true"]');
 
 		// --- Create and fill the first page ---
 		const firstPageTitle = `ai-url-first-${Date.now()}`;
-		const createFirstPage = page.locator(
-			'button:has-text("Create First Page")',
-		);
-		if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) {
-			await createFirstPage.click();
-		} else {
-			await clickSidebarAddOption(page, 'New Page');
-		}
+		await openNewPageDialog(page);
 		await page.getByLabel('Title').fill(firstPageTitle);
 		await page
 			.getByRole('dialog')
@@ -77,10 +66,7 @@ test.describe('Page actions – AI link URL', () => {
 			.locator('aside')
 			.getByText(firstPageTitle, { exact: true })
 			.click();
-		await page.waitForURL(/\/draft\/[^/?#]+/);
-		const firstDocKey = decodeURIComponent(
-			page.url().match(/\/draft\/([^/?#]+)/)?.[1] ?? '',
-		);
+		const firstDocKey = await currentDraftDocKey(page);
 		expect(firstDocKey).not.toBe('');
 
 		await expect(editor).toBeVisible({ timeout: 10000 });
@@ -94,7 +80,7 @@ test.describe('Page actions – AI link URL', () => {
 		});
 		await editor.click();
 		await page.waitForTimeout(500);
-		await page.click('button:has-text("Save")');
+		await saveEditor(page);
 		await page.waitForLoadState('networkidle');
 		await page.waitForTimeout(2000);
 
@@ -112,10 +98,7 @@ test.describe('Page actions – AI link URL', () => {
 			.locator('aside')
 			.getByText(secondPageTitle, { exact: true })
 			.click();
-		await page.waitForURL(/\/draft\/[^/?#]+/);
-		const secondDocKey = decodeURIComponent(
-			page.url().match(/\/draft\/([^/?#]+)/)?.[1] ?? '',
-		);
+		const secondDocKey = await currentDraftDocKey(page);
 		expect(secondDocKey).not.toBe('');
 
 		await expect(editor).toBeVisible({ timeout: 10000 });
@@ -131,7 +114,7 @@ test.describe('Page actions – AI link URL', () => {
 		});
 		await editor.click();
 		await page.waitForTimeout(500);
-		await page.click('button:has-text("Save")');
+		await saveEditor(page);
 		await page.waitForLoadState('networkidle');
 		await page.waitForTimeout(2000);
 
diff --git a/e2e/tests/page-route-editable.spec.ts b/e2e/tests/page-route-editable.spec.ts
index e7611bf24..76d203d95 100644
--- a/e2e/tests/page-route-editable.spec.ts
+++ b/e2e/tests/page-route-editable.spec.ts
@@ -1,5 +1,4 @@
-import { expect, test } from '@playwright/test';
-import { APP_BASE, spaceLinkSelector } from '../helpers/routes';
+import { expect, test } from '../fixtures';
 import { openNewPageDialog } from '../helpers/wiki';
 
 /**
@@ -9,15 +8,12 @@ import { openNewPageDialog } from '../helpers/wiki';
 test.describe('Editable page route', () => {
 	test('prefills the route from the title, then lets the author take it over', async ({
 		page,
+		wiki,
 	}) => {
 		await page.setViewportSize({ width: 1100, height: 900 });
 
-		await page.goto(APP_BASE);
-		await page.waitForLoadState('networkidle');
-
-		const spaceLink = page.locator(spaceLinkSelector()).first();
-		await expect(spaceLink).toBeVisible({ timeout: 5000 });
-		await spaceLink.click();
+		const space = await wiki.space();
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
 
 		await openNewPageDialog(page);
@@ -40,11 +36,17 @@ test.describe('Editable page route', () => {
 		await expect(routeField).toHaveValue(customRoute);
 
 		await dialog.getByRole('button', { name: 'Save' }).click();
-		await page.waitForLoadState('networkidle');
-
-		// The draft panel reports the route the author chose, not a derived one.
-		await expect(page.locator(`text=/${customRoute}`).first()).toBeVisible({
-			timeout: 10000,
-		});
+		await page.waitForURL(/\/draft\//, { timeout: 15000 });
+
+		// The draft carries the route the author chose, not one derived from the
+		// title they typed last. A draft lives only in the local-first store
+		// until it is merged, so there is no document to query — the tree's
+		// search, which matches a page's route as well as its title, is what
+		// can see it.
+		const search = page.getByPlaceholder('Search pages...');
+		await search.fill(customRoute);
+		await expect(
+			page.locator('aside').getByText('Route Gamma', { exact: true }),
+		).toBeVisible({ timeout: 10000 });
 	});
 });
diff --git a/e2e/tests/page-settings-meta.spec.ts b/e2e/tests/page-settings-meta.spec.ts
index a814b3c6e..d3c095f56 100644
--- a/e2e/tests/page-settings-meta.spec.ts
+++ b/e2e/tests/page-settings-meta.spec.ts
@@ -1,85 +1,56 @@
-import { expect, test } from '@playwright/test';
-import { getDoc } from '../helpers/frappe';
-import { appUrl } from '../helpers/routes';
-import {
-	type WikiDocument,
-	type WikiSpace,
-	cleanupWikiSpacesByRoute,
-	createTestWikiDocument,
-	createTestWikiSpace,
-} from '../helpers/wiki';
+import { expect, test } from '../fixtures';
+import type { SeededPage, SeededSpace } from '../helpers/factory';
 
 /**
- * Page Settings dialog (per-page SEO meta fields).
+ * Page settings panel (per-page SEO meta fields).
  *
- * The dialog lives behind the page-header "More actions" dropdown in the
- * editor SPA and saves `meta_title` / `meta_description` (+ `meta_image`,
- * not covered here — see spec) directly on the Wiki Document. This spec
- * covers the tracer path end to end: open the dialog, save, reopen to
- * confirm persistence, then verify the public page head actually emits the
- * og/description/canonical tags — and falls back to the page title when the
- * fields are cleared.
+ * The panel is toggled from the page header in the editor SPA and saves
+ * `meta_title` / `meta_description` (+ `meta_image`, not covered here — see
+ * spec) directly on the Wiki Document. This spec covers the tracer path end
+ * to end: open the panel, save, reopen to confirm persistence, then verify
+ * the public page head actually emits the og/description/canonical tags —
+ * and falls back to the page title when the fields are cleared.
  */
-test.describe('Page Settings meta fields', () => {
-	const route = `meta-fields-${Date.now()}`;
-	let space: WikiSpace;
-	let doc: WikiDocument;
+test.describe('Page settings meta fields', () => {
+	let space: SeededSpace;
+	let doc: SeededPage;
 
-	test.beforeAll(async ({ request }) => {
-		// createTestWikiSpace auto-creates a root_group document
-		// (Wiki Space.before_insert) when one isn't supplied — reuse it as the
-		// page's parent instead of creating a second one. Wiki_space on a
-		// document gets re-stamped from the tree (walking parent_wiki_document
-		// up to the space's root_group), so a page parented outside that tree
-		// would end up with a stale wiki_space and get orphaned by the space's
-		// on_trash cascade delete.
-		space = await createTestWikiSpace(request, { route, is_published: true });
-		const spaceDoc = await getDoc<{ root_group: string }>(
-			request,
-			'Wiki Space',
-			space.name,
-		);
-		doc = await createTestWikiDocument(request, {
-			title: 'Meta Fields Page',
-			route: `${route}/meta-page`,
-			is_published: true,
-			wiki_space: space.name,
-			parent_wiki_document: spaceDoc.root_group,
-		});
-	});
-
-	test.afterAll(async ({ request }) => {
-		await cleanupWikiSpacesByRoute(request, route);
+	test.beforeAll(async ({ wikiSuite }) => {
+		space = await wikiSuite.space({ pages: [{ title: 'Meta Fields Page' }] });
+		doc = space.page('Meta Fields Page');
 	});
 
-	test('saves meta fields from the dialog, persists them, and reflects on the public page', async ({
+	test('saves meta fields from the panel, persists them, and reflects on the public page', async ({
 		page,
 	}) => {
 		const metaTitle = `Meta Title ${Date.now()}`;
 		const metaDescription = 'A hand-written meta description for e2e coverage.';
 
 		await page.setViewportSize({ width: 1200, height: 900 });
-		await page.goto(appUrl('spaces', space.name, 'page', doc.name));
+		await page.goto(space.url('page', doc.name));
 		await expect(page.getByPlaceholder('Page title')).toHaveValue(doc.title, {
 			timeout: 15000,
 		});
 
-		// Open the dialog from the page-header "More actions" dropdown.
-		await page.getByRole('button', { name: 'More actions' }).click();
-		await page.getByRole('menuitem', { name: 'Page settings' }).click();
-
-		const dialog = page.getByRole('dialog');
-		await expect(dialog).toBeVisible({ timeout: 10000 });
-		await expect(dialog.getByText('Page Settings')).toBeVisible();
+		// The panel is a header toggle, not a menu item.
+		const panel = page.getByTestId('page-settings-panel');
+		// `exact` matters: the panel's own close button is "Close page settings",
+		// which a substring match would pick up too.
+		const settingsToggle = page.getByRole('button', {
+			name: 'Page settings',
+			exact: true,
+		});
+		await settingsToggle.click();
+		await expect(panel).toBeVisible({ timeout: 10000 });
 
-		const saveButton = dialog.getByRole('button', {
+		const saveButton = panel.getByRole('button', {
 			name: 'Save',
 			exact: true,
 		});
-		const metaTitleInput = dialog.getByLabel('Meta Title');
-		const metaDescriptionInput = dialog.getByLabel('Meta Description');
+		const metaTitleInput = panel.getByLabel('Meta title');
+		const metaDescriptionInput = panel.getByLabel('Meta description');
 
-		// Clean, freshly-opened dialog: nothing to save yet.
+		// Clean, freshly-opened panel: nothing to save yet.
 		await expect(saveButton).toBeDisabled();
 
 		await metaTitleInput.fill(metaTitle);
@@ -87,24 +58,22 @@ test.describe('Page Settings meta fields', () => {
 		await expect(saveButton).toBeEnabled();
 
 		await saveButton.click();
-		// The dialog deliberately stays open on save: saving is what regenerates
-		// the social preview, so closing would hide the thing that just changed.
-		// Save disabling itself is the signal the write landed.
+		// Save disabling itself is the signal the write landed: the panel is a
+		// form measured against the saved values, so a clean form is a saved one.
 		await expect(saveButton).toBeDisabled({ timeout: 10000 });
-		await expect(dialog).toBeVisible();
-		await dialog.getByRole('button', { name: 'Cancel' }).click();
-		await expect(dialog).not.toBeVisible({ timeout: 10000 });
+		await expect(panel).toBeVisible();
+		await panel.getByRole('button', { name: 'Close page settings' }).click();
+		await expect(panel).not.toBeVisible({ timeout: 10000 });
 
 		// Reopen — values must have persisted to the Wiki Document.
-		await page.getByRole('button', { name: 'More actions' }).click();
-		await page.getByRole('menuitem', { name: 'Page settings' }).click();
-		await expect(dialog).toBeVisible({ timeout: 10000 });
-		await expect(dialog.getByLabel('Meta Title')).toHaveValue(metaTitle);
-		await expect(dialog.getByLabel('Meta Description')).toHaveValue(
+		await settingsToggle.click();
+		await expect(panel).toBeVisible({ timeout: 10000 });
+		await expect(panel.getByLabel('Meta title')).toHaveValue(metaTitle);
+		await expect(panel.getByLabel('Meta description')).toHaveValue(
 			metaDescription,
 		);
-		await dialog.getByRole('button', { name: 'Cancel' }).click();
-		await expect(dialog).not.toBeVisible({ timeout: 10000 });
+		await panel.getByRole('button', { name: 'Close page settings' }).click();
+		await expect(panel).not.toBeVisible({ timeout: 10000 });
 
 		// Public page head: og:title carries the meta title, a plain
 		// description meta tag is present, and the canonical link is emitted.
@@ -125,20 +94,19 @@ test.describe('Page Settings meta fields', () => {
 		// Clear both fields — the public page must fall back to the page
 		// title in og:title, and drop the now-empty description tag rather
 		// than emit an empty one.
-		await page.goto(appUrl('spaces', space.name, 'page', doc.name));
+		await page.goto(space.url('page', doc.name));
 		await expect(page.getByPlaceholder('Page title')).toHaveValue(doc.title, {
 			timeout: 15000,
 		});
-		await page.getByRole('button', { name: 'More actions' }).click();
-		await page.getByRole('menuitem', { name: 'Page settings' }).click();
-		await expect(dialog).toBeVisible({ timeout: 10000 });
-		await dialog.getByLabel('Meta Title').fill('');
-		await dialog.getByLabel('Meta Description').fill('');
+		await settingsToggle.click();
+		await expect(panel).toBeVisible({ timeout: 10000 });
+		await panel.getByLabel('Meta title').fill('');
+		await panel.getByLabel('Meta description').fill('');
 		await expect(saveButton).toBeEnabled();
 		await saveButton.click();
 		await expect(saveButton).toBeDisabled({ timeout: 10000 });
-		await dialog.getByRole('button', { name: 'Cancel' }).click();
-		await expect(dialog).not.toBeVisible({ timeout: 10000 });
+		await panel.getByRole('button', { name: 'Close page settings' }).click();
+		await expect(panel).not.toBeVisible({ timeout: 10000 });
 
 		await page.goto(`/${doc.route}`);
 		await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
diff --git a/e2e/tests/public-blank-lines.spec.ts b/e2e/tests/public-blank-lines.spec.ts
index ee8b7bbcf..a691b7c7a 100644
--- a/e2e/tests/public-blank-lines.spec.ts
+++ b/e2e/tests/public-blank-lines.spec.ts
@@ -1,11 +1,4 @@
-import { expect, test } from '@playwright/test';
-import { updateDoc } from '../helpers/frappe';
-import {
-	createTestWikiDocument,
-	createTestWikiSpace,
-	deleteTestWikiDocument,
-	deleteTestWikiSpace,
-} from '../helpers/wiki';
+import { expect, test } from '../fixtures';
 
 /**
  * prose-v3 zeroes paragraph margins: blank lines the author typed are the only
@@ -18,59 +11,36 @@ const CONTENT = 'First paragraph\n\n\n\n\n\nSecond paragraph';
 test.describe('Public page blank lines', () => {
 	test('renders author blank lines as gaps with real height', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		const spaceRoute = `blank-lines-space-${Date.now()}`;
-		const space = await createTestWikiSpace(request, {
-			route: spaceRoute,
-			is_published: true,
-		});
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceRoute}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-		const doc = await createTestWikiDocument(request, {
-			title: 'Blank Lines Page',
-			route: `${spaceRoute}/blank-lines`,
-			content: CONTENT,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
+		const space = await wiki.space({
+			pages: [{ title: 'Blank Lines Page', content: CONTENT }],
 		});
+		const doc = space.page('Blank Lines Page');
 
-		try {
-			await page.goto(`/${doc.route}`);
-			await page.waitForLoadState('networkidle');
+		await page.goto(`/${doc.route}`);
+		await page.waitForLoadState('networkidle');
 
-			// Two extra blank lines in the markdown => two blank paragraphs.
-			const blanks = page.locator('#wiki-content .wiki-blank-line');
-			await expect(blanks).toHaveCount(2);
+		// Two extra blank lines in the markdown => two blank paragraphs.
+		const blanks = page.locator('#wiki-content .wiki-blank-line');
+		await expect(blanks).toHaveCount(2);
 
-			for (const blank of await blanks.all()) {
-				const box = await blank.boundingBox();
-				expect(box?.height ?? 0).toBeGreaterThan(0);
-			}
+		for (const blank of await blanks.all()) {
+			const box = await blank.boundingBox();
+			expect(box?.height ?? 0).toBeGreaterThan(0);
+		}
 
-			const first = await page
-				.locator('#wiki-content p', { hasText: 'First paragraph' })
-				.first()
-				.boundingBox();
-			const second = await page
-				.locator('#wiki-content p', { hasText: 'Second paragraph' })
-				.first()
-				.boundingBox();
+		const first = await page
+			.locator('#wiki-content p', { hasText: 'First paragraph' })
+			.first()
+			.boundingBox();
+		const second = await page
+			.locator('#wiki-content p', { hasText: 'Second paragraph' })
+			.first()
+			.boundingBox();
 
-			// Without the blank paragraphs the two would sit flush (~one line apart).
-			const delta = (second?.y ?? 0) - ((first?.y ?? 0) + (first?.height ?? 0));
-			expect(delta).toBeGreaterThan(30);
-		} finally {
-			await deleteTestWikiDocument(request, doc.name).catch(() => {});
-			await deleteTestWikiDocument(request, rootGroup.name).catch(() => {});
-			await deleteTestWikiSpace(request, space.name).catch(() => {});
-		}
+		// Without the blank paragraphs the two would sit flush (~one line apart).
+		const delta = (second?.y ?? 0) - ((first?.y ?? 0) + (first?.height ?? 0));
+		expect(delta).toBeGreaterThan(30);
 	});
 });
diff --git a/e2e/tests/public-embed-layout.spec.ts b/e2e/tests/public-embed-layout.spec.ts
index 7af523fbd..a1a563e7d 100644
--- a/e2e/tests/public-embed-layout.spec.ts
+++ b/e2e/tests/public-embed-layout.spec.ts
@@ -1,11 +1,4 @@
-import { expect, test } from '@playwright/test';
-import { updateDoc } from '../helpers/frappe';
-import {
-	createTestWikiDocument,
-	createTestWikiSpace,
-	deleteTestWikiDocument,
-	deleteTestWikiSpace,
-} from '../helpers/wiki';
+import { expect, test } from '../fixtures';
 
 /**
  * Authors paste whatever dimensions the provider's share dialog hands them, and
@@ -25,60 +18,37 @@ const CONTENT =
 test.describe('Public reader embed layout', () => {
 	test('renders a mis-sized embed as a centered 16:9 box', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		const spaceRoute = `embed-layout-space-${Date.now()}`;
-		const space = await createTestWikiSpace(request, {
-			route: spaceRoute,
-			is_published: true,
-		});
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceRoute}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-		const doc = await createTestWikiDocument(request, {
-			title: 'Embed Page',
-			route: `${spaceRoute}/embed`,
-			content: CONTENT,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
+		const space = await wiki.space({
+			pages: [{ title: 'Embed Page', content: CONTENT }],
 		});
+		const doc = space.page('Embed Page');
 
-		try {
-			// Wide enough that the 720px cap leaves visible slack on both sides,
-			// so the centering assertion below is actually measuring something.
-			await page.setViewportSize({ width: 1600, height: 900 });
-			await page.goto(`/${doc.route}`);
-			await page.waitForLoadState('networkidle');
+		// Wide enough that the 720px cap leaves visible slack on both sides,
+		// so the centering assertion below is actually measuring something.
+		await page.setViewportSize({ width: 1600, height: 900 });
+		await page.goto(`/${doc.route}`);
+		await page.waitForLoadState('networkidle');
 
-			const frame = page.locator('#wiki-content iframe');
-			await expect(frame).toBeVisible();
+		const frame = page.locator('#wiki-content iframe');
+		await expect(frame).toBeVisible();
 
-			const box = await frame.boundingBox();
-			const column = await page.locator('#wiki-content').boundingBox();
-			expect(box).not.toBeNull();
-			expect(column).not.toBeNull();
-			if (!box || !column) return;
+		const box = await frame.boundingBox();
+		const column = await page.locator('#wiki-content').boundingBox();
+		expect(box).not.toBeNull();
+		expect(column).not.toBeNull();
+		if (!box || !column) return;
 
-			// The authored 800px must not win over the column.
-			expect(box.width).toBeLessThanOrEqual(720);
+		// The authored 800px must not win over the column.
+		expect(box.width).toBeLessThanOrEqual(720);
 
-			// The bars are gone only if the box is exactly 16:9.
-			expect(Math.abs(box.width / box.height - 16 / 9)).toBeLessThan(0.02);
+		// The bars are gone only if the box is exactly 16:9.
+		expect(Math.abs(box.width / box.height - 16 / 9)).toBeLessThan(0.02);
 
-			const leftGap = box.x - column.x;
-			const rightGap = column.x + column.width - (box.x + box.width);
-			expect(leftGap).toBeGreaterThan(0);
-			expect(Math.abs(leftGap - rightGap)).toBeLessThan(2);
-		} finally {
-			await deleteTestWikiDocument(request, doc.name).catch(() => {});
-			await deleteTestWikiDocument(request, rootGroup.name).catch(() => {});
-			await deleteTestWikiSpace(request, space.name).catch(() => {});
-		}
+		const leftGap = box.x - column.x;
+		const rightGap = column.x + column.width - (box.x + box.width);
+		expect(leftGap).toBeGreaterThan(0);
+		expect(Math.abs(leftGap - rightGap)).toBeLessThan(2);
 	});
 });
diff --git a/e2e/tests/public-pages.spec.ts b/e2e/tests/public-pages.spec.ts
index 82962cf9d..7ef043109 100644
--- a/e2e/tests/public-pages.spec.ts
+++ b/e2e/tests/public-pages.spec.ts
@@ -1,13 +1,11 @@
-import { expect, test } from '@playwright/test';
+import { expect, test } from '../fixtures';
 import { getList } from '../helpers/frappe';
+import { CHANGE_REQUEST_URL_RE } from '../helpers/routes';
 import {
-	APP_BASE,
-	CHANGE_REQUEST_URL_RE,
-	spaceLinkSelector,
-} from '../helpers/routes';
-import {
+	currentDraftDocKey,
 	openNewPageDialog,
 	publishChangeRequestFromReview,
+	saveEditor,
 } from '../helpers/wiki';
 interface WikiDocumentRoute {
 	route: string;
@@ -38,17 +36,13 @@ test.describe('Public Wiki Pages', () => {
 		test('should render TOC with correct headings on published page', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			// Use wider viewport to see TOC (lg breakpoint = 1024px)
 			await page.setViewportSize({ width: 1100, height: 900 });
 
-			// Navigate to wiki and click first space
-			await page.goto(APP_BASE);
-			await page.waitForLoadState('networkidle');
-
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			await expect(spaceLink).toBeVisible({ timeout: 5000 });
-			await spaceLink.click();
+			const space = await wiki.space();
+			await page.goto(space.url());
 			await page.waitForLoadState('networkidle');
 			// Create a new page with multiple headings
 
@@ -68,9 +62,7 @@ test.describe('Public Wiki Pages', () => {
 			// Open the newly created page from the tree
 			await page.locator('aside').getByText(pageTitle, { exact: true }).click();
 			await page.waitForURL(/\/draft\/[^/?#]+/);
-			const draftMatch = page.url().match(/\/draft\/([^/?#]+)/);
-			expect(draftMatch).toBeTruthy();
-			const docKey = decodeURIComponent(draftMatch?.[1] ?? '');
+			const docKey = await currentDraftDocKey(page);
 
 			// Wait for editor to be visible and ready
 			const editor = page.locator('.ProseMirror, [contenteditable="true"]');
@@ -124,7 +116,7 @@ That is all.`;
 			await page.waitForTimeout(500);
 
 			// Save the draft
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 			// Wait for save to complete in database
 			await page.waitForTimeout(2000);
@@ -194,37 +186,24 @@ That is all.`;
 			await publicPage.close();
 		});
 
-		test('should hide TOC on mobile viewport', async ({ page }) => {
-			// Navigate to an existing published page at mobile viewport
+		test('should hide TOC on mobile viewport', async ({ page, wiki }) => {
 			await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
 
-			await page.goto(APP_BASE);
+			// A published page of our own: the reader chrome under test only
+			// renders on a real page, and an ambient one may not exist.
+			const space = await wiki.space({
+				pages: [{ title: 'Reader Page', content: '## Section\n\nBody text.' }],
+			});
+			await page.goto(`/${space.page('Reader Page').route}`);
 			await page.waitForLoadState('networkidle');
 
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			if (await spaceLink.isVisible({ timeout: 3000 }).catch(() => false)) {
-				await spaceLink.click();
-				await page.waitForLoadState('networkidle');
-
-				// Try to find a published page link in sidebar
-				const pageLink = page.locator('aside a[href^="/"]').first();
-				if (await pageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
-					const href = await pageLink.getAttribute('href');
-					if (href) {
-						// Navigate to the public page directly
-						await page.goto(href);
-						await page.waitForLoadState('networkidle');
-
-						// TOC aside should NOT be visible on mobile
-						const tocAside = page.locator('aside').filter({
-							has: page.locator('text=On this page'),
-						});
-
-						// Should be hidden (lg:block means hidden below lg)
-						await expect(tocAside).not.toBeVisible();
-					}
-				}
-			}
+			// TOC aside should NOT be visible on mobile
+			const tocAside = page.locator('aside').filter({
+				has: page.locator('text=On this page'),
+			});
+
+			// Should be hidden (lg:block means hidden below lg)
+			await expect(tocAside).not.toBeVisible();
 		});
 	});
 
@@ -232,15 +211,12 @@ That is all.`;
 		test('should show hash link on heading hover in public page', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			await page.setViewportSize({ width: 1100, height: 900 });
 
-			await page.goto(APP_BASE);
-			await page.waitForLoadState('networkidle');
-
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			await expect(spaceLink).toBeVisible({ timeout: 5000 });
-			await spaceLink.click();
+			const space = await wiki.space();
+			await page.goto(space.url());
 			await page.waitForLoadState('networkidle');
 
 			const pageTitle = `anchor-test-page-${Date.now()}`;
@@ -256,9 +232,7 @@ That is all.`;
 
 			await page.locator('aside').getByText(pageTitle, { exact: true }).click();
 			await page.waitForURL(/\/draft\/[^/?#]+/);
-			const draftMatch = page.url().match(/\/draft\/([^/?#]+)/);
-			expect(draftMatch).toBeTruthy();
-			const docKey = decodeURIComponent(draftMatch?.[1] ?? '');
+			const docKey = await currentDraftDocKey(page);
 
 			const editor = page.locator('.ProseMirror, [contenteditable="true"]');
 			await expect(editor).toBeVisible({ timeout: 10000 });
@@ -291,7 +265,7 @@ End.`;
 			await editor.click();
 			await page.waitForTimeout(500);
 
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 			await page.waitForTimeout(2000);
 
@@ -337,57 +311,36 @@ End.`;
 	});
 
 	test.describe('Sidebar', () => {
-		test('should show sidebar on desktop viewport', async ({ page }) => {
+		test('should show sidebar on desktop viewport', async ({ page, wiki }) => {
 			await page.setViewportSize({ width: 1100, height: 900 });
 
-			await page.goto(APP_BASE);
+			// A published page of our own: the reader chrome under test only
+			// renders on a real page, and an ambient one may not exist.
+			const space = await wiki.space({
+				pages: [{ title: 'Reader Page', content: '## Section\n\nBody text.' }],
+			});
+			await page.goto(`/${space.page('Reader Page').route}`);
 			await page.waitForLoadState('networkidle');
 
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			if (await spaceLink.isVisible({ timeout: 3000 }).catch(() => false)) {
-				await spaceLink.click();
-				await page.waitForLoadState('networkidle');
-
-				// Find a published page and navigate to it
-				const pageLink = page.locator('aside a[href^="/"]').first();
-				if (await pageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
-					const href = await pageLink.getAttribute('href');
-					if (href) {
-						await page.goto(href);
-						await page.waitForLoadState('networkidle');
-
-						// Sidebar should be visible on desktop
-						const sidebar = page.locator('.wiki-sidebar, aside nav').first();
-						await expect(sidebar).toBeVisible();
-					}
-				}
-			}
+			// Sidebar should be visible on desktop
+			const sidebar = page.locator('.wiki-sidebar, aside nav').first();
+			await expect(sidebar).toBeVisible();
 		});
 
-		test('should hide sidebar on mobile viewport', async ({ page }) => {
+		test('should hide sidebar on mobile viewport', async ({ page, wiki }) => {
 			await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
 
-			await page.goto(APP_BASE);
+			// A published page of our own: the reader chrome under test only
+			// renders on a real page, and an ambient one may not exist.
+			const space = await wiki.space({
+				pages: [{ title: 'Reader Page', content: '## Section\n\nBody text.' }],
+			});
+			await page.goto(`/${space.page('Reader Page').route}`);
 			await page.waitForLoadState('networkidle');
 
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			if (await spaceLink.isVisible({ timeout: 3000 }).catch(() => false)) {
-				await spaceLink.click();
-				await page.waitForLoadState('networkidle');
-
-				const pageLink = page.locator('aside a[href^="/"]').first();
-				if (await pageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
-					const href = await pageLink.getAttribute('href');
-					if (href) {
-						await page.goto(href);
-						await page.waitForLoadState('networkidle');
-
-						// Desktop sidebar should be hidden on mobile
-						const desktopSidebar = page.locator('.wiki-sidebar');
-						await expect(desktopSidebar).not.toBeVisible();
-					}
-				}
-			}
+			// Desktop sidebar should be hidden on mobile
+			const desktopSidebar = page.locator('.wiki-sidebar');
+			await expect(desktopSidebar).not.toBeVisible();
 		});
 	});
 });
diff --git a/e2e/tests/search-modal.spec.ts b/e2e/tests/search-modal.spec.ts
index f620bc16c..1ae22f12f 100644
--- a/e2e/tests/search-modal.spec.ts
+++ b/e2e/tests/search-modal.spec.ts
@@ -1,11 +1,4 @@
-import { expect, test } from '@playwright/test';
-import { updateDoc } from '../helpers/frappe';
-import {
-	createTestWikiDocument,
-	createTestWikiSpace,
-	deleteTestWikiDocument,
-	deleteTestWikiSpace,
-} from '../helpers/wiki';
+import { expect, test } from '../fixtures';
 
 /**
  * Tests for the public reader search modal (search_modal.html).
@@ -28,38 +21,22 @@ const SEARCH_INPUT = 'input[placeholder="Search documentation"]';
 test.describe('Search Modal', () => {
 	test('keyboard navigation moves one result per keypress across reopens', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		const timestamp = Date.now();
-		const spaceRoute = `search-modal-space-${timestamp}`;
-
-		const space = await createTestWikiSpace(request, {
-			route: spaceRoute,
-			is_published: true,
-		});
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceRoute}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-		const hostPage = await createTestWikiDocument(request, {
-			title: `Search Host ${timestamp}`,
-			route: `${spaceRoute}/search-host`,
-			content: 'Host page for the search modal test.',
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-		const targetPage = await createTestWikiDocument(request, {
-			title: `Search Target ${timestamp}`,
-			route: `${spaceRoute}/search-target`,
-			content: 'Target page the second result points at.',
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
+		const space = await wiki.space({
+			pages: [
+				{
+					title: 'Search Host',
+					content: 'Host page for the search modal test.',
+				},
+				{
+					title: 'Search Target',
+					content: 'Target page the second result points at.',
+				},
+			],
 		});
+		const hostPage = space.page('Search Host');
+		const targetPage = space.page('Search Target');
 
 		await page.route(SEARCH_API, (route) =>
 			route.fulfill({
@@ -69,21 +46,21 @@ test.describe('Search Modal', () => {
 							{
 								name: 'stub-1',
 								title: 'First stub result',
-								route: `${spaceRoute}/search-host`,
+								route: hostPage.route,
 								content: 'Snippet for the first stub.',
 								score: 3,
 							},
 							{
 								name: 'stub-2',
 								title: 'Second stub result',
-								route: `${spaceRoute}/search-target`,
+								route: targetPage.route,
 								content: 'Snippet for the second stub.',
 								score: 2,
 							},
 							{
 								name: 'stub-3',
 								title: 'Third stub result',
-								route: `${spaceRoute}/search-host`,
+								route: hostPage.route,
 								content: 'Snippet for the third stub.',
 								score: 1,
 							},
@@ -94,42 +71,35 @@ test.describe('Search Modal', () => {
 			}),
 		);
 
-		try {
-			await page.setViewportSize({ width: 1280, height: 800 });
-			await page.goto(`/${hostPage.route}`);
-			await page.waitForLoadState('networkidle');
+		await page.setViewportSize({ width: 1280, height: 800 });
+		await page.goto(`/${hostPage.route}`);
+		await page.waitForLoadState('networkidle');
 
-			const searchInput = page.locator(SEARCH_INPUT);
-			const results = page.getByTestId('search-result');
+		const searchInput = page.locator(SEARCH_INPUT);
+		const results = page.getByTestId('search-result');
 
-			// First open: results render for the query
-			await page.getByRole('button', { name: 'Open search' }).click();
-			await expect(searchInput).toBeVisible();
-			await searchInput.fill('stub');
-			await expect(results).toHaveCount(3);
+		// First open: results render for the query
+		await page.getByRole('button', { name: 'Open search' }).click();
+		await expect(searchInput).toBeVisible();
+		await searchInput.fill('stub');
+		await expect(results).toHaveCount(3);
 
-			// Close and reopen — a leaked keydown listener from the first open
-			// would now double every arrow-key step
-			await page.keyboard.press('Escape');
-			await expect(searchInput).toBeHidden();
-			await page.getByRole('button', { name: 'Open search' }).click();
-			await searchInput.fill('stub');
-			await expect(results).toHaveCount(3);
+		// Close and reopen — a leaked keydown listener from the first open
+		// would now double every arrow-key step
+		await page.keyboard.press('Escape');
+		await expect(searchInput).toBeHidden();
+		await page.getByRole('button', { name: 'Open search' }).click();
+		await searchInput.fill('stub');
+		await expect(results).toHaveCount(3);
 
-			// One ArrowDown must move the highlight exactly one row
-			await page.keyboard.press('ArrowDown');
-			await expect(results.nth(1)).toHaveClass(/surface-gray-3/);
-			await expect(results.nth(0)).not.toHaveClass(/surface-gray-3/);
-			await expect(results.nth(2)).not.toHaveClass(/surface-gray-3/);
+		// One ArrowDown must move the highlight exactly one row
+		await page.keyboard.press('ArrowDown');
+		await expect(results.nth(1)).toHaveClass(/surface-gray-3/);
+		await expect(results.nth(0)).not.toHaveClass(/surface-gray-3/);
+		await expect(results.nth(2)).not.toHaveClass(/surface-gray-3/);
 
-			// Enter navigates to the highlighted (second) result
-			await page.keyboard.press('Enter');
-			await page.waitForURL(`**/${targetPage.route}`, { timeout: 10000 });
-		} finally {
-			await deleteTestWikiDocument(request, targetPage.name).catch(() => {});
-			await deleteTestWikiDocument(request, hostPage.name).catch(() => {});
-			await deleteTestWikiDocument(request, rootGroup.name).catch(() => {});
-			await deleteTestWikiSpace(request, space.name).catch(() => {});
-		}
+		// Enter navigates to the highlighted (second) result
+		await page.keyboard.press('Enter');
+		await page.waitForURL(`**/${targetPage.route}`, { timeout: 10000 });
 	});
 });
diff --git a/e2e/tests/sidebar-drill-in.spec.ts b/e2e/tests/sidebar-drill-in.spec.ts
new file mode 100644
index 000000000..8c31f6f4f
--- /dev/null
+++ b/e2e/tests/sidebar-drill-in.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from '../fixtures';
+import { APP_BASE, appUrl } from '../helpers/routes';
+
+/**
+ * The IA refactor left the app with exactly one navigation column that drills.
+ * At the top level it is the library (every space); entering a space *replaces*
+ * that column with the space's own sidebar, and the back button restores it.
+ *
+ * Two spaces are built below because the load-bearing assertion is a negative
+ * one: inside space A, space B's row must be gone. A single-space fixture would
+ * pass even if the library list were merely appended to rather than replaced.
+ */
+
+const SPACE_A_NAME = 'Drill In Alpha';
+const SPACE_B_NAME = 'Drill In Beta';
+const PAGE_TITLE = 'Alpha First Page';
+
+test.describe('Sidebar drill-in navigation', () => {
+	let spaceA = '';
+	let spaceB = '';
+	let pageName = '';
+
+	test.beforeAll(async ({ wikiSuite }) => {
+		const a = await wikiSuite.space({
+			space_name: SPACE_A_NAME,
+			pages: [{ title: PAGE_TITLE, content: 'Drill-in fixture content.' }],
+		});
+		spaceA = a.name;
+		pageName = a.page(PAGE_TITLE).name;
+
+		spaceB = (await wikiSuite.space({ space_name: SPACE_B_NAME })).name;
+	});
+
+	test('library lists spaces, entering one replaces the column, back restores it', async ({
+		page,
+	}) => {
+		await page.setViewportSize({ width: 1440, height: 900 });
+
+		// Every assertion is scoped to the nav column. The Overview page in the
+		// content column lists the same spaces, so an unscoped href locator
+		// matches twice and proves nothing about which column holds the row.
+		const sidebar = page.locator('[data-slot="sidebar"]');
+
+		// Level 0: the library. Both spaces are rows; nothing is drilled into.
+		await page.goto(APP_BASE);
+		const alphaRow = sidebar.locator(`a[href="${appUrl('spaces', spaceA)}"]`);
+		const betaRow = sidebar.locator(`a[href="${appUrl('spaces', spaceB)}"]`);
+		await expect(alphaRow).toBeVisible();
+		await expect(betaRow).toBeVisible();
+		await expect(sidebar.locator('[title="Back to Overview"]')).toHaveCount(0);
+
+		// Level 1: the sidebar *becomes* space A.
+		await alphaRow.click();
+		await expect(page).toHaveURL(new RegExp(`${APP_BASE}/spaces/${spaceA}`));
+		await expect(sidebar.locator('[title="Back to Overview"]')).toBeVisible();
+		await expect(
+			sidebar.getByText(SPACE_A_NAME, { exact: true }).first(),
+		).toBeVisible();
+		// The replacement, not an addition: the sibling space is no longer
+		// reachable from this column.
+		await expect(betaRow).toHaveCount(0);
+
+		// Level 2: the tree in that column opens a page in the content column.
+		await sidebar.getByText(PAGE_TITLE, { exact: true }).first().click();
+		await expect(page).toHaveURL(
+			new RegExp(`${APP_BASE}/spaces/${spaceA}/page/${pageName}`),
+		);
+		// Drilling to a page keeps the space column — it does not drill again.
+		await expect(sidebar.locator('[title="Back to Overview"]')).toBeVisible();
+
+		// Back out: the library returns whole, with both spaces.
+		await sidebar.locator('[title="Back to Overview"]').first().click();
+		await expect(page).toHaveURL(new RegExp(`${APP_BASE}/?$`));
+		await expect(alphaRow).toBeVisible();
+		await expect(betaRow).toBeVisible();
+		await expect(sidebar.locator('[title="Back to Overview"]')).toHaveCount(0);
+	});
+
+	test('the retired /spaces list page redirects to the library', async ({
+		page,
+	}) => {
+		// Old deep links have to keep working: the list page retired in phase 1
+		// and its path now redirects rather than 404ing.
+		await page.goto(appUrl('spaces'));
+		await expect(page).toHaveURL(new RegExp(`${APP_BASE}/?$`));
+		const sidebar = page.locator('[data-slot="sidebar"]');
+		await expect(
+			sidebar.locator(`a[href="${appUrl('spaces', spaceA)}"]`),
+		).toBeVisible();
+	});
+});
diff --git a/e2e/tests/sidebar-reveal.spec.ts b/e2e/tests/sidebar-reveal.spec.ts
index 95e104bd9..97d1a0573 100644
--- a/e2e/tests/sidebar-reveal.spec.ts
+++ b/e2e/tests/sidebar-reveal.spec.ts
@@ -1,12 +1,5 @@
-import { expect, test } from '@playwright/test';
-import { updateDoc } from '../helpers/frappe';
-import {
-	type WikiDocument,
-	type WikiSpace,
-	cleanupWikiSpacesByRoute,
-	createTestWikiDocument,
-	createTestWikiSpace,
-} from '../helpers/wiki';
+import { expect, test } from '../fixtures';
+import type { SeededPage } from '../helpers/factory';
 
 /**
  * The reader sidebar must always reveal the current page: expand its ancestor
@@ -15,71 +8,39 @@ import {
  * prev/next buttons (see issue #685).
  */
 test.describe('Reader sidebar reveals current page', () => {
-	const spaceRoute = `sidebar-reveal-${Date.now()}`;
-	let space: WikiSpace;
-	let topPage: WikiDocument;
-	let deepPage: WikiDocument;
-	let lastFiller: WikiDocument;
+	let topPage: SeededPage;
+	let deepPage: SeededPage;
+	let lastFiller: SeededPage;
 
 	const FILLER_COUNT = 30;
 
-	test.beforeAll(async ({ request }) => {
-		space = await createTestWikiSpace(request, {
-			route: spaceRoute,
-			is_published: true,
+	test.beforeAll(async ({ wikiSuite }) => {
+		const fillerTitles = Array.from(
+			{ length: FILLER_COUNT },
+			(_, i) => `Filler Page ${String(i).padStart(2, '0')}`,
+		);
+		const space = await wikiSuite.space({
+			pages: [
+				{ title: 'Top Page' },
+				// Two collapsed levels above the page the sidebar has to reveal.
+				{
+					title: 'Outer Group',
+					is_group: true,
+					children: [
+						{
+							title: 'Inner Group',
+							is_group: true,
+							children: [{ title: 'Deep Page' }],
+						},
+					],
+				},
+				// Enough siblings after the deep page to overflow the viewport.
+				...fillerTitles.map((title) => ({ title })),
+			],
 		});
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${spaceRoute}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-
-		topPage = await createTestWikiDocument(request, {
-			title: 'Top Page',
-			route: `${spaceRoute}/top`,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-
-		// Group > Sub Group > Deep Page — two collapsed levels above the page
-		const group = await createTestWikiDocument(request, {
-			title: 'Outer Group',
-			route: `${spaceRoute}/outer`,
-			is_group: true,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-		const subGroup = await createTestWikiDocument(request, {
-			title: 'Inner Group',
-			route: `${spaceRoute}/outer/inner`,
-			is_group: true,
-			is_published: true,
-			parent_wiki_document: group.name,
-		});
-		deepPage = await createTestWikiDocument(request, {
-			title: 'Deep Page',
-			route: `${spaceRoute}/outer/inner/deep`,
-			is_published: true,
-			parent_wiki_document: subGroup.name,
-		});
-
-		// Enough siblings after the deep page to overflow the sidebar viewport
-		for (let i = 0; i < FILLER_COUNT; i++) {
-			lastFiller = await createTestWikiDocument(request, {
-				title: `Filler Page ${String(i).padStart(2, '0')}`,
-				route: `${spaceRoute}/filler-${String(i).padStart(2, '0')}`,
-				is_published: true,
-				parent_wiki_document: rootGroup.name,
-			});
-		}
-	});
-
-	test.afterAll(async ({ request }) => {
-		await cleanupWikiSpacesByRoute(request, spaceRoute);
+		topPage = space.page('Top Page');
+		deepPage = space.page('Deep Page');
+		lastFiller = space.page(fillerTitles[fillerTitles.length - 1]);
 	});
 
 	function sidebarLink(page: import('@playwright/test').Page, route: string) {
diff --git a/e2e/tests/sidebar.spec.ts b/e2e/tests/sidebar.spec.ts
index 791aa39a6..a4d02556b 100644
--- a/e2e/tests/sidebar.spec.ts
+++ b/e2e/tests/sidebar.spec.ts
@@ -1,14 +1,12 @@
-import { expect, test } from '@playwright/test';
+import { expect, test } from '../fixtures';
 import { getList } from '../helpers/frappe';
-import {
-	APP_BASE,
-	CHANGE_REQUEST_URL_RE,
-	spaceLinkSelector,
-} from '../helpers/routes';
+import { CHANGE_REQUEST_URL_RE } from '../helpers/routes';
 import {
 	clickSidebarAddOption,
+	currentDraftDocKey,
 	openNewPageDialog,
 	publishChangeRequestFromReview,
+	saveEditor,
 } from '../helpers/wiki';
 
 interface WikiDocumentRoute {
@@ -26,20 +24,14 @@ test.describe('Public Sidebar', () => {
 		test('should only display published pages in the public sidebar', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			await page.setViewportSize({ width: 1100, height: 900 });
 
-			// Navigate to wiki and click first space
-			await page.goto(APP_BASE);
-			await page.waitForLoadState('networkidle');
-
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			await expect(spaceLink).toBeVisible({ timeout: 5000 });
-			const spaceHref = await spaceLink.getAttribute('href');
-			expect(spaceHref).toBeTruthy();
-			await spaceLink.click();
+			const space = await wiki.space();
+			const spaceUrl = space.url();
+			await page.goto(spaceUrl);
 			await page.waitForLoadState('networkidle');
-			const spaceUrl = spaceHref as string;
 
 			// Create a published page inside the space
 
@@ -60,9 +52,7 @@ test.describe('Public Sidebar', () => {
 				.getByText(publishedPageTitle, { exact: true })
 				.click();
 			await page.waitForURL(/\/draft\/[^/?#]+/);
-			const draftMatch = page.url().match(/\/draft\/([^/?#]+)/);
-			expect(draftMatch).toBeTruthy();
-			const docKey = decodeURIComponent(draftMatch?.[1] ?? '');
+			const docKey = await currentDraftDocKey(page);
 
 			// Wait for editor and add content
 			const editor = page.locator('.ProseMirror, [contenteditable="true"]');
@@ -71,7 +61,7 @@ test.describe('Public Sidebar', () => {
 			await page.keyboard.type('This is published content.');
 
 			// Save the draft
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 
 			// Submit for review and merge the page
@@ -104,7 +94,7 @@ test.describe('Public Sidebar', () => {
 			await expect(editor).toBeVisible({ timeout: 10000 });
 			await editor.click();
 			await page.keyboard.type('This is unpublished content.');
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 
 			// Open public page for published content
@@ -147,16 +137,12 @@ test.describe('Public Sidebar', () => {
 		test('should use client-side navigation without full page refresh when clicking sidebar links', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			await page.setViewportSize({ width: 1100, height: 900 });
 
-			// Navigate to wiki and click first space
-			await page.goto(APP_BASE);
-			await page.waitForLoadState('networkidle');
-
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			await expect(spaceLink).toBeVisible({ timeout: 5000 });
-			await spaceLink.click();
+			const space = await wiki.space();
+			await page.goto(space.url());
 			await page.waitForLoadState('networkidle');
 
 			// Create two pages so we can navigate between them
@@ -176,14 +162,12 @@ test.describe('Public Sidebar', () => {
 				.getByText(firstPageTitle, { exact: true })
 				.click();
 			await page.waitForURL(/\/draft\/[^/?#]+/);
-			const draftMatch = page.url().match(/\/draft\/([^/?#]+)/);
-			expect(draftMatch).toBeTruthy();
-			const firstDocKey = decodeURIComponent(draftMatch?.[1] ?? '');
+			const firstDocKey = await currentDraftDocKey(page);
 			const editor = page.locator('.ProseMirror, [contenteditable="true"]');
 			await expect(editor).toBeVisible({ timeout: 10000 });
 			await editor.click();
 			await page.keyboard.type('First SPA nav test page.');
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 
 			const secondPageTitle = `spa-nav-second-${Date.now()}`;
@@ -202,7 +186,7 @@ test.describe('Public Sidebar', () => {
 			await expect(editor).toBeVisible({ timeout: 10000 });
 			await editor.click();
 			await page.keyboard.type('Second SPA nav test page.');
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 
 			// Merge both pages
@@ -272,16 +256,12 @@ test.describe('Public Sidebar', () => {
 		test('should update content, URL, active state, and metadata when clicking sidebar links', async ({
 			page,
 			request,
+			wiki,
 		}) => {
 			await page.setViewportSize({ width: 1100, height: 900 });
 
-			// Navigate to wiki and click first space
-			await page.goto(APP_BASE);
-			await page.waitForLoadState('networkidle');
-
-			const spaceLink = page.locator(spaceLinkSelector()).first();
-			await expect(spaceLink).toBeVisible({ timeout: 5000 });
-			await spaceLink.click();
+			const space = await wiki.space();
+			await page.goto(space.url());
 			await page.waitForLoadState('networkidle');
 			// Create first page
 			const firstPageTitle = `first-nav-page-${Date.now()}`;
@@ -301,14 +281,12 @@ test.describe('Public Sidebar', () => {
 				.getByText(firstPageTitle, { exact: true })
 				.click();
 			await page.waitForURL(/\/draft\/[^/?#]+/);
-			const draftMatch = page.url().match(/\/draft\/([^/?#]+)/);
-			expect(draftMatch).toBeTruthy();
-			const firstDocKey = decodeURIComponent(draftMatch?.[1] ?? '');
+			const firstDocKey = await currentDraftDocKey(page);
 			const editor = page.locator('.ProseMirror, [contenteditable="true"]');
 			await expect(editor).toBeVisible({ timeout: 10000 });
 			await editor.click();
 			await page.keyboard.type('First page content here.');
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 
 			// Create second page in the same change request
@@ -329,7 +307,7 @@ test.describe('Public Sidebar', () => {
 			await expect(editor).toBeVisible({ timeout: 10000 });
 			await editor.click();
 			await page.keyboard.type('Second page different content.');
-			await page.click('button:has-text("Save")');
+			await saveEditor(page);
 			await page.waitForLoadState('networkidle');
 
 			// Submit for review and merge both pages
diff --git a/e2e/tests/slash-menu.spec.ts b/e2e/tests/slash-menu.spec.ts
index 6a307a817..7b13f551b 100644
--- a/e2e/tests/slash-menu.spec.ts
+++ b/e2e/tests/slash-menu.spec.ts
@@ -1,6 +1,5 @@
-import { expect, test } from '@playwright/test';
-import { APP_BASE, spaceLinkSelector } from '../helpers/routes';
-import { openNewPageDialog } from '../helpers/wiki';
+import { expect, test } from '../fixtures';
+import { createDraftAndOpenEditor } from '../helpers/wiki';
 
 /**
  * Covers the editor's "/" command menu.
@@ -11,48 +10,11 @@ import { openNewPageDialog } from '../helpers/wiki';
  * "No commands found" until a character was typed.
  */
 
-/**
- * Create a draft page and open the editor. Mirrors the helper in
- * iframe-embed.spec.ts — duplicated here rather than exported so changes
- * to one test don't ripple into others.
- */
-async function createDraftAndOpenEditor(
-	page: import('@playwright/test').Page,
-	title: string,
-) {
-	await page.goto(APP_BASE);
-	await page.waitForLoadState('networkidle');
-
-	const spaceLink = page.locator(spaceLinkSelector()).first();
-	await expect(spaceLink).toBeVisible({ timeout: 5000 });
-	await spaceLink.click();
-	await page.waitForLoadState('networkidle');
-
-	await openNewPageDialog(page);
-
-	await page.getByLabel('Title').fill(title);
-	await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
-	await page.waitForLoadState('networkidle');
-
-	// Saving usually auto-opens the new page; fall back to the sidebar entry.
-	const titleBox = page.getByPlaceholder('Page title');
-	const alreadyOpen = await titleBox
-		.inputValue()
-		.then((v) => v === title)
-		.catch(() => false);
-	if (!alreadyOpen) {
-		await page.locator('aside').getByText(title, { exact: true }).click();
-	}
-
-	const editor = page.locator('.ProseMirror, [contenteditable="true"]');
-	await expect(editor).toBeVisible({ timeout: 10000 });
-	return editor;
-}
-
 test.describe('Slash command menu', () => {
-	test('bare "/" opens the full command list', async ({ page }) => {
+	test('bare "/" opens the full command list', async ({ page, wiki }) => {
 		const editor = await createDraftAndOpenEditor(
 			page,
+			await wiki.space(),
 			`slash-menu-${Date.now()}`,
 		);
 
@@ -74,9 +36,11 @@ test.describe('Slash command menu', () => {
 
 	test('typing filters the list and Enter inserts the block', async ({
 		page,
+		wiki,
 	}) => {
 		const editor = await createDraftAndOpenEditor(
 			page,
+			await wiki.space(),
 			`slash-filter-${Date.now()}`,
 		);
 
diff --git a/e2e/tests/spa-editor.mobile.spec.ts b/e2e/tests/spa-editor.mobile.spec.ts
index 96a2e2210..1b3bbc630 100644
--- a/e2e/tests/spa-editor.mobile.spec.ts
+++ b/e2e/tests/spa-editor.mobile.spec.ts
@@ -1,10 +1,7 @@
-import { expect, test } from '@playwright/test';
+import { expect, test } from '../fixtures';
+import { uniqueRoute } from '../helpers/factory';
 import { SPACE_URL_RE, appUrl } from '../helpers/routes';
-import {
-	cleanupWikiSpacesByRoute,
-	clickSidebarAddOption,
-	createTestWikiSpace,
-} from '../helpers/wiki';
+import { openNewPageDialog } from '../helpers/wiki';
 
 /**
  * Mobile-friendly SPA (Phases 1-2) tracer + regression guards, on a phone
@@ -25,25 +22,15 @@ async function pageOverflow(page: import('@playwright/test').Page) {
 }
 
 test.describe('Mobile SPA', () => {
-	const createdRoutes: string[] = [];
-
-	test.afterEach(async ({ request }) => {
-		while (createdRoutes.length) {
-			const route = createdRoutes.pop() as string;
-			await cleanupWikiSpacesByRoute(request, route).catch(() => {});
-		}
-	});
-
 	// Phase 1: the bug we guard against is the editor collapsing to a sliver
 	// because the desktop sidebars ate the screen. The tree must live in a
 	// drawer and the editor must fill the width.
 	test('tree opens in a drawer and the editor fills the screen at 375px', async ({
 		page,
+		wiki,
 	}) => {
-		const stamp = Date.now();
-		const spaceRoute = `mobile-spa-${stamp}`;
-		createdRoutes.push(spaceRoute);
-		const pageTitle = `Mobile Page ${stamp}`;
+		const spaceRoute = uniqueRoute('mobile-spa');
+		const pageTitle = `Mobile Page ${Date.now()}`;
 
 		// --- Setup at desktop: create a space with one page ---
 		await page.setViewportSize(DESKTOP);
@@ -61,15 +48,9 @@ test.describe('Mobile SPA', () => {
 		await expect(page).toHaveURL(SPACE_URL_RE);
 		await page.waitForLoadState('networkidle');
 		const spaceUrl = page.url();
+		wiki.adopt(spaceUrl.split('/spaces/')[1].split(/[/?#]/)[0]);
 
-		const createFirstPage = page.locator(
-			'button:has-text("Create First Page")',
-		);
-		if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) {
-			await createFirstPage.click();
-		} else {
-			await clickSidebarAddOption(page, 'New Page');
-		}
+		await openNewPageDialog(page);
 		await page.getByLabel('Title').fill(pageTitle);
 		await page
 			.getByRole('dialog')
@@ -126,18 +107,16 @@ test.describe('Mobile SPA', () => {
 	// and rows still navigate.
 	test('Spaces and Change Requests render without page overflow; rows navigate', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		const spaceRoute = `mobile-list-${Date.now()}`;
-		createdRoutes.push(spaceRoute);
-		await createTestWikiSpace(request, { route: spaceRoute });
+		const { route: spaceRoute } = await wiki.space();
 
 		await page.setViewportSize(PHONE);
 		await page.goto(appUrl('spaces'));
 		await page.waitForLoadState('networkidle');
 
 		await expect(
-			page.getByRole('heading', { name: 'Wiki Spaces' }),
+			page.getByRole('heading', { name: 'Spaces', exact: true }),
 		).toBeVisible();
 		// The header stacks and the table scrolls inside its container, so the
 		// page itself must not gain a horizontal scrollbar.
@@ -162,14 +141,12 @@ test.describe('Mobile SPA', () => {
 	// backdrop swallows the dialog's outside-click.
 	test('Settings opens on top of the tree drawer, not behind it', async ({
 		page,
-		request,
+		wiki,
 	}) => {
-		const spaceRoute = `mobile-settings-${Date.now()}`;
-		createdRoutes.push(spaceRoute);
-		const space = await createTestWikiSpace(request, { route: spaceRoute });
+		const space = await wiki.space();
 
 		await page.setViewportSize(PHONE);
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
 
 		// Open the tree drawer, then Settings from inside it.
@@ -180,6 +157,6 @@ test.describe('Mobile SPA', () => {
 
 		// Drawer closes; the settings dialog is the only modal left.
 		await expect(drawer).toBeHidden();
-		await expect(page.getByText('Permissions', { exact: true })).toBeVisible();
+		await expect(page.getByText('Access', { exact: true })).toBeVisible();
 	});
 });
diff --git a/e2e/tests/space-permissions-role-search.spec.ts b/e2e/tests/space-access-role-search.spec.ts
similarity index 75%
rename from e2e/tests/space-permissions-role-search.spec.ts
rename to e2e/tests/space-access-role-search.spec.ts
index baf14231a..1498e9fa0 100644
--- a/e2e/tests/space-permissions-role-search.spec.ts
+++ b/e2e/tests/space-access-role-search.spec.ts
@@ -1,10 +1,8 @@
-import { expect, test } from '@playwright/test';
+import { expect, test } from '../fixtures';
 import { createDoc, deleteDoc, getDoc, getList } from '../helpers/frappe';
-import { appUrl } from '../helpers/routes';
-import { createTestWikiSpace, deleteTestWikiSpace } from '../helpers/wiki';
 
 /**
- * Space Settings -> Permissions role picker searches on the server.
+ * Space Settings -> Access role picker searches on the server.
  *
  * Regression for #709: the picker used to load a single page of roles and
  * filter it in the browser, so on any site with more roles than fit in that
@@ -12,21 +10,21 @@ import { createTestWikiSpace, deleteTestWikiSpace } from '../helpers/wiki';
  * alphabetically, so it can only be found if the typed query actually reaches
  * the server.
  */
-test.describe('Space Settings -> Permissions role search', () => {
+test.describe('Space Settings -> Access role search', () => {
 	let roleName = '';
-	let spaceName = '';
 
-	test.afterEach(async ({ request }) => {
-		// The space's child row links the role, so the space goes first.
-		if (spaceName) await deleteTestWikiSpace(request, spaceName);
+	test.afterEach(async ({ request, wiki }) => {
+		// The space's child row links the role, so the space goes first — the
+		// fixture's own teardown runs after this hook, which would be too late.
+		await wiki.destroyAll();
 		if (roleName) await deleteDoc(request, 'Role', roleName);
-		spaceName = '';
 		roleName = '';
 	});
 
 	test('finds and adds a role that sorts past the first page', async ({
 		page,
 		request,
+		wiki,
 	}) => {
 		// The bug only bites once there are more roles than one page holds.
 		const enabledRoles = await getList(request, 'Role', {
@@ -41,19 +39,18 @@ test.describe('Space Settings -> Permissions role search', () => {
 		roleName = `ZZZ Wiki Role ${Date.now()}`;
 		await createDoc(request, 'Role', { role_name: roleName });
 
-		const space = await createTestWikiSpace(request, {
-			route: `role-search-${Date.now()}`,
-		});
-		spaceName = space.name;
+		const space = await wiki.space();
 
 		await page.setViewportSize({ width: 1280, height: 900 });
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForLoadState('networkidle');
 
-		await page.getByTitle('Settings').first().click();
+		// The sidebar's Settings button became a "Space actions" menu (spec 01).
+		await page.getByRole('button', { name: 'Space actions' }).click();
+		await page.getByRole('menuitem', { name: 'Space settings' }).click();
 		const dialog = page.getByRole('dialog');
 		await expect(dialog).toBeVisible();
-		await dialog.getByRole('tab', { name: 'Permissions', exact: true }).click();
+		await dialog.getByRole('tab', { name: 'Access', exact: true }).click();
 
 		// Type a fragment that no role from the first page matches.
 		const picker = dialog.getByPlaceholder('Search role to add');
diff --git a/e2e/tests/space-default-page.spec.ts b/e2e/tests/space-default-page.spec.ts
index e2cc8725d..577d1454c 100644
--- a/e2e/tests/space-default-page.spec.ts
+++ b/e2e/tests/space-default-page.spec.ts
@@ -1,13 +1,6 @@
-import { type APIRequestContext, expect, test } from '@playwright/test';
-import { updateDoc } from '../helpers/frappe';
-import { appUrl, spaceLinkSelector } from '../helpers/routes';
-import {
-	type WikiDocument,
-	type WikiSpace,
-	cleanupWikiSpacesByRoute,
-	createTestWikiDocument,
-	createTestWikiSpace,
-} from '../helpers/wiki';
+import { expect, test } from '../fixtures';
+import type { SeededSpace } from '../helpers/factory';
+import { APP_BASE, spaceLinkSelector } from '../helpers/routes';
 
 /**
  * Opening a space in the editor should never strand the user on the "Select a
@@ -15,67 +8,26 @@ import {
  * whichever page the user last had open (persisted per-space in localStorage).
  */
 test.describe('Space default page', () => {
-	const populatedRoute = `default-page-${Date.now()}`;
-	const emptyRoute = `default-page-empty-${Date.now()}`;
-	let space: WikiSpace;
-	let alpha: WikiDocument;
-	let beta: WikiDocument;
-	let emptySpace: WikiSpace;
+	let space: SeededSpace;
+	let emptySpace: SeededSpace;
 
-	test.beforeAll(async ({ request }) => {
+	test.beforeAll(async ({ wikiSuite }) => {
 		// A space with two published pages, Alpha before Beta.
-		space = await createTestWikiSpace(request, {
-			route: populatedRoute,
-			is_published: true,
+		space = await wikiSuite.space({
+			pages: [{ title: 'Alpha Page' }, { title: 'Beta Page' }],
 		});
-		const rootGroup = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${populatedRoute}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-		alpha = await createTestWikiDocument(request, {
-			title: 'Alpha Page',
-			route: `${populatedRoute}/alpha`,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-		beta = await createTestWikiDocument(request, {
-			title: 'Beta Page',
-			route: `${populatedRoute}/beta`,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-
 		// A space with no pages, to exercise the empty-tree fallback.
-		emptySpace = await createTestWikiSpace(request, {
-			route: emptyRoute,
-			is_published: true,
-		});
-		const emptyRoot = await createTestWikiDocument(request, {
-			title: 'Root',
-			route: `${emptyRoute}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', emptySpace.name, {
-			root_group: emptyRoot.name,
-		});
-	});
-
-	test.afterAll(async ({ request }) => {
-		await cleanupWikiSpacesByRoute(request, populatedRoute);
-		await cleanupWikiSpacesByRoute(request, emptyRoute);
+		emptySpace = await wikiSuite.space();
 	});
 
 	test('auto-opens the first page, then reopens the last opened page', async ({
 		page,
 	}) => {
+		const alpha = space.page('Alpha Page');
+		const beta = space.page('Beta Page');
+
 		// Entering at the bare space route opens the first page (Alpha).
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForURL(`**/spaces/${space.name}/page/${alpha.name}`, {
 			timeout: 15000,
 		});
@@ -84,13 +36,13 @@ test.describe('Space default page', () => {
 		// the rendered title (the page-title input) rather than networkidle: it's
 		// the "page mounted" signal the persist watcher rides on, and avoids
 		// networkidle's flaky 500ms-quiet wait on slow CI.
-		await page.goto(appUrl('spaces', space.name, 'page', beta.name));
+		await page.goto(space.url('page', beta.name));
 		await expect(page.getByPlaceholder('Page title')).toHaveValue('Beta Page', {
 			timeout: 15000,
 		});
 
 		// Re-entering the bare space route now reopens Beta, not Alpha.
-		await page.goto(appUrl('spaces', space.name));
+		await page.goto(space.url());
 		await page.waitForURL(`**/spaces/${space.name}/page/${beta.name}`, {
 			timeout: 15000,
 		});
@@ -99,7 +51,7 @@ test.describe('Space default page', () => {
 	test('stays on the welcome screen when the space has no pages', async ({
 		page,
 	}) => {
-		await page.goto(appUrl('spaces', emptySpace.name));
+		await page.goto(emptySpace.url());
 		// Wait for the tree to resolve (sidebar reports it's empty), so any
 		// redirect would already have happened.
 		await expect(page.locator('aside >> text=No pages yet')).toBeVisible({
@@ -107,7 +59,12 @@ test.describe('Space default page', () => {
 		});
 		// No page was opened — URL is still the bare space route.
 		await expect(page).toHaveURL(new RegExp(`/spaces/${emptySpace.name}$`));
-		await expect(page.locator('text=Select a page')).toBeVisible();
+		// The tree states the fact; the content column carries the action.
+		const content = page.locator('main');
+		await expect(content.getByText('Create your first page')).toBeVisible();
+		await expect(
+			content.getByRole('button', { name: 'New page', exact: true }),
+		).toBeVisible();
 	});
 });
 
@@ -120,86 +77,32 @@ test.describe('Space default page', () => {
  * each space with a fresh page.goto().
  */
 test.describe('Space default page — in-app space switch', () => {
-	const ts = Date.now();
-	const routeA = `switch-a-${ts}`;
-	const routeB = `switch-b-${ts}`;
-	const nameB = `Bravo Space ${ts}`;
-	let spaceA: WikiSpace;
-	let spaceB: WikiSpace;
-	let aFirst: WikiDocument;
-	let bFirst: WikiDocument;
-
-	async function buildSpace(
-		request: APIRequestContext,
-		route: string,
-		spaceName: string,
-		pagePrefix: string,
-	): Promise<{ space: WikiSpace; firstPage: WikiDocument }> {
-		const space = await createTestWikiSpace(request, {
-			route,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			space_name: spaceName,
-		});
-		const rootGroup = await createTestWikiDocument(request, {
-			title: `${pagePrefix} Root`,
-			route: `${route}/root`,
-			is_group: true,
-			is_published: true,
-		});
-		await updateDoc(request, 'Wiki Space', space.name, {
-			root_group: rootGroup.name,
-		});
-		const firstPage = await createTestWikiDocument(request, {
-			title: `${pagePrefix} One`,
-			route: `${route}/one`,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-		await createTestWikiDocument(request, {
-			title: `${pagePrefix} Two`,
-			route: `${route}/two`,
-			is_published: true,
-			parent_wiki_document: rootGroup.name,
-		});
-		return { space, firstPage };
-	}
-
-	test.beforeAll(async ({ request }) => {
-		({ space: spaceA, firstPage: aFirst } = await buildSpace(
-			request,
-			routeA,
-			`Alpha Space ${ts}`,
-			'Alpha',
-		));
-		({ space: spaceB, firstPage: bFirst } = await buildSpace(
-			request,
-			routeB,
-			nameB,
-			'Bravo',
-		));
-	});
-
-	test.afterAll(async ({ request }) => {
-		await cleanupWikiSpacesByRoute(request, routeA);
-		await cleanupWikiSpacesByRoute(request, routeB);
-	});
-
 	test('opens the switched-to space page, not the previous space page', async ({
 		page,
+		wiki,
 	}) => {
+		const spaceA = await wiki.space({
+			space_name: 'Alpha Space',
+			pages: [{ title: 'Alpha One' }, { title: 'Alpha Two' }],
+		});
+		const spaceB = await wiki.space({
+			space_name: 'Bravo Space',
+			pages: [{ title: 'Bravo One' }, { title: 'Bravo Two' }],
+		});
+		const aFirst = spaceA.page('Alpha One');
+		const bFirst = spaceB.page('Bravo One');
+
 		// Enter space A — it auto-opens A's first page and hydrates the singleton
 		// draft store for A.
-		await page.goto(appUrl('spaces', spaceA.name));
+		await page.goto(spaceA.url());
 		await page.waitForURL(`**/spaces/${spaceA.name}/page/${aFirst.name}`, {
 			timeout: 15000,
 		});
 
-		// Switch to space B entirely in-app: back to the list, then into B. No
-		// full reload, so the store still holds A's tree at the moment B mounts.
-		await page.locator('[title="Back to Spaces"]').click();
-		await page.waitForURL(/\/spaces$/, { timeout: 15000 });
+		// Switch to space B entirely in-app: back out to the library, then into B.
+		// No full reload, so the store still holds A's tree at the moment B mounts.
+		await page.locator('[title="Back to Overview"]').first().click();
+		await page.waitForURL(new RegExp(`${APP_BASE}/?$`), { timeout: 15000 });
 		// Target the row by its href (router-link) — robust to how the row text
 		// is rendered — and click it for a client-side nav into B.
 		await page.locator(spaceLinkSelector(spaceB.name)).first().click();
diff --git a/e2e/tests/space-empty-new-page.spec.ts b/e2e/tests/space-empty-new-page.spec.ts
new file mode 100644
index 000000000..3143118e8
--- /dev/null
+++ b/e2e/tests/space-empty-new-page.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '../fixtures';
+
+/**
+ * An empty space's content column offers "New page". The dialog that creates
+ * one lives in the tree, in the sidebar — a sibling column, not an ancestor —
+ * so the button hands its request to a module-scoped flag the tree consumes.
+ * This exercises that hand-off end to end: press the button in 
, get the + * tree's dialog, and land in the editor on the page it made. + */ +test.describe('Empty space — New page from the content column', () => { + test('creates the first page and opens it in the editor', async ({ + page, + wiki, + }) => { + const space = await wiki.space(); + await page.goto(space.url()); + + // The sidebar reporting an empty tree is the signal that any auto-open + // would already have happened. + await expect(page.locator('aside >> text=No pages yet')).toBeVisible({ + timeout: 15000, + }); + + const content = page.locator('main'); + await content + .getByRole('button', { name: 'New page', exact: true }) + .click(); + + const title = `First Page ${Date.now()}`; + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Title').fill(title); + await dialog.getByRole('button', { name: 'Save' }).click(); + + // A created page is a change request entry until it merges, so it opens + // on the draft route rather than /page/. + await page.waitForURL(/\/draft\//, { timeout: 15000 }); + await expect( + page.locator('aside').getByText(title, { exact: false }), + ).toBeVisible({ timeout: 15000 }); + + // No reload allowance here on purpose. The panel used to strand on + // "Draft not found" when the create resolved before it mounted — the + // temp key it was navigated to had already been promoted away. This + // asserts the editor arrives first time. + await expect(page.getByPlaceholder('Page title')).toHaveValue(title, { + timeout: 15000, + }); + }); +}); diff --git a/e2e/tests/space-list.spec.ts b/e2e/tests/space-list.spec.ts deleted file mode 100644 index 5eb855cfb..000000000 --- a/e2e/tests/space-list.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { SPACE_URL_RE, appUrl, spaceLinkSelector } from '../helpers/routes'; - -test.describe('Wiki Space list', () => { - test('View opens the public-facing space in a new tab without navigating the row', async ({ - page, - }) => { - const timestamp = Date.now(); - const spaceName = `View Btn Space ${timestamp}`; - const route = `view-btn-space-${timestamp}`; - - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - - await page.getByRole('button', { name: 'New Space' }).click(); - await page.waitForSelector('[role="dialog"]', { state: 'visible' }); - await page.getByLabel('Space Name').fill(spaceName); - await page.getByLabel('Route').fill(route); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Create' }) - .click(); - await expect(page).toHaveURL(SPACE_URL_RE); - - // Back to the list and find the new (published) space row. - await page.goto(appUrl('spaces')); - await page.waitForLoadState('networkidle'); - const row = page - .locator(spaceLinkSelector()) - .filter({ hasText: spaceName }) - .first(); - await expect(row).toBeVisible({ timeout: 10000 }); - - // View opens the reader at the site root (/) in a new tab, and the - // row must not navigate into the editor (router-link .stop.prevent guard). - const listUrl = page.url(); - const [popup] = await Promise.all([ - page.waitForEvent('popup'), - row.getByRole('button', { name: 'View' }).click(), - ]); - expect(page.url()).toBe(listUrl); - await popup.waitForLoadState('domcontentloaded').catch(() => {}); - expect(popup.url()).toContain(route); - await popup.close(); - }); -}); diff --git a/e2e/tests/space-logo-picker.spec.ts b/e2e/tests/space-logo-picker.spec.ts new file mode 100644 index 000000000..c085f2c87 --- /dev/null +++ b/e2e/tests/space-logo-picker.spec.ts @@ -0,0 +1,136 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { uniqueRoute } from '../helpers/factory'; +import { getDoc } from '../helpers/frappe'; +import { appUrl } from '../helpers/routes'; + +/** + * Space Settings -> General: the Space Logo tile. + * + * The tile is the control -- there is no Upload button beside it any more -- + * and every choice writes straight to the document, so what the browser shows + * and what the row holds must never disagree. The generated mark is the case + * worth an e2e: it is produced by a lazily-imported DiceBear chunk, so a + * broken chunk boundary fails here and nowhere else. + */ +test.describe('Space Settings -> Space Logo', () => { + async function openPicker(page: Page) { + await page.getByRole('button', { name: 'Space actions' }).click(); + await page.getByRole('menuitem', { name: 'Space settings' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByTestId('space-identity-trigger').click(); + // The popover is portaled out of the dialog, so it lives on the page. + return page.getByTestId('space-identity-tabs'); + } + + test('picking an icon and a colour stores both and survives a reload', async ({ + page, + request, + wiki, + }) => { + const space = await wiki.space(); + + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(space.url()); + await page.waitForLoadState('networkidle'); + + await openPicker(page); + await page.getByRole('button', { name: 'green', exact: true }).click(); + await page.getByRole('option', { name: 'Knowledge', exact: true }).click(); + + await expect + .poll(async () => { + const doc = await getDoc(request, 'Wiki Space', space.name); + return [doc.space_icon, doc.space_color]; + }) + .toEqual(['lucide-book-open-text', 'green']); + + // The mark the sidebar header draws comes from the same fields, so a + // reload proves the whole round trip and not just the write. + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect( + page.locator('span.lucide-book-open-text').first(), + ).toBeVisible(); + }); + + test('shuffle generates a mark, stores its seed, and keeps it on reload', async ({ + page, + request, + wiki, + }) => { + const space = await wiki.space(); + + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(space.url()); + await page.waitForLoadState('networkidle'); + + await openPicker(page); + await page.getByTestId('space-identity-shuffle').click(); + + const rolled = await expect + .poll( + async () => { + const doc = await getDoc(request, 'Wiki Space', space.name); + return doc.avatar_seed || ''; + }, + { timeout: 15000 }, + ) + .not.toEqual('') + .then(() => getDoc(request, 'Wiki Space', space.name)); + + expect(rolled.avatar_style).toBeTruthy(); + expect(rolled.avatar).toMatch(/^data:image\/svg\+xml[;,]/); + + await page.reload(); + await page.waitForLoadState('networkidle'); + const mark = page.locator(`img[src^="data:image/svg+xml"]`).first(); + await expect(mark).toBeVisible(); + + // Rolling again has to change the art, or Shuffle is a no-op button. + await openPicker(page); + await page.getByTestId('space-identity-shuffle').click(); + await expect + .poll( + async () => { + const doc = await getDoc(request, 'Wiki Space', space.name); + return doc.avatar_seed; + }, + { timeout: 15000 }, + ) + .not.toEqual(rolled.avatar_seed); + }); + + test('a new space is created with a mark rather than a bare initial', async ({ + page, + request, + wiki, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(appUrl()); + await page.waitForLoadState('networkidle'); + + const route = uniqueRoute('logo-created'); + await page.getByRole('button', { name: 'New Space' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // The roll happens as the dialog opens, so the tile is already a mark + // before anything is typed. + await expect( + dialog.locator('img[src^="data:image/svg+xml"]').first(), + ).toBeVisible({ timeout: 15000 }); + + await dialog.getByPlaceholder('My Wiki Space').fill(route); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + await page.waitForURL(/\/spaces\//, { timeout: 15000 }); + + const spaceName = page.url().split('/spaces/')[1].split(/[/?#]/)[0]; + wiki.adopt(spaceName); + + const doc = await getDoc(request, 'Wiki Space', spaceName); + expect(doc.avatar).toMatch(/^data:image\/svg\+xml[;,]/); + expect(doc.avatar_seed).toBeTruthy(); + }); +}); diff --git a/e2e/tests/stale-tab-flags.spec.ts b/e2e/tests/stale-tab-flags.spec.ts new file mode 100644 index 000000000..829006221 --- /dev/null +++ b/e2e/tests/stale-tab-flags.spec.ts @@ -0,0 +1,201 @@ +import type { Locator } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import type { SeededSpace } from '../helpers/factory'; + +/** + * Horizontal tabs were removed. `Wiki Space.enable_tabs` and the node-level + * `is_tab` / `tab_icon` flags stay in the schema so old revisions and change + * requests still apply, but nothing may read them as navigation any more. + * + * The space below is built through the API carrying every one of those legacy + * flags — the exact shape that used to produce a tab bar. Both surfaces have to + * render it as one plain tree. + */ + +const SPACE_NAME = 'Stale Tabs E2E'; + +// Layout assertions compare row positions, so a missing box is a real failure +// rather than something to silently skip. +async function box(locator: Locator) { + const rect = await locator.boundingBox(); + if (!rect) throw new Error('element has no bounding box'); + return rect; +} + +test.describe('A space carrying legacy tab flags', () => { + let space: SeededSpace; + // The page every test enters on, addressed by the route the server derived. + let salesInvoiceRoute = ''; + + test.beforeAll(async ({ wikiSuite }) => { + space = await wikiSuite.space({ + space_name: SPACE_NAME, + // The switch that used to raise the tab bar. Nothing reads it now. + enable_tabs: 1, + pages: [ + { + title: 'Accounting', + is_group: true, + is_tab: 1, + tab_icon: 'lucide-wallet', + children: [ + { + title: 'Receivables', + is_group: true, + children: [{ title: 'Sales Invoice' }, { title: 'Credit Note' }], + }, + ], + }, + { + title: 'Manufacturing', + is_group: true, + is_tab: 1, + tab_icon: 'lucide-factory', + children: [ + { + title: 'Production', + is_group: true, + children: [{ title: 'Work Order' }], + }, + ], + }, + // Never flagged — used to live behind the synthetic Home tab. + { + title: 'Release Notes', + is_group: true, + children: [{ title: 'v15 Changelog' }], + }, + ], + }); + salesInvoiceRoute = space.page('Sales Invoice').route; + }); + + test('reader shows one tree with every top-level group, and no tab bar', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`/${salesInvoiceRoute}`); + await page.waitForLoadState('networkidle'); + + const sidebar = page.locator('.wiki-sidebar'); + await expect(page.getByRole('tablist')).toHaveCount(0); + + // Flagged and unflagged groups sit side by side; nothing is gated. + await expect( + sidebar.getByText('Accounting', { exact: true }), + ).toBeVisible(); + await expect( + sidebar.getByText('Manufacturing', { exact: true }), + ).toBeVisible(); + await expect( + sidebar.getByText('Release Notes', { exact: true }), + ).toBeVisible(); + + // The subtree of the open page is expanded, as for any other group. + await expect( + sidebar.getByText('Receivables', { exact: true }), + ).toBeVisible(); + }); + + test('reader SPA navigation across former tabs keeps the whole tree', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`/${salesInvoiceRoute}`); + await page.waitForLoadState('networkidle'); + + const sidebar = page.locator('.wiki-sidebar'); + await sidebar.getByText('Manufacturing', { exact: true }).click(); + await expect( + sidebar.getByText('Production', { exact: true }), + ).toBeVisible(); + + await sidebar.getByText('Production', { exact: true }).click(); + await sidebar.getByText('Work Order', { exact: true }).click(); + await expect(page).toHaveURL( + new RegExp(`/${space.page('Work Order').route}`), + ); + + // The former Home content is still there after the SPA hop. + await expect( + sidebar.getByText('Release Notes', { exact: true }), + ).toBeVisible(); + }); + + test('reader chrome is the navbar alone, sitting directly above the tree', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(`/${salesInvoiceRoute}`); + await page.waitForLoadState('networkidle'); + + // `banner` picks the page-level header; the mobile one and the article's + // own
are both plain
elements. + const navbar = page.getByRole('banner'); + const sidebar = page.locator('.wiki-sidebar'); + + const navbarBox = await box(navbar); + const sidebarBox = await box(sidebar); + + // The tab row used to sit between these two — the sidebar now starts + // immediately below the navbar, with no 44px gap left behind. + expect(sidebarBox.y).toBeGreaterThanOrEqual( + navbarBox.y + navbarBox.height - 1, + ); + expect(sidebarBox.y).toBeLessThan(navbarBox.y + navbarBox.height + 4); + expect(navbarBox.width).toBeGreaterThan(sidebarBox.width); + + await expect( + navbar.getByText(SPACE_NAME, { exact: true }).first(), + ).toBeVisible(); + }); + + test('reader mobile drawer shows one tree with no tab picker', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/${salesInvoiceRoute}`); + await page.waitForLoadState('networkidle'); + + await page.getByTestId('mobile-menu-toggle').click(); + + const sheet = page.getByTestId('mobile-bottom-sheet'); + await expect(sheet).toBeVisible(); + + // The tab picker was a combobox listing the space's tabs. Gone entirely. + await expect(sheet.getByRole('listbox')).toHaveCount(0); + + await expect( + sheet.getByText('Release Notes', { exact: true }), + ).toBeVisible(); + await expect( + sheet.getByText('Manufacturing', { exact: true }), + ).toBeVisible(); + }); + + test('app sidebar renders flagged groups as plain top-level groups', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(space.url()); + await page.waitForLoadState('networkidle'); + + const tree = page.locator('aside'); + await expect(tree.getByText('Accounting', { exact: true })).toBeVisible({ + timeout: 15000, + }); + + // All three top-level groups at once — no subtree hidden behind a tab. + await expect( + tree.getByText('Manufacturing', { exact: true }), + ).toBeVisible(); + await expect( + tree.getByText('Release Notes', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('tablist')).toHaveCount(0); + + // And they expand like any other group. + await tree.getByText('Accounting', { exact: true }).click(); + await expect(tree.getByText('Receivables', { exact: true })).toBeVisible(); + }); +}); diff --git a/e2e/tests/tab-navigation.spec.ts b/e2e/tests/tab-navigation.spec.ts deleted file mode 100644 index d97fe62e8..000000000 --- a/e2e/tests/tab-navigation.spec.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { - type APIRequestContext, - type Locator, - expect, - test, -} from '@playwright/test'; -import { createDoc } from '../helpers/frappe'; -import { APP_BASE } from '../helpers/routes'; -import { cleanupWikiSpacesByRoute } from '../helpers/wiki'; - -/** - * Horizontal tab navigation: top-level groups flagged `is_tab` render in a bar - * above the tree, and clicking one swaps the tree to that tab's subtree. - * - * The space is built through the API rather than reusing an existing one, so - * the tab layout under test is exactly the one asserted against. - */ - -const ROUTE = `tabs-e2e-${Date.now()}`; - -type Doc = { name: string }; - -// Layout assertions compare row positions, so a missing box is a real failure -// rather than something to silently skip. -async function box(locator: Locator) { - const rect = await locator.boundingBox(); - if (!rect) throw new Error('element has no bounding box'); - return rect; -} - -async function group( - request: APIRequestContext, - title: string, - parent: string, - sortOrder: number, - tabIcon?: string, -) { - return createDoc(request, 'Wiki Document', { - title, - is_group: 1, - is_published: 1, - parent_wiki_document: parent, - sort_order: sortOrder, - ...(tabIcon ? { is_tab: 1, tab_icon: tabIcon } : {}), - }); -} - -async function page_( - request: APIRequestContext, - title: string, - parent: string, - sortOrder: number, -) { - return createDoc(request, 'Wiki Document', { - title, - is_group: 0, - is_published: 1, - parent_wiki_document: parent, - sort_order: sortOrder, - content: `Documentation for ${title}.`, - }); -} - -test.describe('Horizontal tab navigation', () => { - test.beforeAll(async ({ request }) => { - const root = await createDoc(request, 'Wiki Document', { - title: `Tabs Root ${Date.now()}`, - is_group: 1, - is_published: 1, - }); - await createDoc(request, 'Wiki Space', { - space_name: 'Tabs E2E', - route: ROUTE, - root_group: root.name, - is_published: 1, - // Tabs are opt-in per space; every test below needs the bar. - enable_tabs: 1, - }); - - const accounting = await group( - request, - 'Accounting', - root.name, - 0, - 'lucide-wallet', - ); - const receivables = await group(request, 'Receivables', accounting.name, 0); - await page_(request, 'Sales Invoice', receivables.name, 0); - await page_(request, 'Credit Note', receivables.name, 1); - - const manufacturing = await group( - request, - 'Manufacturing', - root.name, - 1, - 'lucide-factory', - ); - const production = await group( - request, - 'Production', - manufacturing.name, - 0, - ); - await page_(request, 'Work Order', production.name, 0); - - // Non-tab top-level content must keep working alongside tabs. - const misc = await group(request, 'Release Notes', root.name, 2); - await page_(request, 'v15 Changelog', misc.name, 0); - }); - - test.afterAll(async ({ request }) => { - await cleanupWikiSpacesByRoute(request, ROUTE); - }); - - test('reader shows the bar, swaps subtrees on SPA nav, and keeps non-tab content', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(`/${ROUTE}/accounting/receivables/sales-invoice`); - await page.waitForLoadState('networkidle'); - - const tabBar = page.getByRole('tablist'); - const sidebar = page.locator('.wiki-sidebar'); - await expect(tabBar.getByRole('tab', { name: 'Accounting' })).toBeVisible(); - await expect( - tabBar.getByRole('tab', { name: 'Manufacturing' }), - ).toBeVisible(); - - // Hard load: the tab owning the current page is the active one. - await expect( - tabBar.getByRole('tab', { name: 'Accounting' }), - ).toHaveAttribute('aria-selected', 'true'); - await expect( - sidebar.getByText('Receivables', { exact: true }), - ).toBeVisible(); - await expect(sidebar.getByText('Production', { exact: true })).toBeHidden(); - - // SPA navigation must move the bar with it, not leave it stale. - await tabBar.getByRole('tab', { name: 'Manufacturing' }).click(); - await expect( - tabBar.getByRole('tab', { name: 'Manufacturing' }), - ).toHaveAttribute('aria-selected', 'true'); - await expect( - tabBar.getByRole('tab', { name: 'Accounting' }), - ).toHaveAttribute('aria-selected', 'false'); - await expect( - sidebar.getByText('Production', { exact: true }), - ).toBeVisible(); - await expect( - sidebar.getByText('Receivables', { exact: true }), - ).toBeHidden(); - - // With multiple tabs, non-tab top-level content lives under Home, so it's - // hidden while a tab is active (see the Home test below). - await expect( - sidebar.getByText('Release Notes', { exact: true }), - ).toBeHidden(); - }); - - test('reader Home tab lands on untabbed content and gates it behind itself', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(`/${ROUTE}/accounting/receivables/sales-invoice`); - await page.waitForLoadState('networkidle'); - - const tabBar = page.getByRole('tablist'); - const sidebar = page.locator('.wiki-sidebar'); - const home = tabBar.getByRole('tab', { name: 'Home' }); - - // Home leads the bar (≥2 tabs + untabbed content), inactive on a tab page. - await expect(home).toBeVisible(); - await expect(home).toHaveAttribute('aria-selected', 'false'); - await expect( - sidebar.getByText('Release Notes', { exact: true }), - ).toBeHidden(); - - // Clicking Home surfaces the untabbed subtree and deselects the tabs. - await home.click(); - await expect(home).toHaveAttribute('aria-selected', 'true'); - await expect( - sidebar.getByText('Release Notes', { exact: true }), - ).toBeVisible(); - await expect( - tabBar.getByRole('tab', { name: 'Accounting' }), - ).toHaveAttribute('aria-selected', 'false'); - }); - - test('reader deep link into a tab subtree highlights that tab on hard load', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(`/${ROUTE}/manufacturing/production/work-order`); - await page.waitForLoadState('networkidle'); - - const tabBar = page.getByRole('tablist'); - const sidebar = page.locator('.wiki-sidebar'); - await expect( - tabBar.getByRole('tab', { name: 'Manufacturing' }), - ).toHaveAttribute('aria-selected', 'true'); - await expect( - sidebar.getByText('Production', { exact: true }), - ).toBeVisible(); - }); - - test('editor shows the bar and swaps the sidebar subtree', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - await page.getByText('Tabs E2E', { exact: true }).first().click(); - await page.waitForLoadState('networkidle'); - - const aside = page.locator('aside').first(); - // The bar sits above the sidebar+content row now, so it's page-level, not - // scoped to
. - const tabBar = page.getByRole('tablist'); - await expect(tabBar.getByRole('tab', { name: 'Accounting' })).toBeVisible({ - timeout: 15000, - }); - await expect(aside.getByText('Receivables', { exact: true })).toBeVisible(); - - await tabBar.getByRole('tab', { name: 'Manufacturing' }).click(); - await expect(aside.getByText('Production', { exact: true })).toBeVisible(); - await expect(aside.getByText('Receivables', { exact: true })).toHaveCount( - 0, - ); - }); - - test('editor creates a tab with an icon and it appears in the bar immediately', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - await page.getByText('Tabs E2E', { exact: true }).first().click(); - await page.waitForLoadState('networkidle'); - - const aside = page.locator('aside').first(); - const tabBar = page.getByRole('tablist'); - await expect(tabBar.getByRole('tab', { name: 'Accounting' })).toBeVisible({ - timeout: 15000, - }); - - await aside.getByTitle('Add').click(); - await page.getByRole('menuitem', { name: 'New Tab' }).click(); - - const dialog = page.getByRole('dialog'); - await expect(dialog.getByText('Create New Tab')).toBeVisible(); - await dialog.getByLabel('Title').fill('Projects'); - // Curated icon list, so a known-present label must be offered. - await dialog.getByRole('option', { name: 'Launch' }).click(); - await dialog.getByRole('button', { name: 'Save' }).click(); - - // Comes from the draft tree, so it shows before the CR is merged. - await expect( - tabBar.getByRole('tab', { name: 'Projects', exact: true }), - ).toBeVisible({ - timeout: 15000, - }); - }); - - test('reader stacks navbar, tabs, then the tree, and renders tab icons', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(`/${ROUTE}/accounting/receivables/sales-invoice`); - await page.waitForLoadState('networkidle'); - - // `banner` picks the page-level header; the mobile one and the article's - // own
are both plain
elements. - const navbar = page.getByRole('banner'); - const tabBar = page.getByRole('tablist'); - const sidebar = page.locator('.wiki-sidebar'); - - // The hierarchy is the point of this layout: each row starts below the - // previous one, and both chrome rows span past the sidebar's width. - const navbarBox = await box(navbar); - const tabsBox = await box(tabBar); - const sidebarBox = await box(sidebar); - expect(tabsBox.y).toBeGreaterThanOrEqual( - navbarBox.y + navbarBox.height - 1, - ); - expect(sidebarBox.y).toBeGreaterThanOrEqual(tabsBox.y + tabsBox.height - 1); - expect(navbarBox.width).toBeGreaterThan(sidebarBox.width); - - // The space name moved out of the sidebar and into the navbar. `.first()` - // is the switcher's own label — the rest are its (hidden) menu entries. - await expect( - navbar.getByText('Tabs E2E', { exact: true }).first(), - ).toBeVisible(); - - // Icons are inlined server-side (wiki.utils.lucide_svg) because the - // reader's Tailwind build has no lucide plugin. - await expect( - tabBar.getByRole('tab', { name: 'Accounting' }).locator('svg'), - ).toBeVisible(); - }); - - test('editor bar creates a tab from its own add button, above the draft banner', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - await page.getByText('Tabs E2E', { exact: true }).first().click(); - await page.waitForLoadState('networkidle'); - - // Banner and bar both sit above the sidebar+content row now, so they're - // page-level, not scoped to
. - const tabBar = page.getByRole('tablist'); - await expect(tabBar.getByRole('tab', { name: 'Accounting' })).toBeVisible({ - timeout: 15000, - }); - - // The change-request banner is about the whole draft, so it outranks the - // tab being browsed. - const bannerBox = await box(page.locator('.contribution-banner')); - const tabsBox = await box(tabBar); - expect(bannerBox.y).toBeLessThan(tabsBox.y); - - await page.getByTestId('new-tab-button').click(); - const dialog = page.getByRole('dialog'); - await expect(dialog.getByText('Create New Tab')).toBeVisible(); - // The dialog only asks for a title now — a default icon is applied and - // changed inline from the bar afterwards. - await dialog.getByLabel('Title').fill('Support'); - await dialog.getByRole('button', { name: 'Create' }).click(); - - await expect( - tabBar.getByRole('tab', { name: 'Support', exact: true }), - ).toBeVisible({ timeout: 15000 }); - }); - - test('editor reorders tabs by dragging them in the bar', async ({ page }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - await page.getByText('Tabs E2E', { exact: true }).first().click(); - await page.waitForLoadState('networkidle'); - - const tabBar = page.getByRole('tablist'); - const accounting = tabBar.getByRole('tab', { name: 'Accounting' }); - const manufacturing = tabBar.getByRole('tab', { name: 'Manufacturing' }); - await expect(accounting).toBeVisible({ timeout: 15000 }); - - // Home leads the bar (synthetic, non-draggable), so compare the real tabs - // that follow it. - const order = async () => - (await tabBar.getByRole('tab').allInnerTexts()) - .map((t) => t.trim()) - .filter((t) => t !== 'Home'); - expect((await order()).slice(0, 2)).toEqual([ - 'Accounting', - 'Manufacturing', - ]); - - // SortableJS runs in pointer-fallback mode, so drive real mouse moves in - // steps rather than Playwright's HTML5-drag `dragTo` (which it ignores). - // Drop past the target's midpoint to land the dragged tab after it. - const src = await box(accounting); - const dst = await box(manufacturing); - await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2); - await page.mouse.down(); - await page.mouse.move(dst.x + dst.width - 4, dst.y + dst.height / 2, { - steps: 12, - }); - await page.mouse.move(dst.x + dst.width - 4, dst.y + dst.height / 2); - await page.mouse.up(); - - await expect - .poll(async () => (await order()).slice(0, 2)) - .toEqual(['Manufacturing', 'Accounting']); - }); -}); - -/** - * A single-tab space still gates its untabbed top-level content behind Home — - * regression for the sidebar leaking untabbed pages into the one tab's subtree - * (it took a no-Home inline branch when there were fewer than two tabs). - */ -test.describe('Reader sidebar with a single tab', () => { - const SOLO_ROUTE = `tabs-solo-e2e-${Date.now()}`; - - test.beforeAll(async ({ request }) => { - const root = await createDoc(request, 'Wiki Document', { - title: `Solo Tab Root ${Date.now()}`, - is_group: 1, - is_published: 1, - }); - await createDoc(request, 'Wiki Space', { - space_name: 'Solo Tab E2E', - route: SOLO_ROUTE, - root_group: root.name, - is_published: 1, - enable_tabs: 1, - }); - - const accounting = await group( - request, - 'Accounting', - root.name, - 0, - 'lucide-wallet', - ); - const receivables = await group(request, 'Receivables', accounting.name, 0); - await page_(request, 'Payment Entry', receivables.name, 0); - - // Untabbed top-level page — must NOT show while the tab is active. - await page_(request, 'Bold Heading Check', root.name, 1); - }); - - test.afterAll(async ({ request }) => { - await cleanupWikiSpacesByRoute(request, SOLO_ROUTE); - }); - - test('untabbed content stays behind Home, out of the tab subtree', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(`/${SOLO_ROUTE}/accounting/receivables/payment-entry`); - await page.waitForLoadState('networkidle'); - - const tabBar = page.getByRole('tablist'); - const sidebar = page.locator('.wiki-sidebar'); - const home = tabBar.getByRole('tab', { name: 'Home' }); - - // One tab + untabbed content => Home leads, inactive on the tab page, and - // the untabbed page is hidden rather than leaking into the tab's sidebar. - await expect(home).toBeVisible(); - await expect( - sidebar.getByText('Bold Heading Check', { exact: true }), - ).toBeHidden(); - - await home.click(); - await expect( - sidebar.getByText('Bold Heading Check', { exact: true }), - ).toBeVisible(); - }); -}); - -/** - * Tabs are opt-in per space (Wiki Space.enable_tabs). A space with tab groups - * but the switch off must show no bar anywhere, and none of its content may - * become unreachable because a tab was hiding it. - */ -test.describe('Tab navigation disabled', () => { - const OFF_ROUTE = `tabs-off-e2e-${Date.now()}`; - - test.beforeAll(async ({ request }) => { - const root = await createDoc(request, 'Wiki Document', { - title: `Tabs Off Root ${Date.now()}`, - is_group: 1, - is_published: 1, - }); - await createDoc(request, 'Wiki Space', { - space_name: 'Tabs Off E2E', - route: OFF_ROUTE, - root_group: root.name, - is_published: 1, - }); - - // Flagged as a tab, but the space never opts in — so it stays an ordinary - // top-level group everywhere. - const accounting = await group( - request, - 'Accounting', - root.name, - 0, - 'lucide-wallet', - ); - await page_(request, 'Sales Invoice', accounting.name, 0); - await page_(request, 'Release Notes', root.name, 1); - }); - - test.afterAll(async ({ request }) => { - await cleanupWikiSpacesByRoute(request, OFF_ROUTE); - }); - - test('reader shows no bar and keeps every top-level node in the sidebar', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(`/${OFF_ROUTE}/accounting/sales-invoice`); - await page.waitForLoadState('networkidle'); - - await expect(page.getByRole('tablist')).toHaveCount(0); - - const sidebar = page.locator('.wiki-sidebar'); - await expect( - sidebar.getByText('Accounting', { exact: true }), - ).toBeVisible(); - await expect( - sidebar.getByText('Release Notes', { exact: true }), - ).toBeVisible(); - }); - - test('editor shows no bar, the whole tree, and the page actions row', async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - await page.getByText('Tabs Off E2E', { exact: true }).first().click(); - await page.waitForLoadState('networkidle'); - - const aside = page.locator('aside').first(); - await expect(aside.getByText('Accounting', { exact: true })).toBeVisible({ - timeout: 15000, - }); - await expect( - aside.getByText('Release Notes', { exact: true }), - ).toBeVisible(); - await expect(page.getByRole('tablist')).toHaveCount(0); - - // Page actions live in the content column, so they survive the missing bar. - await aside.getByText('Accounting', { exact: true }).click(); - await aside.getByText('Sales Invoice', { exact: true }).click(); - await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ - timeout: 15000, - }); - }); -}); diff --git a/e2e/tests/toc-navigation.spec.ts b/e2e/tests/toc-navigation.spec.ts index e8efc59ac..a825241b3 100644 --- a/e2e/tests/toc-navigation.spec.ts +++ b/e2e/tests/toc-navigation.spec.ts @@ -1,14 +1,12 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; import { getList } from '../helpers/frappe'; -import { - APP_BASE, - CHANGE_REQUEST_URL_RE, - spaceLinkSelector, -} from '../helpers/routes'; +import { CHANGE_REQUEST_URL_RE } from '../helpers/routes'; import { clickSidebarAddOption, + currentDraftDocKey, openNewPageDialog, publishChangeRequestFromReview, + saveEditor, } from '../helpers/wiki'; interface WikiDocumentRoute { @@ -37,16 +35,12 @@ test.describe('TOC Navigation', () => { test('should update TOC headings when navigating between pages via sidebar', async ({ page, request, + wiki, }) => { await page.setViewportSize({ width: 1100, height: 900 }); - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Create first page with specific headings @@ -67,9 +61,7 @@ test.describe('TOC Navigation', () => { .getByText(firstPageTitle, { exact: true }) .click(); await page.waitForURL(/\/draft\/[^/?#]+/); - const draftMatch1 = page.url().match(/\/draft\/([^/?#]+)/); - expect(draftMatch1).toBeTruthy(); - const firstDocKey = decodeURIComponent(draftMatch1?.[1] ?? ''); + const firstDocKey = await currentDraftDocKey(page); const editor = page.locator('.ProseMirror, [contenteditable="true"]'); await expect(editor).toBeVisible({ timeout: 10000 }); @@ -101,7 +93,7 @@ Beta sub content.`; await editor.click(); await page.waitForTimeout(500); - await page.click('button:has-text("Save")'); + await saveEditor(page); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); @@ -156,7 +148,7 @@ Epsilon content here.`; await editor.click(); await page.waitForTimeout(500); - await page.click('button:has-text("Save")'); + await saveEditor(page); await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); diff --git a/e2e/tests/tree-search.spec.ts b/e2e/tests/tree-search.spec.ts index cdc4d307b..aca475455 100644 --- a/e2e/tests/tree-search.spec.ts +++ b/e2e/tests/tree-search.spec.ts @@ -1,72 +1,38 @@ -import { expect, test } from '@playwright/test'; -import { updateDoc } from '../helpers/frappe'; -import { appUrl } from '../helpers/routes'; -import { createTestWikiDocument, createTestWikiSpace } from '../helpers/wiki'; +import { expect, test } from '../fixtures'; /** * E2E tests for the editor tree fuzzy search box. - * Verifies that typing filters the in-memory tree in place: matches stay, - * non-matches are pruned, ancestor groups auto-expand, route-only matches - * surface, and clearing the query restores the full tree. + * Verifies that typing swaps the tree for a flat result list: only the matches + * themselves render (no ancestor groups), route-only matches surface, a matched + * group drops the query and opens where it lives, and clearing restores the + * tree. */ test.describe('Editor Tree Search', () => { - test('filters the tree by title and route, then restores on clear', async ({ + test('lists matches flat by title and route, then restores on clear', async ({ page, - request, + wiki, }) => { - const spaceName = `tree-search-${Date.now()}`; - const space = await createTestWikiSpace(request, { - route: spaceName, - is_published: true, + // Group "Guides" with two pages; one matches only by route. A sibling + // "Reference" group should be pruned away on a "Guides" search. + const space = await wiki.space({ + pages: [ + { + title: 'Guides', + is_group: true, + children: [ + { title: 'Getting Started' }, + { title: 'Authentication', slug: 'auth-tokens' }, + ], + }, + { + title: 'Reference', + is_group: true, + children: [{ title: 'API Keys' }], + }, + ], }); - const rootGroup = await createTestWikiDocument(request, { - title: 'Root', - route: `${spaceName}/root`, - is_group: true, - is_published: true, - }); - await updateDoc(request, 'Wiki Space', space.name, { - root_group: rootGroup.name, - }); - - // Group "Guides" with two pages; one page matches only by route. - const guides = await createTestWikiDocument(request, { - title: 'Guides', - route: `${spaceName}/guides`, - is_group: true, - is_published: true, - parent_wiki_document: rootGroup.name, - }); - await createTestWikiDocument(request, { - title: 'Getting Started', - route: `${spaceName}/guides/getting-started`, - is_published: true, - parent_wiki_document: guides.name, - }); - await createTestWikiDocument(request, { - title: 'Authentication', - route: `${spaceName}/guides/auth-tokens`, - is_published: true, - parent_wiki_document: guides.name, - }); - - // A sibling group that should be pruned away on a "Guides" search. - const reference = await createTestWikiDocument(request, { - title: 'Reference', - route: `${spaceName}/reference`, - is_group: true, - is_published: true, - parent_wiki_document: rootGroup.name, - }); - await createTestWikiDocument(request, { - title: 'API Keys', - route: `${spaceName}/reference/api-keys`, - is_published: true, - parent_wiki_document: reference.name, - }); - - await page.goto(appUrl('spaces', space.name)); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); const tree = page.locator('aside'); @@ -77,11 +43,11 @@ test.describe('Editor Tree Search', () => { const search = page.getByPlaceholder('Search pages...'); await expect(search).toBeVisible(); - // Title match: keeps the page + its ancestor group (auto-expanded), - // prunes the unrelated branch. + // Title match: the page is its own row, with no ancestor group dragged + // along and nothing from the unrelated branch. await search.fill('getting'); await expect(tree.getByText('Getting Started')).toBeVisible(); - await expect(tree.getByText('Guides', { exact: true })).toBeVisible(); + await expect(tree.getByText('Guides', { exact: true })).toHaveCount(0); await expect(tree.getByText('Reference', { exact: true })).toHaveCount(0); await expect(tree.getByText('API Keys')).toHaveCount(0); await expect(tree.getByText('Authentication')).toHaveCount(0); @@ -91,11 +57,20 @@ test.describe('Editor Tree Search', () => { await expect(tree.getByText('Authentication')).toBeVisible(); await expect(tree.getByText('Getting Started')).toHaveCount(0); - // No matches: the empty state shows. + // No matches: the empty line shows. await search.fill('zzzznomatch'); - await expect(tree.getByText('No matches')).toBeVisible(); + await expect(tree.getByText('No pages match "zzzznomatch"')).toBeVisible(); + + // A matched group has nothing to open in place, so picking it clears the + // search and expands the group back in the tree. + await search.fill('Guides'); + await expect(tree.getByText('Getting Started')).toHaveCount(0); + await tree.getByText('Guides', { exact: true }).click(); + await expect(search).toHaveValue(''); + await expect(tree.getByText('Getting Started')).toBeVisible(); // Clearing restores the full tree. + await search.fill('auth'); await search.fill(''); await expect(tree.getByText('Guides', { exact: true })).toBeVisible(); await expect(tree.getByText('Reference', { exact: true })).toBeVisible(); diff --git a/e2e/tests/webp-conversion.spec.ts b/e2e/tests/webp-conversion.spec.ts index 5bca8ab1e..5e04c929a 100644 --- a/e2e/tests/webp-conversion.spec.ts +++ b/e2e/tests/webp-conversion.spec.ts @@ -1,8 +1,9 @@ import { randomUUID } from 'node:crypto'; import { deflateSync } from 'node:zlib'; -import { type Page, expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import type { SeededSpace } from '../helpers/factory'; import { deleteDoc, getList, updateDoc } from '../helpers/frappe'; -import { APP_BASE, spaceLinkSelector } from '../helpers/routes'; import { openNewPageDialog } from '../helpers/wiki'; /** @@ -83,14 +84,13 @@ async function setWebpConversion( * until the editor is mounted on its draft. Mirrors the setup used by other * editor e2e tests (image-viewer.spec.ts) but stops at the editable draft. */ -async function openNewPageInEditor(page: Page, title: string): Promise { +async function openNewPageInEditor( + page: Page, + space: SeededSpace, + title: string, +): Promise { await page.setViewportSize({ width: 1100, height: 900 }); - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); await openNewPageDialog(page); @@ -151,12 +151,13 @@ test.describe('Automatic WebP image optimization', () => { test('converts an uploaded PNG to WebP when the setting is enabled', async ({ page, request, + wiki, }) => { await setWebpConversion(request, true); const stamp = Date.now(); const fileName = `e2e-webp-on-${stamp}.png`; - await openNewPageInEditor(page, `webp-on-${stamp}`); + await openNewPageInEditor(page, await wiki.space(), `webp-on-${stamp}`); await uploadImage(page, fileName); // The node first shows a base64 preview (loading), then swaps to the @@ -187,12 +188,13 @@ test.describe('Automatic WebP image optimization', () => { test('keeps the original format when the setting is disabled', async ({ page, request, + wiki, }) => { await setWebpConversion(request, false); const stamp = Date.now(); const fileName = `e2e-webp-off-${stamp}.png`; - await openNewPageInEditor(page, `webp-off-${stamp}`); + await openNewPageInEditor(page, await wiki.space(), `webp-off-${stamp}`); await uploadImage(page, fileName); // With conversion off, the image stays a .png served from /files. diff --git a/e2e/tests/wiki.spec.ts b/e2e/tests/wiki.spec.ts index 96da5c828..2c8ccd7c8 100644 --- a/e2e/tests/wiki.spec.ts +++ b/e2e/tests/wiki.spec.ts @@ -1,4 +1,5 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { uniqueRoute } from '../helpers/factory'; import { getList } from '../helpers/frappe'; import { APP_BASE, @@ -8,10 +9,11 @@ import { spaceLinkSelector, } from '../helpers/routes'; import { - cleanupWikiSpacesByRoute, - createTestWikiSpace, + currentDraftDocKey, + newPageButton, openNewPageDialog, publishChangeRequestFromReview, + saveEditor, } from '../helpers/wiki'; interface WikiDocumentRoute { @@ -24,15 +26,6 @@ interface WikiDocumentRoute { * For public-facing page tests (TOC, sidebar), see public-pages.spec.ts */ test.describe('Wiki Editor', () => { - // Spaces created via API for tests that need a clean, isolated space rather - // than reusing whatever "first available space" happens to exist. - const createdRoutes: string[] = []; - test.afterAll(async ({ request }) => { - for (const route of createdRoutes) { - await cleanupWikiSpacesByRoute(request, route); - } - }); - test('should display wiki spaces list', async ({ page }) => { await page.goto(APP_BASE); await page.waitForLoadState('networkidle'); @@ -47,7 +40,7 @@ test.describe('Wiki Editor', () => { await expect(spacesContainer.first()).toBeVisible(); }); - test('should create a new wiki space via UI', async ({ page }) => { + test('should create a new wiki space via UI', async ({ page, wiki }) => { await page.goto(APP_BASE); await page.waitForLoadState('networkidle'); @@ -58,7 +51,7 @@ test.describe('Wiki Editor', () => { const dialog = page.locator('[role="dialog"]').first(); await dialog.waitFor({ state: 'visible' }); - const spaceName = `Test Space ${Date.now()}`; + const spaceName = uniqueRoute('test-space'); await dialog.locator('input[type="text"]').first().fill(spaceName); // Wait for route to auto-populate from space name @@ -74,6 +67,7 @@ test.describe('Wiki Editor', () => { // In change-request mode the name lives in the top banner rather than the // tree aside; the timestamped name is unique, so match it page-wide. await expect(page).toHaveURL(SPACE_URL_RE, { timeout: 10000 }); + wiki.adopt(page.url().split('/spaces/')[1].split(/[/?#]/)[0]); await expect( page.getByText(spaceName, { exact: true }).first(), ).toBeVisible(); @@ -82,15 +76,11 @@ test.describe('Wiki Editor', () => { test('should navigate to space and create a wiki page', async ({ page, request, + wiki, }) => { - // Create a dedicated, empty space rather than reusing whatever space - // happens to be first — that shared space can carry an in-progress draft - // from another test, which made this flaky. - const spaceRoute = `create-page-${Date.now()}`; - createdRoutes.push(spaceRoute); - const space = await createTestWikiSpace(request, { route: spaceRoute }); - - await page.goto(appUrl('spaces', space.name)); + const space = await wiki.space(); + + await page.goto(space.url()); await page.waitForLoadState('networkidle'); await expect(page.locator('aside')).toBeVisible(); @@ -126,52 +116,32 @@ test.describe('Wiki Editor', () => { await expect(page.getByText(pageTitle).first()).toBeVisible(); }); - test('should have New Page button in space sidebar', async ({ page }) => { - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + test('should have New Page button in space sidebar', async ({ + page, + wiki, + }) => { + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Should have sidebar with space management buttons await expect(page.locator('aside')).toBeVisible(); - // Wait for the tree to load (CR mode requires async init). - // On an empty space the empty-state "Create First Page" CTA renders - // instead of the sidebar Add dropdown — `.or().first()` tolerates - // either without tripping strict-mode on two matches. - const createFirstPage = page.locator( - 'button:has-text("Create First Page")', - ); - const addButton = page.locator('button[title="Add"]'); - await expect(createFirstPage.or(addButton).first()).toBeVisible({ - timeout: 10000, - }); + // Wait for the tree to load (CR mode requires async init). The sidebar + // footer's New page button is there whether or not the space has pages. + await expect(newPageButton(page)).toBeVisible({ timeout: 10000 }); }); test('should open wiki editor when clicking page in sidebar', async ({ page, + wiki, }) => { - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); - // Wait for sidebar to load - either the empty-state CTA or the Add menu - const createFirstPage = page.locator( - 'button:has-text("Create First Page")', - ); - const addButton = page.locator('button[title="Add"]'); - await expect(createFirstPage.or(addButton).first()).toBeVisible({ - timeout: 10000, - }); + // Wait for the sidebar to load. + await expect(newPageButton(page)).toBeVisible({ timeout: 10000 }); // Always create a new page so we know exactly what to click const pageTitle = `Test Page ${Date.now()}`; @@ -193,28 +163,25 @@ test.describe('Wiki Editor', () => { page.locator('.ProseMirror, [contenteditable="true"]'), ).toBeVisible({ timeout: 10000 }); - // Verify save draft button is present (indicates edit mode) - await expect(page.locator('button:has-text("Save")')).toBeVisible(); + // The editor autosaves, so its own header action is what marks edit mode. + await expect( + page.getByRole('button', { name: 'Submit for Review' }), + ).toBeVisible(); }); test('should publish page and view it on public route', async ({ page, request, + wiki, }) => { - // Navigate to wiki and click first space - await page.goto(APP_BASE); - await page.waitForLoadState('networkidle'); - - const spaceLink = page.locator(spaceLinkSelector()).first(); - await expect(spaceLink).toBeVisible({ timeout: 5000 }); - await spaceLink.click(); + const space = await wiki.space(); + await page.goto(space.url()); await page.waitForLoadState('networkidle'); // Create a new page with specific title and content const pageTitle = `e2e-cr-page-${Date.now()}`; const pageContent = `This is test content created by E2E tests at ${new Date().toISOString()}`; - // Click create button (either "Create First Page" or "New Page") await openNewPageDialog(page); // Fill in page title @@ -228,9 +195,7 @@ test.describe('Wiki Editor', () => { // Open the newly created page from the tree await page.locator('aside').getByText(pageTitle, { exact: true }).click(); await page.waitForURL(/\/draft\/[^/?#]+/); - const draftMatch = page.url().match(/\/draft\/([^/?#]+)/); - expect(draftMatch).toBeTruthy(); - const docKey = decodeURIComponent(draftMatch?.[1] ?? ''); + const docKey = await currentDraftDocKey(page); // Wait for editor to be visible const editor = page.locator('.ProseMirror, [contenteditable="true"]'); @@ -242,7 +207,7 @@ test.describe('Wiki Editor', () => { await page.keyboard.type(pageContent); // Save the draft - await page.click('button:has-text("Save")'); + await saveEditor(page); await page.waitForLoadState('networkidle'); // Submit for review and merge diff --git a/frontend/package.json b/frontend/package.json index 5a4a4797e..c190faccc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,22 +12,24 @@ "copy-html-entry": "cp ../wiki/public/frontend/index.html ../wiki/www/wiki-app.html" }, "dependencies": { + "@dicebear/core": "10.7.0", + "@dicebear/styles": "10.6.0", "@floating-ui/dom": "^1.6.0", "@pierre/diffs": "^1.2.11", - "@tiptap/core": "^3.26.0", - "@tiptap/extension-image": "^3.26.0", - "@tiptap/extension-list": "^3.26.0", - "@tiptap/extension-table": "^3.26.0", - "@tiptap/extensions": "^3.26.0", - "@tiptap/markdown": "^3.26.0", - "@tiptap/pm": "^3.26.0", - "@tiptap/starter-kit": "^3.26.0", - "@tiptap/suggestion": "^3.26.0", - "@tiptap/vue-3": "^3.26.0", + "@tiptap/core": "^3.29.2", + "@tiptap/extension-image": "^3.29.2", + "@tiptap/extension-list": "^3.29.2", + "@tiptap/extension-table": "^3.29.2", + "@tiptap/extensions": "^3.29.2", + "@tiptap/markdown": "^3.29.2", + "@tiptap/pm": "^3.29.2", + "@tiptap/starter-kit": "^3.29.2", + "@tiptap/suggestion": "^3.29.2", + "@tiptap/vue-3": "^3.29.2", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.3.0", "@vueuse/router": "^14.2.1", - "frappe-ui": "1.0.0-beta.25", + "frappe-ui": "1.0.0-beta.55", "fuzzysort": "^3.1.0", "highlight.js": "~11.11.0", "idb-keyval": "^6.2.0", @@ -49,6 +51,49 @@ "vite": "^5.4.10" }, "resolutions": { + "@tiptap/core": "3.29.2", + "@tiptap/extension-blockquote": "3.29.2", + "@tiptap/extension-bold": "3.29.2", + "@tiptap/extension-bubble-menu": "3.29.2", + "@tiptap/extension-bullet-list": "3.29.2", + "@tiptap/extension-code": "3.29.2", + "@tiptap/extension-code-block": "3.29.2", + "@tiptap/extension-code-block-lowlight": "3.29.2", + "@tiptap/extension-color": "3.29.2", + "@tiptap/extension-document": "3.29.2", + "@tiptap/extension-dropcursor": "3.29.2", + "@tiptap/extension-floating-menu": "3.29.2", + "@tiptap/extension-gapcursor": "3.29.2", + "@tiptap/extension-hard-break": "3.29.2", + "@tiptap/extension-heading": "3.29.2", + "@tiptap/extension-highlight": "3.29.2", + "@tiptap/extension-horizontal-rule": "3.29.2", + "@tiptap/extension-image": "3.29.2", + "@tiptap/extension-italic": "3.29.2", + "@tiptap/extension-link": "3.29.2", + "@tiptap/extension-list": "3.29.2", + "@tiptap/extension-list-item": "3.29.2", + "@tiptap/extension-list-keymap": "3.29.2", + "@tiptap/extension-mention": "3.29.2", + "@tiptap/extension-node-range": "3.29.2", + "@tiptap/extension-ordered-list": "3.29.2", + "@tiptap/extension-paragraph": "3.29.2", + "@tiptap/extension-placeholder": "3.29.2", + "@tiptap/extension-strike": "3.29.2", + "@tiptap/extension-table": "3.29.2", + "@tiptap/extension-task-item": "3.29.2", + "@tiptap/extension-task-list": "3.29.2", + "@tiptap/extension-text": "3.29.2", + "@tiptap/extension-text-align": "3.29.2", + "@tiptap/extension-text-style": "3.29.2", + "@tiptap/extension-typography": "3.29.2", + "@tiptap/extension-underline": "3.29.2", + "@tiptap/extensions": "3.29.2", + "@tiptap/markdown": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tiptap/suggestion": "3.29.2", + "@tiptap/vue-3": "3.29.2", "shiki": "^3.23.0", "@shikijs/themes": "^3.23.0", "prosemirror-model": "^1.25.9", diff --git a/frontend/src/.tokens-v2-ink-shift b/frontend/src/.tokens-v2-ink-shift new file mode 100644 index 000000000..408f64cb3 --- /dev/null +++ b/frontend/src/.tokens-v2-ink-shift @@ -0,0 +1,3 @@ +The ink scale shift (tokens-v2 --ink-shift, #1016) ran here on 2026-08-11T09:28:35.436Z. +A second run would double-shift every chromatic ink token. +Delete this file only to re-run the shift on purpose. diff --git a/frontend/src/components/AssignDialog.vue b/frontend/src/components/AssignDialog.vue index b5f37080e..75c6bbef2 100644 --- a/frontend/src/components/AssignDialog.vue +++ b/frontend/src/components/AssignDialog.vue @@ -8,11 +8,10 @@

{{ __('Assign this change request to a reviewer. They will be notified and it will appear in their "Assigned to me" list.') }}

- @@ -34,9 +33,9 @@ diff --git a/frontend/src/components/ContributionsPanel.vue b/frontend/src/components/ContributionsPanel.vue index c3a024b72..5a434c3c0 100644 --- a/frontend/src/components/ContributionsPanel.vue +++ b/frontend/src/components/ContributionsPanel.vue @@ -1,132 +1,132 @@ diff --git a/frontend/src/components/MobileAppMenu.vue b/frontend/src/components/MobileAppMenu.vue index 988057ad8..568c32e51 100644 --- a/frontend/src/components/MobileAppMenu.vue +++ b/frontend/src/components/MobileAppMenu.vue @@ -1,5 +1,5 @@