This commit is contained in:
oneao committed 2026-10-12 17:32:50 +08:00
1 parent 78eb811ff7
commit 97fe2ff306
83 files changed
+10083 -3662

No files matched your search

@@ -0,0 +1,441 @@
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<void> {
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<void> {
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('<p>第一段</p><p></p><p></p><p>第二段</p>')
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('<p>甲</p><p></p><p></p><p></p><p>乙</p>')
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('<h2>新标题</h2><p>新正文</p>')
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('<p>不该出现</p>')
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('<p>乱改的</p>')
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('<p>替换后的文字</p>')
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('<p>保留</p><marquee>会被丢弃</marquee>')
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)
})
})