Files
workspace/code/g3soft-libs/packages/ui/e2e/rich-text-tools.spec.ts
T
2026-10-12 17:32:50 +08:00

442 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})