import { expect, test, type Page } from '@playwright/test' /** * 富文本新增能力的回归: * 1. 列表样式(无序 / 有序的多档位); * 2. 「整理格式」清理空段落 / 空白文本 / 空 mark; * 3. 预览(模态框只读渲染); * 4. 打印(隐藏 iframe,只打正文); * 5. 源代码(模态框编辑 HTML + 保存写回)。 * * 这些都依赖真实的 contenteditable + 选区 / 真实 DOM(打印要 iframe、模态框要 Teleport), * happy-dom 里跑不了,所以放在 E2E。 */ const EDITOR = '[data-testid="rich-text"]' /** 清空编辑器后输入一段文本(不选中,光标落在段落末尾) */ async function typeText(page: Page, text: string): Promise { const editor = page.getByTestId('rich-text') await editor.scrollIntoViewIfNeeded() await editor.locator('.tiptap').click() await page.keyboard.press('Control+A') await page.keyboard.press('Delete') await page.keyboard.type(text) } /** 打开「更多」菜单 */ async function openMore(page: Page): Promise { await page .locator(`${EDITOR} .g3-rich-text__toolbar`) .getByRole('button', { name: '更多' }) .click() } /** * 点开某个列表按钮的样式面板(面板 Teleport 到 body)。 * * 分裂按钮(G3ButtonGroup 组合):点**图标**一键建/撤列表,点**右侧箭头**才打开样式面板。 * 箭头是 ButtonGroup 里的第二个按钮,aria-label 为「无序列表样式 / 有序列表样式」。 */ async function openListPanel(page: Page, name: '无序列表' | '有序列表') { await page .locator(`${EDITOR} .g3-rich-text__toolbar`) .getByRole('button', { name: `${name}样式` }) .click() const panel = page.locator('.g3-rich-text__list-panel').last() await expect(panel).toBeVisible() return panel } /** 点列表按钮的**图标**(一键建/撤列表,不打开面板) */ async function clickListIcon(page: Page, name: '无序列表' | '有序列表') { await page .locator(`${EDITOR} .g3-rich-text__toolbar`) .getByRole('button', { name }) .click() } test.describe('RichText · 列表样式', () => { test('无序列表:实心方块写进 ul 的 type 属性', async ({ page }) => { await page.goto('/') await typeText(page, '第一项') // 点开无序列表按钮的样式面板,选「实心方块」—— 一步完成建列表 + 设样式 const panel = await openListPanel(page, '无序列表') await panel.getByRole('menuitemradio', { name: '实心方块' }).click() // 样式落在 type 属性上(不是 class)—— 属性自描述,导出即正确 await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(1) await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveAttribute('type', 'square') }) test('有序列表:大写罗马数字写进 ol 的 type 属性', async ({ page }) => { await page.goto('/') await typeText(page, '第一步') const panel = await openListPanel(page, '有序列表') await panel.getByRole('menuitemradio', { name: '大写罗马数字' }).click() await expect(page.locator(`${EDITOR} .tiptap ol`)).toHaveAttribute('type', 'I') }) test('有序列表:小写希腊字母走 style(type="lower-greek" 是非法 HTML 值)', async ({ page }) => { await page.goto('/') await typeText(page, '第一步') const panel = await openListPanel(page, '有序列表') await panel.getByRole('menuitemradio', { name: '小写希腊字母' }).click() const ol = page.locator(`${EDITOR} .tiptap ol`) // 必须走 style —— 写 type="lower-greek" 会被浏览器当作未知值忽略、退回数字编号 await expect(ol).toHaveAttribute('style', /list-style-type:\s*lower-greek/) await expect(ol).not.toHaveAttribute('type', 'lower-greek') }) test('点图标一键建列表,不打开样式面板', async ({ page }) => { await page.goto('/') await typeText(page, '还没有列表') await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(0) await clickListIcon(page, '无序列表') // 只建列表、不弹面板(样式仍是默认档) await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(1) await expect(page.locator('.g3-rich-text__list-panel')).toHaveCount(0) }) test('点图标再次点击退列表(toggle 语义)', async ({ page }) => { await page.goto('/') await typeText(page, '第一项') await clickListIcon(page, '无序列表') await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(1) await clickListIcon(page, '无序列表') await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(0) }) test('点右侧箭头打开样式面板,点图标不打开', async ({ page }) => { await page.goto('/') await typeText(page, '第一项') // 点箭头:面板打开 await (await openListPanel(page, '无序列表')).getByRole('menuitemradio', { name: '默认' }).click() // 点图标:面板不开(只 toggle 列表) await clickListIcon(page, '无序列表') await expect(page.locator('.g3-rich-text__list-panel')).toHaveCount(0) }) test('当前档位带选中态(aria-checked),档位名是中文而非英文枚举', async ({ page }) => { await page.goto('/') await typeText(page, '第一项') // 先建列表 + 选实心方块 const panel = await openListPanel(page, '无序列表') await panel.getByRole('menuitemradio', { name: '实心方块' }).click() await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveAttribute('type', 'square') // 再打开面板:实心方块是当前档位 → 选中态;「默认」未被选中 const reopened = await openListPanel(page, '无序列表') await expect(reopened.getByRole('menuitemradio', { name: '实心方块' })).toHaveAttribute( 'aria-checked', 'true', ) await expect(reopened.getByRole('menuitemradio', { name: '默认' })).toHaveAttribute( 'aria-checked', 'false', ) // 档位名是中文,不暴露 disc / circle / square 这些英文枚举 await expect(reopened.getByRole('menuitemradio', { name: /disc|circle|square/i })).toHaveCount(0) }) }) test.describe('RichText · 整理格式', () => { test('清理空段落与空白文本,并把统计报给宿主', async ({ page }) => { await page.goto('/') // 用源代码对话框塞入带空段落的正文(比敲键盘造空段落更直接) await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') await expect(dialog).toBeVisible() await dialog.locator('textarea').fill('

第一段

第二段

') await dialog.getByRole('button', { name: '保存' }).click() await expect(dialog).toHaveCount(0) await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(4) // 「更多 → 整理格式」 await openMore(page) await page.getByRole('menuitem', { name: '整理格式' }).click() // 两个空段落被清掉,只剩有内容的两段 await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(2) await expect(page.locator(`${EDITOR} .tiptap p`).first()).toHaveText('第一段') }) test('整理格式是一次可撤销的操作(撤销栈只留一步)', async ({ page }) => { await page.goto('/') await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') await dialog.locator('textarea').fill('

甲

乙

') await dialog.getByRole('button', { name: '保存' }).click() await expect(dialog).toHaveCount(0) await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(5) await openMore(page) await page.getByRole('menuitem', { name: '整理格式' }).click() await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(2) // 一次 Ctrl+Z 就整体退回(若实现成逐段 dispatch,这里只会退回一个空段落) await page.locator(`${EDITOR} .tiptap`).click() await page.keyboard.press('Control+z') await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(5) }) }) test.describe('RichText · 预览', () => { test('模态框只读渲染当前正文,排版规则与编辑区一致', async ({ page }) => { await page.goto('/') await typeText(page, '预览内容') await openMore(page) await page.getByRole('menuitem', { name: '预览' }).click() const dialog = page.locator('.g3-modal') await expect(dialog).toBeVisible() await expect(dialog).toContainText('预览内容') // 只读:预览区里没有 contenteditable const body = dialog.locator('.g3-rich-text__preview-body') await expect(body).toBeVisible() await expect(body.locator('[contenteditable="true"]')).toHaveCount(0) }) test('预览取的是打开那一刻的快照(之后改正文不影响已打开的预览)', async ({ page }) => { await page.goto('/') await typeText(page, '快照') await openMore(page) await page.getByRole('menuitem', { name: '预览' }).click() const dialog = page.locator('.g3-modal') await expect(dialog).toBeVisible() // 预览区不复用编辑器的 DOM 节点(是 v-html 渲染的独立副本) const inEditor = await page .locator(`${EDITOR} .tiptap`) .evaluate((editable) => editable.contains(document.querySelector('.g3-rich-text__preview-body'))) expect(inEditor).toBe(false) }) test('预览里可以发起打印', async ({ page }) => { await page.goto('/') await typeText(page, '打印内容') await openMore(page) await page.getByRole('menuitem', { name: '预览' }).click() const dialog = page.locator('.g3-modal') await expect(dialog).toBeVisible() /** * 拦掉真实的打印调用。 * * 打印走的是**隐藏 iframe 的** `contentWindow.print()`,而不是宿主的 `window.print()`。 * 在 iframe 的文档里注入拦截:`printHtml` 用 `srcdoc` 装载文档、等 `load` 后调 print, * 所以这里在 iframe 挂载时立刻改写它自己窗口的 print(`about:srcdoc` 与父页面同源, * 可以直接访问 `contentWindow`)。 * * 不能用 `HTMLIFrameElement.prototype.contentWindow` 的 getter 覆盖 —— 那会破坏 * 组件自己读 `contentWindow` 的路径(`load` 回调里要拿它调 print), * 结果是「为了让测试通过而把被测代码弄坏」。 */ await page.evaluate(() => { const win = window as unknown as { __printed?: number } win.__printed = 0 const observer = new MutationObserver((records) => { for (const record of records) { for (const node of Array.from(record.addedNodes)) { if (!(node instanceof HTMLIFrameElement)) continue // 等到 iframe 的文档就绪后再改写(srcdoc 是异步解析的) node.addEventListener('load', () => { try { const frameWin = node.contentWindow as unknown as { print: () => void } | null if (frameWin) { frameWin.print = () => { win.__printed = (win.__printed ?? 0) + 1 } } } catch { // 跨源 / 已销毁:忽略,测试会在下面的 poll 里失败并给出真实原因 } }) } } }) observer.observe(document.body, { childList: true }) }) await dialog.getByRole('button', { name: '打印' }).click() await expect .poll(() => page.evaluate(() => (window as unknown as { __printed?: number }).__printed)) .toBeGreaterThan(0) }) }) test.describe('RichText · 打印', () => { test('从「更多」打印:用隐藏 iframe 承载正文,且打印后清理掉', async ({ page }) => { await page.goto('/') await typeText(page, '正文内容') await page.evaluate(() => { const win = window as unknown as { __printed?: number } win.__printed = 0 /** * 记录 iframe 里承载的正文,并拦掉它的 print。 * 一次 `MutationObserver` 同时做两件事:断言「打印走 iframe」与 * 「iframe 里装的是正文」(而不是宿主的整页 HTML)。 */ const state = window as unknown as { __iframeHtml?: string } state.__iframeHtml = '' const observer = new MutationObserver((records) => { for (const record of records) { for (const node of Array.from(record.addedNodes)) { if (!(node instanceof HTMLIFrameElement)) continue state.__iframeHtml = node.getAttribute('srcdoc') ?? '' node.addEventListener('load', () => { try { const frameWin = node.contentWindow as unknown as { print: () => void } | null if (frameWin) { frameWin.print = () => { win.__printed = (win.__printed ?? 0) + 1 } } } catch { // 忽略:poll 会暴露真实问题 } }) } } }) observer.observe(document.body, { childList: true }) }) await openMore(page) await page.getByRole('menuitem', { name: '打印' }).click() await expect .poll(() => page.evaluate(() => (window as unknown as { __printed?: number }).__printed)) .toBeGreaterThan(0) // iframe 里装的是正文,而不是宿主页面的导航 / 按钮 const carried = await page.evaluate( () => (window as unknown as { __iframeHtml?: string }).__iframeHtml ?? '', ) expect(carried).toContain('正文内容') expect(carried).not.toContain('g3-rich-text__toolbar') // 打印后清理掉 iframe,不能在 DOM 里越积越多 await expect(page.locator('iframe[srcdoc]')).toHaveCount(0) }) }) test.describe('RichText · 源代码', () => { test('编辑 HTML 后点保存写回编辑器', async ({ page }) => { await page.goto('/') await typeText(page, '原内容') await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') await expect(dialog).toBeVisible() // 打开时预填当前 HTML const textarea = dialog.locator('textarea') await expect(textarea).toHaveValue(/原内容/) await textarea.fill('

新标题

新正文

') await dialog.getByRole('button', { name: '保存' }).click() // 对话框关闭 + 正文已更新 await expect(dialog).toHaveCount(0) await expect(page.locator(`${EDITOR} .tiptap h2`)).toHaveText('新标题') await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveText('新正文') }) test('「取消」关闭对话框且不写入文档', async ({ page }) => { await page.goto('/') await typeText(page, '保持不变') await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') await dialog.locator('textarea').fill('

不该出现

') await dialog.getByRole('button', { name: '取消' }).click() await expect(dialog).toHaveCount(0) await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('保持不变') await expect(page.locator(`${EDITOR} .tiptap`)).not.toContainText('不该出现') }) test('「重置」把草稿恢复成打开时的内容', async ({ page }) => { await page.goto('/') await typeText(page, '初始内容') await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') const textarea = dialog.locator('textarea') await textarea.fill('

乱改的

') await dialog.getByRole('button', { name: '重置' }).click() await expect(textarea).toHaveValue(/初始内容/) }) test('保存后的内容可以一次撤销退回', async ({ page }) => { await page.goto('/') await typeText(page, '原始文字') await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') await dialog.locator('textarea').fill('

替换后的文字

') await dialog.getByRole('button', { name: '保存' }).click() await expect(dialog).toHaveCount(0) await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('替换后的文字') await page.locator(`${EDITOR} .tiptap`).click() await page.keyboard.press('Control+z') await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('原始文字') }) test('不认识的标签会被 schema 过滤掉(界面如实告知,不假装能完整往返)', async ({ page }) => { await page.goto('/') await openMore(page) await page.getByRole('menuitem', { name: '源代码' }).click() const dialog = page.locator('.g3-modal') await dialog.locator('textarea').fill('

保留

会被丢弃') await dialog.getByRole('button', { name: '保存' }).click() await expect(dialog).toHaveCount(0) await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('保留') await expect(page.locator(`${EDITOR} .tiptap marquee`)).toHaveCount(0) }) })