diff --git a/.gitignore b/.gitignore index 3fc23d52..3876cd2a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ wiki/www/wiki.html wiki/public/css/tailwind.css playwright-report/ test-results/ -e2e/.auth/ \ No newline at end of file +e2e/.auth/ +.env +screenshots/ \ No newline at end of file diff --git a/e2e/tests/callout-rich-text.spec.ts b/e2e/tests/callout-rich-text.spec.ts new file mode 100644 index 00000000..a204c0b1 --- /dev/null +++ b/e2e/tests/callout-rich-text.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from '@playwright/test'; + +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('/wiki'); + await page.waitForLoadState('networkidle'); + + const spaceLink = page.locator('a[href*="/wiki/spaces/"]').first(); + await expect(spaceLink).toBeVisible({ timeout: 5000 }); + await spaceLink.click(); + await page.waitForLoadState('networkidle'); + + const createFirstPage = page.locator( + 'button:has-text("Create First Page")', + ); + const newPageButton = page.locator('button[title="New Page"]'); + + if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { + await createFirstPage.click(); + } else { + await newPageButton.click(); + } + + await page.getByLabel('Title').fill(pageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .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('callout should round-trip inline markdown (bold, italic, links)', async ({ + page, + }) => { + const pageTitle = `callout-rt-${Date.now()}`; + await createPageAndOpenEditor(page, pageTitle); + + 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' }; + + // 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:::`, { + contentType: 'markdown', + }); + + const md1 = editor.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', + ); + + return { + md1, + md2, + roundTrip: md1 === md2, + calloutContent: calloutNode?.attrs?.content, + hasCallout: !!calloutNode, + }; + }); + + expect(result).not.toHaveProperty('error'); + expect(result.hasCallout).toBe(true); + + // Content should preserve inline markdown + expect(result.calloutContent).toContain('**bold**'); + expect(result.calloutContent).toContain('*italic*'); + expect(result.calloutContent).toContain('[a link](https://example.com)'); + + // Round-trip should be stable + expect(result.roundTrip).toBe(true); + }); + + test('callout view mode should render formatted HTML preview', async ({ + page, + }) => { + const pageTitle = `callout-preview-${Date.now()}`; + await createPageAndOpenEditor(page, pageTitle); + + // 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' }, + ); + }); + + // The callout should render in view mode (not editing) with formatted text + const calloutContent = page.locator( + '.callout-block-wrapper .callout-content-text', + ); + 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'); + }); + + test('callout sub-editor should appear on double-click', async ({ page }) => { + const pageTitle = `callout-edit-${Date.now()}`; + await createPageAndOpenEditor(page, pageTitle); + + // 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:::', { + 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(); + + // The sub-editor (a nested ProseMirror instance) and toolbar should appear + const subEditor = page.locator( + '.callout-block-wrapper .callout-sub-editor-content', + ); + 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 expect(toolbar).toBeVisible(); + }); +}); diff --git a/e2e/tests/iframe-embed.spec.ts b/e2e/tests/iframe-embed.spec.ts new file mode 100644 index 00000000..3a508f83 --- /dev/null +++ b/e2e/tests/iframe-embed.spec.ts @@ -0,0 +1,186 @@ +import { expect, test } from '@playwright/test'; + +/** + * Covers the iframe embed extension added for frappe/wiki#599. + * + * The fixture below is exactly the iframe YouTube's Share → Embed dialog + * produces today — full attribute set (width, height, allow, referrerpolicy, + * allowfullscreen). That's the realistic paste we need to support. + */ +const IFRAME_FIXTURE = + ''; + +const IFRAME_SRC = + 'https://www.youtube.com/embed/QDia3e12czc?si=8or3Lz5IEeelsdcF'; + +declare global { + interface Window { + wikiEditor: { + commands: { + setContent: ( + content: string, + options?: { contentType?: string }, + ) => void; + setIframe?: (attrs: Record) => boolean; + }; + getMarkdown: () => string; + getJSON: () => { + type: string; + content: { type: string; attrs?: Record }[]; + }; + }; + } +} + +/** + * 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('/wiki'); + await page.waitForLoadState('networkidle'); + + const spaceLink = page.locator('a[href*="/wiki/spaces/"]').first(); + await expect(spaceLink).toBeVisible({ timeout: 5000 }); + await spaceLink.click(); + await page.waitForLoadState('networkidle'); + + const createFirstPage = page.locator('button:has-text("Create First Page")'); + const newPageButton = page.locator('button[title="New Page"]'); + + if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { + await createFirstPage.click(); + } else { + await newPageButton.click(); + } + + await page.getByLabel('Title').fill(title); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .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, + }) => { + await createDraftAndOpenEditor(page, `iframe-parse-${Date.now()}`); + + const result = await page.evaluate((html) => { + window.wikiEditor.commands.setContent(html, { contentType: 'markdown' }); + const json = window.wikiEditor.getJSON(); + const block = json.content?.find((n) => n.type === 'iframeBlock'); + return { + hasBlock: !!block, + src: block?.attrs?.src as string | undefined, + allowfullscreen: block?.attrs?.allowfullscreen as boolean | undefined, + title: block?.attrs?.title as string | undefined, + width: block?.attrs?.width as string | undefined, + height: block?.attrs?.height as string | undefined, + }; + }, IFRAME_FIXTURE); + + expect(result.hasBlock).toBe(true); + expect(result.src).toBe(IFRAME_SRC); + expect(result.allowfullscreen).toBe(true); + expect(result.title).toBe('YouTube video player'); + expect(result.width).toBe('560'); + expect(result.height).toBe('315'); + }); + + test('renders the iframe preview inside the editor', async ({ page }) => { + await createDraftAndOpenEditor(page, `iframe-preview-${Date.now()}`); + + await page.evaluate((html) => { + window.wikiEditor.commands.setContent(html, { contentType: 'markdown' }); + }, IFRAME_FIXTURE); + + const preview = page.locator( + '.iframe-block-wrapper iframe[src*="youtube.com/embed"]', + ); + await expect(preview).toBeVisible({ timeout: 5000 }); + await expect(preview).toHaveAttribute('src', IFRAME_SRC); + }); + + test('round-trips iframe markdown without mutating the src', async ({ + page, + }) => { + await createDraftAndOpenEditor(page, `iframe-roundtrip-${Date.now()}`); + + const { md1, md2 } = await page.evaluate((html) => { + window.wikiEditor.commands.setContent(html, { contentType: 'markdown' }); + const md1 = window.wikiEditor.getMarkdown(); + // Second pass: re-parse the serialized markdown and re-serialize. + // This is the cycle that used to compound-escape before the extension. + window.wikiEditor.commands.setContent(md1, { contentType: 'markdown' }); + const md2 = window.wikiEditor.getMarkdown(); + return { md1, md2 }; + }, IFRAME_FIXTURE); + + // Both passes preserve the raw src — no <, no &lt; leaking in. + expect(md1).toContain(`src="${IFRAME_SRC}"`); + expect(md1).not.toMatch(/<iframe|&lt;/); + expect(md2).toContain(`src="${IFRAME_SRC}"`); + expect(md2).not.toMatch(/<iframe|&lt;/); + + // Serialization must be idempotent — a second round-trip shouldn't drift. + expect(md2).toBe(md1); + }); + + test('accepts the full iframe tag in the /embed URL input', async ({ + page, + }) => { + await createDraftAndOpenEditor(page, `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). + await page.evaluate(() => { + const editor = window.wikiEditor as unknown as { + commands: { insertIframePlaceholder: () => boolean }; + }; + editor.commands.insertIframePlaceholder(); + }); + + const placeholderInput = page + .locator('.iframe-block-wrapper') + .getByPlaceholder('https://'); + await expect(placeholderInput).toBeVisible({ timeout: 5000 }); + + await placeholderInput.fill(IFRAME_FIXTURE); + await page + .locator('.iframe-block-wrapper') + .getByRole('button', { name: 'Embed' }) + .click(); + + const preview = page.locator( + '.iframe-block-wrapper iframe[src*="youtube.com/embed"]', + ); + await expect(preview).toBeVisible({ timeout: 5000 }); + await expect(preview).toHaveAttribute('src', IFRAME_SRC); + + // Saved markdown reflects the attrs pulled from the pasted iframe HTML. + const md = await page.evaluate(() => window.wikiEditor.getMarkdown()); + expect(md).toContain(`src="${IFRAME_SRC}"`); + expect(md).toContain('title="YouTube video player"'); + }); +}); diff --git a/e2e/tests/image-viewer.spec.ts b/e2e/tests/image-viewer.spec.ts new file mode 100644 index 00000000..abbbcf55 --- /dev/null +++ b/e2e/tests/image-viewer.spec.ts @@ -0,0 +1,253 @@ +import { expect, test } from '@playwright/test'; +import { getList } from '../helpers/frappe'; + +interface WikiDocumentRoute { + route: string; + doc_key: string; +} + +declare global { + interface Window { + wikiEditor: { + commands: { + setContent: ( + content: string, + options?: { contentType?: string }, + ) => void; + }; + }; + } +} + +/** + * Helper: create a wiki page with given markdown, publish it, and return the public URL. + */ +async function createAndPublishPage( + page: import('@playwright/test').Page, + request: import('@playwright/test').APIRequestContext, + title: string, + markdownContent: string, +): Promise { + await page.setViewportSize({ width: 1100, height: 900 }); + await page.goto('/wiki'); + await page.waitForLoadState('networkidle'); + + const spaceLink = page.locator('a[href*="/wiki/spaces/"]').first(); + await expect(spaceLink).toBeVisible({ timeout: 5000 }); + await spaceLink.click(); + await page.waitForLoadState('networkidle'); + + const createFirstPage = page.locator('button:has-text("Create First Page")'); + const newPageButton = page.locator('button[title="New Page"]'); + + if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { + await createFirstPage.click(); + } else { + await newPageButton.click(); + } + + await page.getByLabel('Title').fill(title); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .click(); + await page.waitForLoadState('networkidle'); + + await page.locator('aside').getByText(title, { exact: true }).click(); + await page.waitForURL(/\/draft\/[^/?#]+/); + const draftMatch = page.url().match(/\/draft\/([^/?#]+)/); + expect(draftMatch).toBeTruthy(); + const docKey = decodeURIComponent(draftMatch?.[1] ?? ''); + + const editor = page.locator('.ProseMirror, [contenteditable="true"]'); + await expect(editor).toBeVisible({ timeout: 10000 }); + await page.waitForFunction(() => window.wikiEditor !== undefined, { + timeout: 10000, + }); + + await page.evaluate((content) => { + window.wikiEditor.commands.setContent(content, { + contentType: 'markdown', + }); + }, markdownContent); + + await editor.click(); + await page.waitForTimeout(500); + + await page.click('button:has-text("Save Draft")'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + await page.getByRole('button', { name: 'Submit for Review' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page).toHaveURL(/\/wiki\/change-requests\//, { + timeout: 10000, + }); + await page.getByRole('button', { name: 'Merge' }).click(); + await expect(page.locator('text=Change request merged').first()).toBeVisible({ + timeout: 15000, + }); + + const routes = await getList(request, 'Wiki Document', { + fields: ['route', 'doc_key'], + filters: { doc_key: docKey }, + limit: 1, + }); + expect(routes.length).toBe(1); + return `/${routes[0].route}`; +} + +test.describe('Image Viewer / Lightbox', () => { + test('should open lightbox when clicking a prose image and close on overlay click', async ({ + page, + request, + }) => { + const pageTitle = `lightbox-test-${Date.now()}`; + const markdown = `## Image Test + +Here is an image: + +![Test screenshot](https://placehold.co/600x400/png) + +Some text after the image.`; + + const publicUrl = await createAndPublishPage( + page, + request, + pageTitle, + markdown, + ); + + const publicPage = await page.context().newPage(); + await publicPage.goto(publicUrl); + await publicPage.waitForLoadState('networkidle'); + + // Verify the image is rendered in content + const img = publicPage.locator('#wiki-content img').first(); + await expect(img).toBeVisible({ timeout: 10000 }); + + // Image should have zoom-in cursor + await expect(img).toHaveCSS('cursor', 'zoom-in'); + + // Lightbox overlay should be hidden initially + const viewer = publicPage.locator('#image-viewer'); + await expect(viewer).not.toBeVisible(); + + // Click the image to open lightbox + await img.click(); + + // Overlay should become visible + await expect(viewer).toBeVisible({ timeout: 3000 }); + await expect(viewer).toHaveClass(/active/); + + // Body should have scroll lock + await expect(publicPage.locator('body')).toHaveClass(/image-viewer-open/); + + // Viewer image should have the same src + const viewerImg = publicPage.locator('#image-viewer-img'); + const originalSrc = await img.getAttribute('src'); + expect(originalSrc).toBeTruthy(); + await expect(viewerImg).toHaveAttribute('src', originalSrc as string); + + // Click overlay to close + await viewer.click({ position: { x: 10, y: 10 } }); + + // Wait for transition to complete + await publicPage.waitForTimeout(300); + await expect(viewer).not.toBeVisible(); + + // Body scroll should be restored + const bodyClasses = await publicPage.locator('body').getAttribute('class'); + expect(bodyClasses).not.toContain('image-viewer-open'); + + await publicPage.close(); + }); + + test('should close lightbox on Escape key', async ({ page, request }) => { + const pageTitle = `lightbox-esc-test-${Date.now()}`; + const markdown = `## Escape Key Test + +![Test image](https://placehold.co/600x400/png)`; + + const publicUrl = await createAndPublishPage( + page, + request, + pageTitle, + markdown, + ); + + const publicPage = await page.context().newPage(); + await publicPage.goto(publicUrl); + await publicPage.waitForLoadState('networkidle'); + + const img = publicPage.locator('#wiki-content img').first(); + await expect(img).toBeVisible({ timeout: 10000 }); + + // Open lightbox + await img.click(); + const viewer = publicPage.locator('#image-viewer'); + await expect(viewer).toBeVisible({ timeout: 3000 }); + + // Press Escape to close + await publicPage.keyboard.press('Escape'); + + await publicPage.waitForTimeout(300); + await expect(viewer).not.toBeVisible(); + + await publicPage.close(); + }); + + test('should wire up images loaded via SPA navigation', async ({ + page, + request, + }) => { + // Create two pages — one with an image, one without + const pageTitle1 = `lightbox-spa-1-${Date.now()}`; + const pageTitle2 = `lightbox-spa-2-${Date.now()}`; + + const markdown1 = `## Page One + +Just some text, no images here.`; + + const markdown2 = `## Page Two + +![SPA test image](https://placehold.co/600x400/png)`; + + const publicUrl1 = await createAndPublishPage( + page, + request, + pageTitle1, + markdown1, + ); + const publicUrl2 = await createAndPublishPage( + page, + request, + pageTitle2, + markdown2, + ); + + // Navigate to the first page (no images) + const publicPage = await page.context().newPage(); + await publicPage.goto(publicUrl1); + await publicPage.waitForLoadState('networkidle'); + + // Now SPA-navigate to the second page (has image) using prev/next or direct nav + await publicPage.goto(publicUrl2); + await publicPage.waitForLoadState('networkidle'); + + // The image on this page should still trigger the lightbox + const img = publicPage.locator('#wiki-content img').first(); + await expect(img).toBeVisible({ timeout: 10000 }); + + await img.click(); + const viewer = publicPage.locator('#image-viewer'); + await expect(viewer).toBeVisible({ timeout: 3000 }); + + // Close and verify + await publicPage.keyboard.press('Escape'); + await publicPage.waitForTimeout(300); + await expect(viewer).not.toBeVisible(); + + await publicPage.close(); + }); +}); diff --git a/e2e/tests/markdown-breaks.spec.ts b/e2e/tests/markdown-breaks.spec.ts new file mode 100644 index 00000000..6267ca14 --- /dev/null +++ b/e2e/tests/markdown-breaks.spec.ts @@ -0,0 +1,294 @@ +import { expect, test } from '@playwright/test'; +import { getList } from '../helpers/frappe'; + +interface WikiDocument { + name: string; + title: string; + content: string; + route: string; + doc_key?: string; +} + +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('/wiki'); + await page.waitForLoadState('networkidle'); + + const spaceLink = page.locator('a[href*="/wiki/spaces/"]').first(); + await expect(spaceLink).toBeVisible({ timeout: 5000 }); + await spaceLink.click(); + await page.waitForLoadState('networkidle'); + + const createFirstPage = page.locator( + 'button:has-text("Create First Page")', + ); + const newPageButton = page.locator('button[title="New Page"]'); + + if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { + await createFirstPage.click(); + } else { + await newPageButton.click(); + } + + await page.getByLabel('Title').fill(pageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .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('editor should round-trip single line breaks (soft breaks)', async ({ + page, + }) => { + const pageTitle = `md-breaks-soft-${Date.now()}`; + const editor = await createPageAndOpenEditor(page, pageTitle); + + // Use the Tiptap editor API to set markdown content with single newlines + const result = await page.evaluate(() => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + const editor = ed?.editor; + if (!editor) return { error: 'editor not found' }; + + const input = 'Line 1\nLine 2\nLine 3'; + editor.commands.setContent(input, { contentType: 'markdown' }); + const md1 = editor.getMarkdown(); + + // Round-trip: parse the output back + editor.commands.setContent(md1, { contentType: 'markdown' }); + const md2 = editor.getMarkdown(); + const html = editor.getHTML(); + + return { input, md1, md2, html, roundTrip: md1 === md2 }; + }); + + expect(result).not.toHaveProperty('error'); + // Single newlines should produce hard breaks (
) within the same paragraph + expect(result.html).toContain('
'); + expect(result.html).toMatch( + /

.*Line 1.*
.*Line 2.*
.*Line 3.*<\/p>/, + ); + // Round-trip should be stable + expect(result.roundTrip).toBe(true); + }); + + test('editor should round-trip consecutive blank lines', async ({ page }) => { + const pageTitle = `md-breaks-blank-${Date.now()}`; + const editor = await createPageAndOpenEditor(page, pageTitle); + + const result = await page.evaluate(() => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + const editor = ed?.editor; + if (!editor) return { error: 'editor not found' }; + + // 4 newlines = Hello paragraph + 1 empty paragraph + bye paragraph + const input = 'Hello\n\n\n\nbye'; + editor.commands.setContent(input, { contentType: 'markdown' }); + const md1 = editor.getMarkdown(); + + // Round-trip + editor.commands.setContent(md1, { contentType: 'markdown' }); + const md2 = editor.getMarkdown(); + const html = editor.getHTML(); + + return { input, md1, md2, html, roundTrip: md1 === md2 }; + }); + + expect(result).not.toHaveProperty('error'); + // Should have 3 paragraphs: Hello, empty, bye + expect(result.html).toBe('

Hello

bye

'); + // Markdown should preserve the 4 newlines + expect(result.md1).toBe('Hello\n\n\n\nbye'); + expect(result.roundTrip).toBe(true); + }); + + test('editor should round-trip multiple consecutive blank lines', async ({ + page, + }) => { + const pageTitle = `md-breaks-multi-${Date.now()}`; + const editor = await createPageAndOpenEditor(page, pageTitle); + + const result = await page.evaluate(() => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + const editor = ed?.editor; + if (!editor) return { error: 'editor not found' }; + + // 6 newlines = Hello paragraph + 2 empty paragraphs + bye paragraph + const input = 'Hello\n\n\n\n\n\nbye'; + editor.commands.setContent(input, { contentType: 'markdown' }); + const md1 = editor.getMarkdown(); + + editor.commands.setContent(md1, { contentType: 'markdown' }); + const md2 = editor.getMarkdown(); + const html = editor.getHTML(); + + return { input, md1, md2, html, roundTrip: md1 === md2 }; + }); + + expect(result).not.toHaveProperty('error'); + expect(result.html).toBe('

Hello

bye

'); + expect(result.md1).toBe('Hello\n\n\n\n\n\nbye'); + expect(result.roundTrip).toBe(true); + }); + + test('editor should round-trip mixed content: headings, breaks, and soft breaks', async ({ + page, + }) => { + const pageTitle = `md-breaks-mixed-${Date.now()}`; + const editor = await createPageAndOpenEditor(page, pageTitle); + + const result = await page.evaluate(() => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + const editor = ed?.editor; + if (!editor) return { error: 'editor not found' }; + + const input = + '# Title\n\nParagraph 1\n\n\n\nParagraph 2\n\nLine A\nLine B\n\n\n\n\n\nEnd'; + editor.commands.setContent(input, { contentType: 'markdown' }); + const md1 = editor.getMarkdown(); + + editor.commands.setContent(md1, { contentType: 'markdown' }); + const md2 = editor.getMarkdown(); + + return { input, md1, md2, roundTrip: md1 === md2 }; + }); + + expect(result).not.toHaveProperty('error'); + // Headings, normal paragraphs, blank lines, and soft breaks should all survive + expect(result.md1).toContain('# Title'); + expect(result.md1).toContain('Paragraph 1\n\n\n\nParagraph 2'); + expect(result.md1).toMatch(/Line A {2}\nLine B/); + expect(result.md1).toContain('\n\n\n\n\n\nEnd'); + expect(result.roundTrip).toBe(true); + }); + + test('standard paragraph breaks should not create empty paragraphs', async ({ + page, + }) => { + const pageTitle = `md-breaks-standard-${Date.now()}`; + const editor = await createPageAndOpenEditor(page, pageTitle); + + const result = await page.evaluate(() => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + const editor = ed?.editor; + if (!editor) return { error: 'editor not found' }; + + // Normal double newline = standard paragraph break, no empty paragraphs + const input = 'Hello\n\nbye'; + editor.commands.setContent(input, { contentType: 'markdown' }); + const md1 = editor.getMarkdown(); + const html = editor.getHTML(); + + return { md1, html }; + }); + + expect(result).not.toHaveProperty('error'); + expect(result.html).toBe('

Hello

bye

'); + expect(result.md1).toBe('Hello\n\nbye'); + }); + + test('blank lines should persist through save and reload', async ({ + page, + }) => { + const pageTitle = `md-breaks-persist-${Date.now()}`; + const editor = await createPageAndOpenEditor(page, pageTitle); + + const inputMarkdown = + 'First paragraph\n\n\n\nSecond paragraph\n\nLine A\nLine B'; + + // Set content with blank lines via the editor API + await page.evaluate((md) => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + ed?.editor?.commands.setContent(md, { contentType: 'markdown' }); + }, inputMarkdown); + + // Save the draft + await page.click('button:has-text("Save Draft")'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Reload the page to force a fresh load from the server + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + // The editor should reload with the saved content + const editorAfterReload = page.locator( + '.ProseMirror, [contenteditable="true"]', + ); + await expect(editorAfterReload).toBeVisible({ timeout: 10000 }); + + // Get the markdown from the reloaded editor + const result = await page.evaluate(() => { + const ed = document.querySelector('.ProseMirror') as HTMLElement & { + editor?: { + commands: { setContent: (c: string, o?: object) => void }; + getMarkdown: () => string; + getHTML: () => string; + }; + }; + const editor = ed?.editor; + if (!editor) return { error: 'editor not found' }; + return { markdown: editor.getMarkdown(), html: editor.getHTML() }; + }); + + expect(result).not.toHaveProperty('error'); + // Blank lines should be preserved after save + reload + expect(result.markdown).toContain( + 'First paragraph\n\n\n\nSecond paragraph', + ); + // Soft breaks should be preserved + expect(result.markdown).toMatch(/Line A {2}\nLine B/); + // HTML should have the empty paragraph + expect(result.html).toContain( + '

First paragraph

Second paragraph

', + ); + }); +}); diff --git a/e2e/tests/mobile-view.spec.ts b/e2e/tests/mobile-view.spec.ts index 03d29b72..3c7a0969 100644 --- a/e2e/tests/mobile-view.spec.ts +++ b/e2e/tests/mobile-view.spec.ts @@ -122,6 +122,9 @@ async function createPublishedTestPage( return `/${routes[0].route}`; } +/** Locator helpers using data-testid attributes */ +const tid = (page: Page, id: string) => page.getByTestId(id); + test.describe('Mobile View', () => { test.describe('Mobile Header', () => { test('should display mobile header on small viewport', async ({ @@ -138,16 +141,19 @@ test.describe('Mobile View', () => { await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // Mobile container should be visible (lg:hidden means visible below lg breakpoint) - const mobileContainer = page.locator('.lg\\:hidden').first(); - await expect(mobileContainer).toBeVisible(); + // Mobile nav container should be visible + const mobileNav = tid(page, 'mobile-nav'); + await expect(mobileNav).toBeVisible(); // Mobile header should be visible - const mobileHeader = mobileContainer.locator('header').first(); + const mobileHeader = tid(page, 'mobile-header'); await expect(mobileHeader).toBeVisible(); - // Header should be sticky - await expect(mobileHeader).toHaveClass(/sticky/); + // Nav container should be sticky (check computed style, not class name) + const position = await mobileNav.evaluate( + (el) => getComputedStyle(el).position, + ); + expect(position).toBe('sticky'); // Desktop sidebar should be hidden on mobile const desktopSidebar = page.locator('.wiki-sidebar'); @@ -168,14 +174,11 @@ test.describe('Mobile View', () => { await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // Mobile header should contain the space name (a span with font-semibold) - const mobileHeader = page.locator('.lg\\:hidden header').first(); - const spaceNameElement = mobileHeader.locator('span.font-semibold'); - await expect(spaceNameElement).toBeVisible(); + // Space name element should be visible and non-empty + const spaceName = tid(page, 'mobile-space-name'); + await expect(spaceName).toBeVisible(); - // The space name should not be empty - const spaceNameText = await spaceNameElement.textContent(); - expect(spaceNameText).toBeTruthy(); + const spaceNameText = await spaceName.textContent(); expect(spaceNameText?.trim().length).toBeGreaterThan(0); }); }); @@ -195,20 +198,15 @@ test.describe('Mobile View', () => { await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // Find and click the menu toggle button (hamburger icon) - const menuButton = page.locator('.lg\\:hidden header button').first(); - await expect(menuButton).toBeVisible(); - await menuButton.click(); + // Click the menu toggle button + await tid(page, 'mobile-menu-toggle').click(); // Bottom sheet should be visible - const bottomSheet = page.locator('.lg\\:hidden .fixed.bottom-0'); + const bottomSheet = tid(page, 'mobile-bottom-sheet'); await expect(bottomSheet).toBeVisible({ timeout: 5000 }); - // Bottom sheet should have rounded top corners - await expect(bottomSheet).toHaveClass(/rounded-t/); - // Overlay backdrop should be visible - const overlay = page.locator('.lg\\:hidden .fixed.inset-0.bg-black\\/50'); + const overlay = tid(page, 'mobile-overlay'); await expect(overlay).toBeVisible(); }); @@ -227,10 +225,9 @@ test.describe('Mobile View', () => { await page.waitForLoadState('networkidle'); // Open the bottom sheet - const menuButton = page.locator('.lg\\:hidden header button').first(); - await menuButton.click(); + await tid(page, 'mobile-menu-toggle').click(); - const bottomSheet = page.locator('.lg\\:hidden .fixed.bottom-0'); + const bottomSheet = tid(page, 'mobile-bottom-sheet'); await expect(bottomSheet).toBeVisible({ timeout: 5000 }); // Click the overlay to close (click at top of viewport, away from bottom sheet) @@ -255,13 +252,12 @@ test.describe('Mobile View', () => { await page.waitForLoadState('networkidle'); // Open the bottom sheet - const menuButton = page.locator('.lg\\:hidden header button').first(); - await menuButton.click(); + await tid(page, 'mobile-menu-toggle').click(); - const bottomSheet = page.locator('.lg\\:hidden .fixed.bottom-0'); + const bottomSheet = tid(page, 'mobile-bottom-sheet'); await expect(bottomSheet).toBeVisible({ timeout: 5000 }); - // Find and click the close button (X icon) inside bottom sheet + // Click the close button inside bottom sheet const closeButton = bottomSheet.getByRole('button', { name: 'Close sidebar', }); @@ -286,10 +282,9 @@ test.describe('Mobile View', () => { await page.waitForLoadState('networkidle'); // Open the bottom sheet - const menuButton = page.locator('.lg\\:hidden header button').first(); - await menuButton.click(); + await tid(page, 'mobile-menu-toggle').click(); - const bottomSheet = page.locator('.lg\\:hidden .fixed.bottom-0'); + const bottomSheet = tid(page, 'mobile-bottom-sheet'); await expect(bottomSheet).toBeVisible({ timeout: 5000 }); // Bottom sheet should contain a nav element with wiki links @@ -316,10 +311,9 @@ test.describe('Mobile View', () => { await page.waitForLoadState('networkidle'); // Open the bottom sheet - const menuButton = page.locator('.lg\\:hidden header button').first(); - await menuButton.click(); + await tid(page, 'mobile-menu-toggle').click(); - const bottomSheet = page.locator('.lg\\:hidden .fixed.bottom-0'); + const bottomSheet = tid(page, 'mobile-bottom-sheet'); await expect(bottomSheet).toBeVisible({ timeout: 5000 }); // Click a navigation link @@ -349,14 +343,13 @@ test.describe('Mobile View', () => { await page.waitForLoadState('networkidle'); // Open the bottom sheet - const menuButton = page.locator('.lg\\:hidden header button').first(); - await menuButton.click(); + await tid(page, 'mobile-menu-toggle').click(); - const bottomSheet = page.locator('.lg\\:hidden .fixed.bottom-0'); + const bottomSheet = tid(page, 'mobile-bottom-sheet'); await expect(bottomSheet).toBeVisible({ timeout: 5000 }); - // Drag handle should be visible (rounded pill shape at top) - const dragHandle = bottomSheet.locator('.rounded-full').first(); + // Drag handle should be visible + const dragHandle = tid(page, 'mobile-drag-handle'); await expect(dragHandle).toBeVisible(); }); }); @@ -393,14 +386,12 @@ Content for second section.`; const contentHeadings = page.locator('#wiki-content h2'); await expect(contentHeadings.first()).toBeVisible({ timeout: 10000 }); - // Verify the mobile header structure exists - const mobileHeader = page.locator('.lg\\:hidden header'); - await expect(mobileHeader).toBeVisible(); + // Verify the mobile header exists + await expect(tid(page, 'mobile-header')).toBeVisible(); - // The TOC dropdown button exists in DOM (may be hidden via x-show) - const tocButton = mobileHeader.locator('button:has-text("On this page")'); - // Just verify the element exists in the DOM structure - await expect(tocButton).toHaveCount(1); + // The TOC dropdown toggle exists in DOM (may be hidden via x-show) + const tocToggle = tid(page, 'mobile-toc-toggle'); + await expect(tocToggle).toHaveCount(1); }); test('should render headings with anchor links on mobile', async ({ @@ -461,24 +452,9 @@ Getting started content.`; await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // The mobile header should have buttons for search and theme toggle - const mobileHeader = page.locator('.lg\\:hidden header').first(); - await expect(mobileHeader).toBeVisible(); - - // Find the header's right section with action buttons - const headerButtons = mobileHeader.locator('button'); - - // Should have multiple buttons (menu, search, theme toggle at minimum) - const buttonCount = await headerButtons.count(); - expect(buttonCount).toBeGreaterThanOrEqual(3); - - // The theme button should contain an SVG icon (sun or moon) - // It's in the right side buttons section - const rightButtons = mobileHeader - .locator('div.flex.items-center.gap-1') - .first() - .locator('button'); - await expect(rightButtons.first()).toBeVisible(); + // Theme toggle button should be visible + const themeToggle = tid(page, 'mobile-theme-toggle'); + await expect(themeToggle).toBeVisible(); }); }); @@ -497,20 +473,10 @@ Getting started content.`; await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // Find search button in mobile header - // It's one of the buttons on the right side of the header - const mobileHeader = page.locator('.lg\\:hidden header').first(); - const headerButtons = mobileHeader.locator( - '> div > div:last-child button', - ); - - // Search button should be visible - const searchButton = headerButtons.first(); - await expect(searchButton).toBeVisible(); - await searchButton.click(); + // Click the search button + await tid(page, 'mobile-search-button').click(); // Search modal/dialog should open - // Look for search input or search dialog const searchInput = page.locator( '[role="dialog"] input, [role="combobox"], input[type="search"], input[placeholder*="Search"]', ); @@ -533,16 +499,16 @@ Getting started content.`; await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // Verify mobile header is visible on mobile - const mobileContainer = page.locator('.lg\\:hidden').first(); - await expect(mobileContainer).toBeVisible(); + // Verify mobile nav is visible on mobile + const mobileNav = tid(page, 'mobile-nav'); + await expect(mobileNav).toBeVisible(); // Switch to desktop viewport await page.setViewportSize({ width: 1100, height: 900 }); await page.waitForTimeout(300); - // Mobile header should now be hidden - await expect(mobileContainer).not.toBeVisible(); + // Mobile nav should now be hidden + await expect(mobileNav).not.toBeVisible(); // Desktop sidebar should be visible const desktopSidebar = page.locator('.wiki-sidebar'); @@ -563,9 +529,9 @@ Getting started content.`; await page.goto(publicUrl); await page.waitForLoadState('networkidle'); - // Mobile header should be visible on tablet (768px < 1024px lg breakpoint) - const mobileContainer = page.locator('.lg\\:hidden').first(); - await expect(mobileContainer).toBeVisible(); + // Mobile nav should be visible on tablet (768px < 1024px lg breakpoint) + const mobileNav = tid(page, 'mobile-nav'); + await expect(mobileNav).toBeVisible(); }); }); }); diff --git a/e2e/tests/sidebar.spec.ts b/e2e/tests/sidebar.spec.ts index 2c26e79a..34cb3e3d 100644 --- a/e2e/tests/sidebar.spec.ts +++ b/e2e/tests/sidebar.spec.ts @@ -153,6 +153,144 @@ test.describe('Public Sidebar', () => { }); test.describe('Sidebar Navigation', () => { + test('should use client-side navigation without full page refresh when clicking sidebar links', async ({ + page, + request, + }) => { + await page.setViewportSize({ width: 1100, height: 900 }); + + // Navigate to wiki and click first space + await page.goto('/wiki'); + await page.waitForLoadState('networkidle'); + + const spaceLink = page.locator('a[href*="/wiki/spaces/"]').first(); + await expect(spaceLink).toBeVisible({ timeout: 5000 }); + await spaceLink.click(); + await page.waitForLoadState('networkidle'); + + // Create two pages so we can navigate between them + const firstPageTitle = `spa-nav-first-${Date.now()}`; + const createFirstPage = page.locator( + 'button:has-text("Create First Page")', + ); + const newPageButton = page.locator('button[title="New Page"]'); + + if ( + await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false) + ) { + await createFirstPage.click(); + } else { + await newPageButton.click(); + } + + await page.getByLabel('Title').fill(firstPageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .click(); + await page.waitForLoadState('networkidle'); + + await page + .locator('aside') + .getByText(firstPageTitle, { exact: true }) + .click(); + await page.waitForURL(/\/draft\/[^/?#]+/); + const draftMatch = page.url().match(/\/draft\/([^/?#]+)/); + expect(draftMatch).toBeTruthy(); + const firstDocKey = decodeURIComponent(draftMatch?.[1] ?? ''); + 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 Draft")'); + await page.waitForLoadState('networkidle'); + + const secondPageTitle = `spa-nav-second-${Date.now()}`; + await page.locator('button[title="New Page"]').click(); + await page.getByLabel('Title').fill(secondPageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .click(); + await page.waitForLoadState('networkidle'); + + await page + .locator('aside') + .getByText(secondPageTitle, { exact: true }) + .click(); + 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 Draft")'); + await page.waitForLoadState('networkidle'); + + // Merge both pages + await page.getByRole('button', { name: 'Submit for Review' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page).toHaveURL(/\/wiki\/change-requests\//, { + timeout: 10000, + }); + await page.getByRole('button', { name: 'Merge' }).click(); + await expect( + page.locator('text=Change request merged').first(), + ).toBeVisible({ timeout: 15000 }); + + // Open first page in public view + const routes = await getList( + request, + 'Wiki Document', + { + fields: ['route', 'doc_key'], + filters: { doc_key: firstDocKey }, + limit: 1, + }, + ); + expect(routes.length).toBe(1); + const publicPage = await page.context().newPage(); + await publicPage.goto(`/${routes[0].route}`); + await publicPage.waitForLoadState('networkidle'); + await publicPage.setViewportSize({ width: 1100, height: 900 }); + + await expect(publicPage.locator('#wiki-page-title')).toHaveText( + firstPageTitle, + { timeout: 10000 }, + ); + + // Set a sentinel on window — a full page refresh will wipe it + await publicPage.evaluate(() => { + ( + window as unknown as Record + ).__spa_nav_sentinel = true; + }); + + // Click the second page in the sidebar + const sidebar = publicPage.locator('.wiki-sidebar'); + const secondPageLink = sidebar.locator( + `.wiki-link:has-text("${secondPageTitle}")`, + ); + await expect(secondPageLink).toBeVisible({ timeout: 5000 }); + await secondPageLink.click(); + + // Wait for content to update (SPA navigation) + await expect(publicPage.locator('#wiki-page-title')).toHaveText( + secondPageTitle, + { timeout: 10000 }, + ); + + // The sentinel must still exist — if a full page refresh happened, it's gone + const sentinelSurvived = await publicPage.evaluate( + () => + (window as unknown as Record).__spa_nav_sentinel === + true, + ); + expect(sentinelSurvived).toBe(true); + + // Verify URL changed (pushState, not a reload) + expect(publicPage.url()).not.toContain(routes[0].route); + + await publicPage.close(); + }); + test('should update content, URL, active state, and metadata when clicking sidebar links', async ({ page, request, @@ -277,7 +415,7 @@ test.describe('Public Sidebar', () => { await expect(lastUpdated).toContainText('Last updated'); // Get initial edit link href - const editLinks = publicPage.locator('#wiki-edit-link'); + const editLinks = publicPage.locator('.wiki-edit-link, #wiki-edit-link'); const initialEditHref = await editLinks.first().getAttribute('href'); // Click the second page link in sidebar diff --git a/e2e/tests/toc-navigation.spec.ts b/e2e/tests/toc-navigation.spec.ts new file mode 100644 index 00000000..20e7eb70 --- /dev/null +++ b/e2e/tests/toc-navigation.spec.ts @@ -0,0 +1,263 @@ +import { expect, test } from '@playwright/test'; +import { getList } from '../helpers/frappe'; + +interface WikiDocumentRoute { + route: string; + doc_key: string; +} + +declare global { + interface Window { + wikiEditor: { + commands: { + setContent: ( + content: string, + options?: { contentType?: string }, + ) => void; + }; + }; + } +} + +/** + * Tests that the Table of Contents updates correctly during + * client-side sidebar navigation (SPA navigation). + */ +test.describe('TOC Navigation', () => { + test('should update TOC headings when navigating between pages via sidebar', async ({ + page, + request, + }) => { + await page.setViewportSize({ width: 1100, height: 900 }); + + // Navigate to wiki and click first space + await page.goto('/wiki'); + await page.waitForLoadState('networkidle'); + + const spaceLink = page.locator('a[href*="/wiki/spaces/"]').first(); + await expect(spaceLink).toBeVisible({ timeout: 5000 }); + await spaceLink.click(); + await page.waitForLoadState('networkidle'); + + // Create first page with specific headings + const firstPageTitle = `toc-nav-first-${Date.now()}`; + const createFirstPage = page.locator( + 'button:has-text("Create First Page")', + ); + const newPageButton = page.locator('button[title="New Page"]'); + + if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { + await createFirstPage.click(); + } else { + await newPageButton.click(); + } + + await page.getByLabel('Title').fill(firstPageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .click(); + await page.waitForLoadState('networkidle'); + + // Open first page and set content with headings + await page + .locator('aside') + .getByText(firstPageTitle, { exact: true }) + .click(); + await page.waitForURL(/\/draft\/[^/?#]+/); + const draftMatch1 = page.url().match(/\/draft\/([^/?#]+)/); + expect(draftMatch1).toBeTruthy(); + const firstDocKey = decodeURIComponent(draftMatch1?.[1] ?? ''); + + const editor = page.locator('.ProseMirror, [contenteditable="true"]'); + await expect(editor).toBeVisible({ timeout: 10000 }); + await page.waitForFunction(() => window.wikiEditor !== undefined, { + timeout: 10000, + }); + + const firstPageMarkdown = `## Alpha Section + +Alpha content here. + +## Beta Section + +Beta content here. + +### Beta Subsection + +Beta sub content.`; + + await page.evaluate((content) => { + window.wikiEditor.commands.setContent(content, { + contentType: 'markdown', + }); + }, firstPageMarkdown); + + await expect(editor.locator('h2:has-text("Alpha Section")')).toBeVisible({ + timeout: 5000, + }); + await editor.click(); + await page.waitForTimeout(500); + + await page.click('button:has-text("Save Draft")'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Create second page with different headings + const secondPageTitle = `toc-nav-second-${Date.now()}`; + await page.locator('button[title="New Page"]').click(); + await page.getByLabel('Title').fill(secondPageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .click(); + await page.waitForLoadState('networkidle'); + + await page + .locator('aside') + .getByText(secondPageTitle, { exact: true }) + .click(); + await page.waitForURL(/\/draft\/[^/?#]+/); + await expect(editor).toBeVisible({ timeout: 10000 }); + await page.waitForFunction( + () => window.wikiEditor?.commands?.setContent !== undefined, + { timeout: 10000 }, + ); + // Ensure editor is ready to accept content + await page.waitForTimeout(500); + + const secondPageMarkdown = `## Gamma Section + +Gamma content here. + +## Delta Section + +Delta content here. + +### Delta Subsection + +Delta sub content. + +## Epsilon Section + +Epsilon content here.`; + + await page.evaluate((content) => { + window.wikiEditor.commands.setContent(content, { + contentType: 'markdown', + }); + }, secondPageMarkdown); + + await expect(editor.locator('h2:has-text("Gamma Section")')).toBeVisible({ + timeout: 5000, + }); + await editor.click(); + await page.waitForTimeout(500); + + await page.click('button:has-text("Save Draft")'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + // Submit and merge both pages + await page.getByRole('button', { name: 'Submit for Review' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page).toHaveURL(/\/wiki\/change-requests\//, { + timeout: 10000, + }); + // Handle both "Merge" and "Resolve & Merge" buttons (conflicts may auto-resolve) + const mergeButton = page.getByRole('button', { name: /Merge/ }); + await expect(mergeButton).toBeVisible({ timeout: 10000 }); + await mergeButton.click(); + await expect( + page.locator('text=Change request merged').first(), + ).toBeVisible({ timeout: 15000 }); + + // Open public view for the first page + const routes = await getList(request, 'Wiki Document', { + fields: ['route', 'doc_key'], + filters: { doc_key: firstDocKey }, + limit: 1, + }); + expect(routes.length).toBe(1); + + const publicPage = await page.context().newPage(); + await publicPage.goto(`/${routes[0].route}`); + await publicPage.waitForLoadState('networkidle'); + await publicPage.setViewportSize({ width: 1100, height: 900 }); + + // Verify first page TOC has correct headings + const tocContainer = publicPage.locator('#wiki-toc'); + await expect(tocContainer).toBeVisible({ timeout: 10000 }); + + const tocNav = tocContainer.locator('nav'); + await expect(tocNav.locator('a:has-text("Alpha Section")')).toBeVisible(); + await expect(tocNav.locator('a:has-text("Beta Section")')).toBeVisible(); + await expect(tocNav.locator('a:has-text("Beta Subsection")')).toBeVisible(); + + // First page TOC should NOT contain second page headings + await expect( + tocNav.locator('a:has-text("Gamma Section")'), + ).not.toBeVisible(); + await expect( + tocNav.locator('a:has-text("Delta Section")'), + ).not.toBeVisible(); + + // Navigate to second page via sidebar + const sidebar = publicPage.locator('.wiki-sidebar'); + const secondPageLink = sidebar.locator( + `.wiki-link:has-text("${secondPageTitle}")`, + ); + await expect(secondPageLink).toBeVisible({ timeout: 5000 }); + await secondPageLink.click(); + + // Wait for content to update + await expect(publicPage.locator('#wiki-page-title')).toHaveText( + secondPageTitle, + { timeout: 10000 }, + ); + + // Verify TOC updated to second page headings + await expect(tocNav.locator('a:has-text("Gamma Section")')).toBeVisible({ + timeout: 5000, + }); + await expect(tocNav.locator('a:has-text("Delta Section")')).toBeVisible(); + await expect( + tocNav.locator('a:has-text("Delta Subsection")'), + ).toBeVisible(); + await expect(tocNav.locator('a:has-text("Epsilon Section")')).toBeVisible(); + + // First page headings should no longer be in TOC + await expect( + tocNav.locator('a:has-text("Alpha Section")'), + ).not.toBeVisible(); + await expect( + tocNav.locator('a:has-text("Beta Section")'), + ).not.toBeVisible(); + + // Navigate back to first page via sidebar + const firstPageLink = sidebar.locator( + `.wiki-link:has-text("${firstPageTitle}")`, + ); + await expect(firstPageLink).toBeVisible({ timeout: 5000 }); + await firstPageLink.click(); + + // Wait for content to update back + await expect(publicPage.locator('#wiki-page-title')).toHaveText( + firstPageTitle, + { timeout: 10000 }, + ); + + // Verify TOC reverted to first page headings + await expect(tocNav.locator('a:has-text("Alpha Section")')).toBeVisible({ + timeout: 5000, + }); + await expect(tocNav.locator('a:has-text("Beta Section")')).toBeVisible(); + + // Second page headings should be gone again + await expect( + tocNav.locator('a:has-text("Gamma Section")'), + ).not.toBeVisible(); + + await publicPage.close(); + }); +}); diff --git a/e2e/tests/wiki.spec.ts b/e2e/tests/wiki.spec.ts index 0b89a5b6..b8d2daa4 100644 --- a/e2e/tests/wiki.spec.ts +++ b/e2e/tests/wiki.spec.ts @@ -30,16 +30,20 @@ test.describe('Wiki Editor', () => { await page.waitForLoadState('networkidle'); // Click create new space button - await page.click('button:has-text("New"), button:has-text("Create")'); + await page.click('button:has-text("New Space")'); - // Fill in space details in the dialog - await page.waitForSelector('[role="dialog"], .modal', { state: 'visible' }); + // Fill in space details in the dialog (scope to dialog to avoid hitting search input) + const dialog = page.locator('[role="dialog"]').first(); + await dialog.waitFor({ state: 'visible' }); const spaceName = `Test Space ${Date.now()}`; - await page.fill('input[type="text"]', spaceName); + await dialog.locator('input[type="text"]').first().fill(spaceName); - // Submit the form - await page.click('button:has-text("Create"), button[type="submit"]'); + // Wait for route to auto-populate from space name + await page.waitForTimeout(500); + + // Submit the form (click Create button inside the dialog) + await dialog.locator('button:has-text("Create")').click(); // Wait for the dialog to close and page to update await page.waitForLoadState('networkidle'); @@ -65,13 +69,12 @@ test.describe('Wiki Editor', () => { await expect(page.locator('aside')).toBeVisible(); // Look for add/create page button in sidebar + // "New Page" is an icon-only button with title attribute, not text content const addButton = page - .locator( - 'button:has-text("Create First Page"), button:has-text("New Page")', - ) + .locator('button:has-text("Create First Page"), button[title="New Page"]') .first(); await expect(addButton).toBeVisible({ - timeout: 5000, + timeout: 10000, }); await addButton.click(); @@ -138,33 +141,34 @@ test.describe('Wiki Editor', () => { await spaceLink.click(); await page.waitForLoadState('networkidle'); - // Check if there's an existing page (indicated by "Not Published" badge in tree) - // or if we need to create one - const pageTreeRow = page - .locator('aside') - .locator('.cursor-pointer') - .first(); + // Wait for sidebar to load - either "Create First Page" or "New Page" icon button const createFirstPage = page.locator( 'button:has-text("Create First Page")', ); + const newPageButton = page.locator('button[title="New Page"]'); + await expect(createFirstPage.or(newPageButton)).toBeVisible({ + timeout: 10000, + }); - if (await createFirstPage.isVisible({ timeout: 2000 }).catch(() => false)) { - // No pages - create one + // Always create a new page so we know exactly what to click + const pageTitle = `Test Page ${Date.now()}`; + if (await createFirstPage.isVisible().catch(() => false)) { await createFirstPage.click(); - const pageTitle = `Test Page ${Date.now()}`; - await page.getByLabel('Title').fill(pageTitle); - await page - .getByRole('dialog') - .getByRole('button', { name: 'Save Draft' }) - .click(); - await page.waitForLoadState('networkidle'); - await page.locator('aside').getByText(pageTitle, { exact: true }).click(); } else { - // Pages exist - click on first tree row - await pageTreeRow.click(); - await page.waitForLoadState('networkidle'); + await newPageButton.click(); } + await page.getByLabel('Title').fill(pageTitle); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Save Draft' }) + .click(); + await page.waitForLoadState('networkidle'); + + // Click the newly created page in the sidebar + await page.locator('aside').getByText(pageTitle, { exact: true }).click(); + await page.waitForLoadState('networkidle'); + // Editor should be visible (clicking a page opens it in edit mode) await expect( page.locator('.ProseMirror, [contenteditable="true"]'), diff --git a/frontend/index.html b/frontend/index.html index 7474f399..dd1ceb76 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ + Frappe Wiki diff --git a/frontend/package.json b/frontend/package.json index 2f14523d..db3b126f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,24 +13,26 @@ "dependencies": { "@floating-ui/dom": "^1.6.0", "@pierre/diffs": "^1.0.8", - "@tiptap/core": "^3.14.0", - "@tiptap/extension-code-block": "^3.14.0", - "@tiptap/extension-code-block-lowlight": "^3.14.0", - "@tiptap/extension-image": "^3.14.0", - "@tiptap/extension-list": "^3.14.0", - "@tiptap/extension-table": "^3.14.0", - "@tiptap/extensions": "^3.14.0", - "@tiptap/markdown": "^3.14.0", - "@tiptap/pm": "^3.14.0", - "@tiptap/starter-kit": "^3.14.0", - "@tiptap/suggestion": "^3.14.0", - "@tiptap/vue-3": "^3.14.0", + "@tiptap/core": "^3.19.0", + "@tiptap/extension-code-block": "^3.19.0", + "@tiptap/extension-code-block-lowlight": "^3.19.0", + "@tiptap/extension-image": "^3.19.0", + "@tiptap/extension-list": "^3.19.0", + "@tiptap/extension-table": "^3.19.0", + "@tiptap/extensions": "^3.19.0", + "@tiptap/markdown": "^3.19.0", + "@tiptap/pm": "^3.19.0", + "@tiptap/starter-kit": "^3.19.0", + "@tiptap/suggestion": "^3.19.0", + "@tiptap/vue-3": "^3.19.0", "@vueuse/core": "^14.1.0", + "@vueuse/router": "^14.2.1", "feather-icons": "^4.29.2", "frappe-ui": "^0.1.235", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "lucide-vue-next": "^0.468.0", + "pinia": "^3.0.4", "socket.io-client": "^4.7.2", "tippy.js": "^6.3.7", "vue": "^3.5.13", diff --git a/frontend/src/components/ContributionBanner.vue b/frontend/src/components/ContributionBanner.vue index 079fbaac..ff3b2837 100644 --- a/frontend/src/components/ContributionBanner.vue +++ b/frontend/src/components/ContributionBanner.vue @@ -1,6 +1,6 @@