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

@@ -1,56 +0,0 @@
import { expect, test } from '@playwright/test'
/**
* Attachment 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
*
* 回归背景(都踩过):
* 1. 工具栏勾选后浮出批量按钮,一度把整条工具栏撑高、下方内容跟着下移;
* 2. 分隔线依赖 Splitter 的 barOnHover,默认只在 hover 时显示,
* 表现为「分类栏右侧看不见边界」。
*/
test.describe('Attachment 附件管理器', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.locator('[data-testid="attachment"]').scrollIntoViewIfNeeded()
})
test('工具栏高度恒定:勾选前后、宽窄面板下都是 40px', async ({ page }) => {
// 40 = --g3-control-height-sm(24) + --g3-space-2(8) * 2
const EXPECTED_HEIGHT = 40
for (const testId of ['attachment', 'attachment-narrow']) {
const toolbar = page.locator(`[data-testid="${testId}"] .g3-attachment__toolbar`)
const before = await toolbar.boundingBox()
await toolbar.locator('.g3-checkbox').first().click()
await expect(toolbar.locator('.g3-attachment__toolbar-count')).toBeVisible()
const after = await toolbar.boundingBox()
expect(before, `${testId}: 工具栏应可测量`).not.toBeNull()
expect(after, `${testId}: 工具栏应可测量`).not.toBeNull()
expect(before!.height, `${testId}: 未勾选时工具栏高度`).toBe(EXPECTED_HEIGHT)
// 勾选后浮出「已选 N 项 + 下载 + 删除」,高度必须纹丝不动
expect(after!.height, `${testId}: 勾选后工具栏高度`).toBe(EXPECTED_HEIGHT)
// 复位,避免影响下一个实例的断言
await toolbar.locator('.g3-checkbox').first().click()
await expect(toolbar.locator('.g3-attachment__toolbar-count')).toBeHidden()
}
})
test('分类栏右侧的分割线常显(不依赖 hover)', async ({ page }) => {
const line = page
.locator('[data-testid="attachment"] .g3-attachment__splitter .g3-splitter__bar-line')
.first()
await expect(line).toBeVisible()
const background = await line.evaluate((el) => getComputedStyle(el).backgroundColor)
// 默认是 --g3-border-color(#d9d9d9);只要不是透明的,就说明线是画出来的
expect(background).not.toBe('rgba(0, 0, 0, 0)')
const box = await line.boundingBox()
expect(box!.width).toBeLessThanOrEqual(2)
expect(box!.height).toBeGreaterThan(100)
})
})
@@ -14,13 +14,20 @@ import { expect, test, type Page } from '@playwright/test'
* 这些都是布局结果,只能放 E2E(happy-dom 没有布局引擎)。
*/
/** 插入表格(cols 为列数,默认 3 行 3 列) */
async function insertTable(page: Page, cols?: number): Promise<void> {
/**
* 插入表格。
*
* 面板已从「行数 / 列数」两个数字输入框换成**网格选择器**(对齐 TinyMCE):
* 移入 (rows, cols) 那个格子即高亮对应矩形,点击插入。所以这里改成点格子,
* 不再有「插入」按钮需要按。
*/
async function insertTable(page: Page, cols = 3, rows = 3): Promise<void> {
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('button[aria-label="插入表格"]').click()
if (cols) await page.locator('.g3-rich-text__table-input').nth(1).fill(String(cols))
await page.getByRole('button', { name: '插入', exact: true }).click()
const cell = page.locator(`.g3-rich-text__table-grid-cell[data-row="${rows}"][data-col="${cols}"]`)
await cell.hover()
await cell.click()
await expect(editor.locator('table')).toHaveCount(1)
await editor.locator('table').scrollIntoViewIfNeeded()
}
@@ -145,41 +152,64 @@ test.describe('RichText 表格', () => {
}
})
test('插入表格面板:label 与输入框上下排列且输入框不溢出', async ({ page }) => {
test('插入表格面板:网格选择器高亮矩形并实时显示尺寸', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('button[aria-label="插入表格"]').click()
const fields = page.locator('.g3-rich-text__table-field')
// 行数 / 列数 / 包含表头行 三个字段
await expect(fields).toHaveCount(3)
// 8 行 × 10 列的网格
const cells = page.locator('.g3-rich-text__table-grid-cell')
await expect(cells).toHaveCount(80)
// 只对含数字输入框的两个字段做上下排列 + 不溢出校验
const geometry = await fields.evaluateAll((els) =>
els.flatMap((el) => {
const label = el.querySelector('.g3-rich-text__table-label')!.getBoundingClientRect()
const input = el.querySelector('.g3-rich-text__table-input')
if (!input) return []
const rect = input.getBoundingClientRect()
return [
{
labelBottom: label.bottom,
inputTop: rect.top,
inputWidth: rect.width,
fieldWidth: el.getBoundingClientRect().width,
},
]
}),
// 尺寸提示:未悬停时显示默认 3 × 3
const label = page.locator('.g3-rich-text__table-grid-label')
await expect(label).toHaveText('3 × 3')
// 悬停 4 行 6 列 → 高亮 24 格,提示同步
await page.locator('.g3-rich-text__table-grid-cell[data-row="4"][data-col="6"]').hover()
await expect(label).toHaveText('4 × 6')
await expect(page.locator('.g3-rich-text__table-grid-cell.is-table-grid-cell-active')).toHaveCount(
24,
)
expect(geometry).toHaveLength(2)
for (const item of geometry) {
// 输入框在 label 下方
expect(item.inputTop).toBeGreaterThanOrEqual(item.labelBottom)
// content-box + width:100% 曾让输入框比容器宽 18px(padding + border)
expect(Math.round(item.inputWidth)).toBeLessThanOrEqual(Math.round(item.fieldWidth) + 1)
}
// 悬停更小的区域 → 高亮随之收缩(不是只增不减)
await page.locator('.g3-rich-text__table-grid-cell[data-row="2"][data-col="2"]').hover()
await expect(label).toHaveText('2 × 2')
await expect(page.locator('.g3-rich-text__table-grid-cell.is-table-grid-cell-active')).toHaveCount(
4,
)
})
test('插入表格面板:不溢出容器,含表头行勾选可用', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('button[aria-label="插入表格"]').click()
const form = page.locator('.g3-rich-text__table-form')
const grid = page.locator('.g3-rich-text__table-grid')
const formBox = (await form.boundingBox())!
const gridBox = (await grid.boundingBox())!
// 网格必须落在面板内(早期手写样式曾让内容比容器宽 18px)
expect(gridBox.width).toBeLessThanOrEqual(formBox.width + 1)
expect(gridBox.x).toBeGreaterThanOrEqual(formBox.x - 1)
// 「包含表头行」默认选中;取消后插入的表首行应为 td 而非 th。
// 注意:点击**包裹用的 label**,不能对内部 input 调 uncheck() ——
// G3Checkbox 的原生 input 是 1px + opacity:0 + pointer-events:none(视觉由 span 承担),
// Playwright 认为它不可见 / 不可交互,uncheck() 会直接超时。
const toggleInput = page.locator('.g3-rich-text__table-checkbox input')
const toggleLabel = page.locator('.g3-rich-text__table-header-toggle')
await expect(toggleInput).toBeChecked()
await toggleLabel.click()
await expect(toggleInput).not.toBeChecked()
await page.locator('.g3-rich-text__table-grid-cell[data-row="2"][data-col="2"]').click()
await expect(editor.locator('table tr')).toHaveCount(2)
await expect(editor.locator('table')).toHaveCount(1)
await expect(editor.locator('table tr:first-child th')).toHaveCount(0)
})
})
@@ -371,7 +401,10 @@ test.describe('RichText 表格 · 操作浮层', () => {
// 操作栏里已不再出现单元格对齐按钮(对齐统一走顶部工具条)
await expect(menu.getByRole('button', { name: '单元格居中' })).toHaveCount(0)
await expect(menu.getByRole('button', { name: '取消单元格对齐' })).toHaveCount(0)
// 「表头」整组已移除:切换表头列 / 切换表头单元格都不再提供
await expect(menu.getByRole('button', { name: '切换表头行' })).toHaveCount(0)
await expect(menu.getByRole('button', { name: '切换表头列' })).toHaveCount(0)
await expect(menu.getByRole('button', { name: '切换表头单元格' })).toHaveCount(0)
// 删除行 / 删除列的图标是自绘 SVG(不再是拆分行列的 lucide 图标)
for (const name of ['删除行', '删除列']) {
@@ -387,23 +420,6 @@ test.describe('RichText 表格 · 操作浮层', () => {
await expect(editor.locator('table tr:first-child th')).toHaveCount(2)
})
test('切换表头列 / 表头单元格', async ({ page }) => { await page.goto('/')
await insertTable(page)
const editor = page.getByTestId('rich-text')
const menu = page.locator('.g3-rich-text-table-menu')
// 表头单元格:把普通单元格切换为 th
await openMenuInCell(page, 1)
await menu.getByRole('button', { name: '切换表头单元格' }).click()
await expect(editor.locator('table tr').nth(1).locator('th')).toHaveCount(1)
// 表头列:整列切换为 th(第 2、3 行的首格都变 th;首行本就是表头行不受影响)
await menu.getByRole('button', { name: '切换表头列' }).click()
await expect(editor.locator('table tr').nth(1).locator('th')).toHaveCount(1)
await expect(editor.locator('table tr').nth(2).locator('th')).toHaveCount(1)
await expect(editor.locator('table tr').nth(1).locator('td')).toHaveCount(2)
})
test('单元格内文字的对齐走顶部工具条', async ({ page }) => {
await page.goto('/')
await insertTable(page)
@@ -418,28 +434,29 @@ test.describe('RichText 表格 · 操作浮层', () => {
await expect(cell.locator('p')).toHaveAttribute('style', /text-align:\s*center/)
})
test('插入面板行列数超界被夹回合法范围,且可关闭表头行', async ({ page }) => {
/**
* 「超界被夹回」这条用例已随数字输入框一起移除:网格选择器的可选范围由网格本身
* 硬性限定(8 行 × 10 列),用户在结构上就填不出 0 行或 99 列 —— 也就没有「夹回」可言。
* 这里改为断言这个上限本身,防止有人无意中把网格尺寸改大或改小。
*/
test('网格选择器的上限为 8 行 × 10 列,且只能选到边界内的格子', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('button[aria-label="插入表格"]').click()
// 输入非法值(0 行 / 99 列)
await page.locator('.g3-rich-text__table-input').nth(0).fill('0')
await page.locator('.g3-rich-text__table-input').nth(1).fill('99')
await page.getByRole('button', { name: '插入', exact: true }).click()
const cells = page.locator('.g3-rich-text__table-grid-cell')
await expect(cells).toHaveCount(80)
// 边界格子存在,越界的不存在
await expect(page.locator('.g3-rich-text__table-grid-cell[data-row="8"][data-col="10"]')).toHaveCount(1)
await expect(page.locator('.g3-rich-text__table-grid-cell[data-row="9"]')).toHaveCount(0)
await expect(page.locator('.g3-rich-text__table-grid-cell[data-col="11"]')).toHaveCount(0)
// 选边界值:8 行 10 列能正常插入
await page.locator('.g3-rich-text__table-grid-cell[data-row="8"][data-col="10"]').click()
const table = editor.locator('table')
await expect(table).toHaveCount(1)
// 0 → 1 行,99 → 20 列
await expect(table.locator('tr')).toHaveCount(1)
await expect(table.locator('tr:first-child th')).toHaveCount(20)
// 关闭表头行:插入后首行是 td 而非 th
await editor.locator('button[aria-label="插入表格"]').click()
await page.locator('.g3-rich-text__table-checkbox').uncheck()
await page.getByRole('button', { name: '插入', exact: true }).click()
await expect(editor.locator('table')).toHaveCount(2)
await expect(editor.locator('table').nth(1).locator('tr:first-child th')).toHaveCount(0)
await expect(table.locator('tr')).toHaveCount(8)
await expect(table.locator('tr:first-child th')).toHaveCount(10)
})
})
@@ -177,13 +177,21 @@ test.describe('RichText 工具条 · 更多菜单', () => {
})
})
/** 工具条当前占几行(同一行的元素共享垂直中心) */
/**
* 工具条当前占几行(同一行的元素共享垂直中心)。
*
* ⚠️ 不能遍历 `toolbar.children` —— 分组容器用了 `display: contents`,
* 它们的盒子不参与布局、`getBoundingClientRect()` 恒为 0,按 children 数会永远得到 1 行
* (哪怕实际折成了三行)。必须量**真正参与布局的按钮**。
*/
function toolbarRows(page: Page): Promise<number> {
return page.evaluate(() => {
const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement
const centers = new Set<number>()
for (const el of Array.from(toolbar.children) as HTMLElement[]) {
const buttons = toolbar.querySelectorAll('button')
for (const el of Array.from(buttons) as HTMLElement[]) {
const rect = el.getBoundingClientRect()
if (rect.height === 0) continue
centers.add(Math.round(rect.top + rect.height / 2))
}
return centers.size
@@ -191,23 +199,64 @@ function toolbarRows(page: Page): Promise<number> {
}
test.describe('RichText 工具条 · 外观', () => {
test('窄到 780px 仍保持一行(低频收进「更多」的预算)', async ({ page }) => {
test('宽容器下保持一行(低频收进「更多」的预算)', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('.tiptap').click()
await page.evaluate(() => {
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '780px'
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '1000px'
})
await expect
.poll(() => toolbarRows(page), {
message: '工具条自然宽度约 744px(32px 按钮 + 左右 10px 内边距);新增按钮前先想清楚该不该挪进「更多」',
message:
'工具条在 1000px 宽下应当是一行;窄了就折行(折行以按钮为单位,见 .g3-rich-text__group 的 display: contents)',
})
.toBe(1)
})
test('窄容器下折行时按按钮换行,不整组跳行', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('.tiptap').click()
// 620px:一行放不下所有按钮,必然折行
await page.evaluate(() => {
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '620px'
})
const info = await page.evaluate(() => {
const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement
const barRect = toolbar.getBoundingClientRect()
const rows = new Map<number, { right: number; count: number }>()
for (const el of Array.from(toolbar.querySelectorAll('button')) as HTMLElement[]) {
const r = el.getBoundingClientRect()
if (r.height === 0) continue
const top = Math.round(r.top)
const entry = rows.get(top) ?? { right: 0, count: 0 }
entry.right = Math.max(entry.right, Math.round(r.right))
entry.count += 1
rows.set(top, entry)
}
const sorted = [...rows.entries()].sort((a, b) => a[0] - b[0])
return {
rowCount: sorted.length,
// 每行最右侧按钮距容器内容区右边缘还剩多少(越小说明填得越满)
slack: sorted.map(([, v]) => Math.round(barRect.right - 10 - v.right)),
firstRowCount: sorted[0]?.[1].count ?? 0,
}
})
// 折行了,但不是「整组跳行」导致第一行只放几个
expect(info.rowCount).toBeGreaterThan(1)
expect(info.firstRowCount).toBeGreaterThan(8)
// 第一行应当填得比较满:剩余空隙小于一个按钮宽(不出现「右边空一大片」)
expect(info.slack[0]).toBeLessThan(36)
})
test('工具条带下投影,且抬在正文之上(滚动时投影不被正文盖住)', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
@@ -231,3 +280,428 @@ test.describe('RichText 工具条 · 外观', () => {
expect(Number(style.zIndex)).toBeGreaterThan(0)
})
})
test.describe('RichText 工具条 · 键盘操作', () => {
/** 当前获得焦点的元素(用 aria-label 标识,便于断言「焦点在第几个按钮上」) */
function focusedLabel(page: Page): Promise<string | null> {
return page.evaluate(() => {
const el = document.activeElement as HTMLElement | null
if (!el || el.tagName !== 'BUTTON') return null
return el.getAttribute('aria-label')
})
}
/** 工具条里 tabindex=0 的按钮 aria-label 列表(roving tabindex 应当只有一个) */
function tabbableInToolbar(page: Page): Promise<(string | null)[]> {
return page.evaluate(() =>
Array.from(
document.querySelectorAll('.g3-rich-text__toolbar button:not(:disabled)'),
)
.filter((el) => (el as HTMLElement).tabIndex === 0)
.map((el) => el.getAttribute('aria-label')),
)
}
/**
* 工具条里**可聚焦**按钮的 aria-label,按 DOM 顺序。
*
* 必须排除 disabled:初始状态下「撤销」「重做」是禁用的(历史为空),
* 它们不可聚焦、也不该出现在方向键序列里。断言用真实 DOM 顺序而不是写死
* 按钮名,避免以后调整工具条顺序时测试变成假失败。
*/
function focusableLabels(page: Page): Promise<(string | null)[]> {
return page.evaluate(() =>
Array.from(
document.querySelectorAll('.g3-rich-text__toolbar button:not(:disabled)'),
).map((el) => el.getAttribute('aria-label')),
)
}
/**
* 段落格式触发器(「正文 / 标题 N」)。
*
* 用 class 定位而不是 role+name:它的可访问名是**当前块格式**、随光标变化
* (playground 初始内容含 `<h2>`,名字就是「标题 2」),拿它当固定名字必然不稳。
*/
function headingTrigger(page: Page) {
return page.locator(`${EDITOR} .g3-rich-text__toolbar .g3-rich-text__trigger`)
}
test('工具条是 role=toolbar 的单个 Tab 停靠点', async ({ page }) => {
await page.goto('/')
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
await expect(toolbar).toHaveAttribute('role', 'toolbar')
await expect(toolbar).toHaveAttribute('aria-orientation', 'horizontal')
// 无论有多少按钮,tabindex=0 的只有一个 —— 否则每个按钮都占一个 Tab 位
const tabbable = await tabbableInToolbar(page)
expect(tabbable).toHaveLength(1)
const buttonCount = await page
.locator(`${EDITOR} .g3-rich-text__toolbar button:not(:disabled)`)
.count()
// 14 个图标按钮 + 1 个段落格式触发器(撤销 / 重做初始禁用,不计入)
expect(buttonCount).toBeGreaterThanOrEqual(15)
})
test('方向键在按钮间漫游,Home / End 跳首尾', async ({ page }) => {
await page.goto('/')
const labels = await focusableLabels(page)
// 初始态「撤销 / 重做」禁用,序列从段落格式触发器开始,其后是「加粗」
expect(labels[0]).toBe('段落格式')
expect(labels[1]).toBe('加粗')
await headingTrigger(page).focus()
await expect.poll(() => focusedLabel(page)).toBe('段落格式')
// 右键 → 下一个
await page.keyboard.press('ArrowRight')
await expect.poll(() => focusedLabel(page)).toBe('加粗')
// 左键回退
await page.keyboard.press('ArrowLeft')
await expect.poll(() => focusedLabel(page)).toBe('段落格式')
// 首个再往左 → 两端循环到末位(「更多」)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => focusedLabel(page)).toBe(labels[labels.length - 1])
// End 跳末位 / Home 跳首位
await page.keyboard.press('End')
await expect.poll(() => focusedLabel(page)).toBe(labels[labels.length - 1])
await page.keyboard.press('Home')
await expect.poll(() => focusedLabel(page)).toBe(labels[0])
})
test('禁用按钮(撤销 / 重做)不会被方向键走到', async ({ page }) => {
await page.goto('/')
// 初始历史为空 → 撤销 / 重做禁用
const undo = page.locator(`${EDITOR} .g3-rich-text__toolbar button[aria-label="撤销"]`)
await expect(undo).toBeDisabled()
const labels = await focusableLabels(page)
expect(labels).not.toContain('撤销')
expect(labels).not.toContain('重做')
})
test('方向键漫游时不会把光标带进正文(preventDefault 生效)', async ({ page }) => {
await page.goto('/')
// 先制造可撤销的历史,让「撤销」进入可聚焦序列(更接近真实使用)
const editor = page.getByTestId('rich-text')
await editor.locator('.tiptap').click()
await page.keyboard.type('键盘测试')
const undo = editor.locator('.g3-rich-text__toolbar button[aria-label="撤销"]')
await expect(undo).toBeEnabled()
await undo.focus()
await page.keyboard.press('End')
await page.keyboard.press('Home')
// Home / End 若没被 preventDefault,浏览器会把焦点 / 光标带出工具条
await expect.poll(() => focusedLabel(page)).toBe('撤销')
await expect(editor.locator('.tiptap')).not.toHaveClass(/ProseMirror-focused/)
})
test('焦点移动后 tabindex 跟着走(roving 归一化)', async ({ page }) => {
await page.goto('/')
await headingTrigger(page).focus()
await page.keyboard.press('ArrowRight')
expect(await focusedLabel(page)).toBe('加粗')
// 任意时刻恰有一个 tabindex=0,且它就是当前焦点所在按钮
expect(await tabbableInToolbar(page)).toEqual(['加粗'])
})
})
test.describe('RichText 工具条 · 链接', () => {
/** 地址输入框(面板里两个 input,靠 aria-label 区分) */
function urlInput(page: Page) {
return page.getByLabel('链接地址')
}
/** 显示文字输入框 */
function textInput(page: Page) {
return page.getByLabel('显示文字')
}
/** 在正文里输入文本并全选,然后打开链接面板 */
async function openLinkPanel(page: Page, text = '点我'): 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)
await page.keyboard.press('Control+A')
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '链接' }).click()
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
}
test('输入地址后回车写入选区,且不弹原生 dialog', async ({ page }) => {
// 回归:旧实现用 window.prompt,这里断言「没有任何原生对话框被触发」
let dialogSeen = false
page.on('dialog', async (dialog) => {
dialogSeen = true
await dialog.dismiss()
})
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('https://example.com/a')
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveAttribute('href', 'https://example.com/a')
expect(dialogSeen, '不应再触发 window.prompt 原生弹窗').toBe(false)
// 应用后面板收起
await expect(page.locator('.g3-rich-text__link-form')).toHaveCount(0)
})
test('面板有「链接地址」与「显示文字」两个字段,且无 placeholder', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
await expect(urlInput(page)).toBeVisible()
await expect(textInput(page)).toBeVisible()
// 刻意不留 placeholder:字段名已说明填什么,示例文案属于噪音
await expect(urlInput(page)).not.toHaveAttribute('placeholder', /.*/)
await expect(textInput(page)).not.toHaveAttribute('placeholder', /.*/)
// 有选区时「显示文字」预填选中的文字
await expect(textInput(page)).toHaveValue('点我')
})
test('显示文字:替换选中文字', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
await urlInput(page).fill('https://example.com/a')
await textInput(page).fill('查看详情')
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveText('查看详情')
await expect(link).toHaveAttribute('href', 'https://example.com/a')
})
test('「取消」关闭面板且不写入文档', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
await urlInput(page).fill('https://example.com/never')
await page.locator('.g3-rich-text__link-form').getByRole('button', { name: '取消' }).click()
// 面板收起、正文没有产生链接
await expect(page.locator('.g3-rich-text__link-form')).toHaveCount(0)
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(0)
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('点我')
})
test('「取消 / 确定」靠右成组,主操作位置稳定', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
const form = page.locator('.g3-rich-text__link-form')
const cancel = form.getByRole('button', { name: '取消' })
const confirm = form.getByRole('button', { name: '确定' })
const cancelBox = (await cancel.boundingBox())!
const confirmBox = (await confirm.boundingBox())!
const formBox = (await form.boundingBox())!
// 确定在取消右侧
expect(confirmBox.x).toBeGreaterThan(cancelBox.x)
// 两者底边对齐(同一行)
expect(Math.abs(cancelBox.y - confirmBox.y)).toBeLessThan(2)
// 确定右边缘贴近面板右侧内边距(靠右而不是居中/通栏)
const formRight = formBox.x + formBox.width
expect(formRight - (confirmBox.x + confirmBox.width)).toBeLessThan(20)
})
test('操作按钮用默认尺寸而非 small(高度对齐 G3Input)', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
const form = page.locator('.g3-rich-text__link-form')
const confirm = (await form.getByRole('button', { name: '确定' }).boundingBox())!
// 量 G3Input 的**根包裹层**(32px 的那个盒子),不是它内部的裸 <input>(21px 行盒)——
// getByLabel 解析到的是内部 input,直接量它会得出「按钮比输入框高」的错误结论
const inputBox = (await form.locator('.g3-input').first().boundingBox())!
// 默认尺寸 = --g3-control-height = 32px;small 是 24px
expect(confirm.height).toBeGreaterThanOrEqual(30)
// 与输入框等高,同一行里视觉齐平
expect(Math.abs(confirm.height - inputBox.height)).toBeLessThan(3)
})
test('编辑态:移除链接在左,取消 / 确定在右', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
await urlInput(page).fill('https://example.com/x')
await page.keyboard.press('Enter')
await page.locator(`${EDITOR} .tiptap a`).click()
await page
.locator(`${EDITOR} .g3-rich-text__toolbar`)
.getByRole('button', { name: '链接' })
.click()
const form = page.locator('.g3-rich-text__link-form')
const remove = (await form.getByRole('button', { name: '移除链接' }).boundingBox())!
const cancel = (await form.getByRole('button', { name: '取消' }).boundingBox())!
// 移除链接在最左,取消在它右侧(危险操作与主操作分开)
expect(remove.x).toBeLessThan(cancel.x)
})
test('显示文字:空选区时插入为链接文字', async ({ page }) => {
await page.goto('/')
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 editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '链接' }).click()
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
await urlInput(page).fill('https://example.com/b')
// 「显示文字」不是必填(只填地址即可提交);这里我们主动填上文字
await textInput(page).fill('官网')
await page.keyboard.press('Enter')
// 落下的文字本身带链接,href 正确、文字正确
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveText('官网')
await expect(link).toHaveAttribute('href', 'https://example.com/b')
})
test('显示文字留空时沿用原选区文字', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '保留我')
await urlInput(page).fill('https://example.com/c')
await textInput(page).fill('')
// 有原选区文字 → 仍可提交
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveText('保留我')
await expect(link).toHaveAttribute('href', 'https://example.com/c')
})
test('不带协议时自动补 https://', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('example.com/docs')
await page.locator('.g3-rich-text__link-form').getByRole('button', { name: '确定' }).click()
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveAttribute(
'href',
'https://example.com/docs',
)
})
test('空选区且未填显示文字:确定可用,并把地址本身作为链接文字', async ({ page }) => {
await page.goto('/')
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 editor
.locator('.g3-rich-text__toolbar')
.getByRole('button', { name: '链接' })
.click()
// 「显示文字」不是必填(产品要求)—— 填了地址就应当能提交
await urlInput(page).fill('https://example.com')
await expect(
page.locator('.g3-rich-text__link-form').getByRole('button', { name: '确定' }),
).toBeEnabled()
// 兜底:没有任何文字可挂时,用地址本身当链接文字
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveText('https://example.com')
})
test('只填地址也能提交(显示文字留空)', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '原文')
// 主动清掉预填的显示文字
await textInput(page).fill('')
await urlInput(page).fill('https://example.com/only-url')
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveAttribute('href', 'https://example.com/only-url')
// 有选中文字、显示文字留空 → 沿用原文字
await expect(link).toHaveText('原文')
})
test('光标在已有链接上时,面板可编辑并可移除链接', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('https://example.com/first')
await page.keyboard.press('Enter')
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(1)
// 把光标放回链接文字里,再打开面板 → 地址被预填、可编辑
await page.locator(`${EDITOR} .tiptap a`).click()
await page
.locator(`${EDITOR} .g3-rich-text__toolbar`)
.getByRole('button', { name: '链接' })
.click()
await expect(urlInput(page)).toHaveValue('https://example.com/first')
// 编辑态能看到「移除链接」
await expect(
page.locator('.g3-rich-text__link-form').getByRole('button', { name: '移除链接' }),
).toBeVisible()
// 改地址
await urlInput(page).fill('https://example.com/second')
await page.keyboard.press('Enter')
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveAttribute(
'href',
'https://example.com/second',
)
// 再打开 → 移除链接
await page.locator(`${EDITOR} .tiptap a`).click()
await page
.locator(`${EDITOR} .g3-rich-text__toolbar`)
.getByRole('button', { name: '链接' })
.click()
await page
.locator('.g3-rich-text__link-form')
.getByRole('button', { name: '移除链接' })
.click()
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(0)
})
test('javascript: 之类的可执行协议不会被写入 href', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('javascript:alert(1)')
await page.keyboard.press('Enter')
// normalizeUrl 只放行 http/https/mailto/tel/#// 开头的地址,其余原样保留 → 不写链接
const href = await page
.locator(`${EDITOR} .tiptap a`)
.evaluateAll((els) => els.map((el) => el.getAttribute('href')))
expect(href.every((value) => !value?.toLowerCase().startsWith('javascript:'))).toBe(true)
})
})
@@ -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)
})
})
@@ -0,0 +1,162 @@
import { expect, test } from '@playwright/test'
/**
* G3Table 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
*
* 用例只按 data-testid 找元素(不依赖文案与结构),宿主页在 playground/main.ts。
*/
test.describe('G3Table 表格', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.locator('[data-testid="table"]').scrollIntoViewIfNeeded()
})
test('基础渲染:表头与数据行都在,且序号列存在', async ({ page }) => {
const table = page.locator('[data-testid="table-wrap"]')
await expect(table.locator('.stk-table')).toBeVisible()
// 表头至少有我们的业务列文案
await expect(table.locator('th', { hasText: '名称' })).toBeVisible()
await expect(table.locator('th', { hasText: '金额' })).toBeVisible()
// 数据行渲染出来了
const rows = table.locator('.stk-tbody-main tr')
await expect(rows).toHaveCount(3)
})
test('小数据量(3 行):纵向虚拟滚动关闭,DOM 里是真实 3 行', async ({ page }) => {
const table = page.locator('[data-testid="table-wrap"]')
// 库开启虚拟滚动时会给根加 .virtual 类;门控生效时不应有
await expect(table.locator('.stk-table')).not.toHaveClass(/virtual/)
})
test('大数据量(5000 行):纵向虚拟滚动开启,DOM 行数远少于数据量', async ({ page }) => {
const big = page.locator('[data-testid="table-big-wrap"]')
await big.scrollIntoViewIfNeeded()
await expect(big.locator('.stk-table')).toHaveClass(/virtual/)
// 虚拟滚动的意义:只渲染视口内若干行,DOM 行数应当是个位数/几十,而不是 5000
const rendered = await big.locator('.stk-tbody-main tr').count()
expect(rendered).toBeGreaterThan(0)
expect(rendered).toBeLessThan(200)
})
test('大数据量:滚动后仍只渲染视口内行(虚拟窗口跟随)', async ({ page }) => {
const big = page.locator('[data-testid="table-big-wrap"]')
await big.scrollIntoViewIfNeeded()
const scroller = big.locator('.stk-table-scroll-container, .stk-table-main').first()
await scroller.evaluate((el) => {
el.scrollTop = 3000
})
await page.waitForTimeout(150)
const rendered = await big.locator('.stk-tbody-main tr').count()
expect(rendered).toBeGreaterThan(0)
expect(rendered).toBeLessThan(200)
})
test('高度约束:容器限高时表格不溢出', async ({ page }) => {
const wrap = page.locator('[data-testid="table-wrap"]')
const wrapBox = await wrap.boundingBox()
const inner = wrap.locator('.stk-table').first()
const innerBox = await inner.boundingBox()
expect(wrapBox).not.toBeNull()
expect(innerBox).not.toBeNull()
// 表格不应高于它的容器(留 2px 容差:边框/亚像素)
expect(innerBox!.height).toBeLessThanOrEqual(wrapBox!.height + 2)
})
test('外框圆角:bordered 时容器有圆角与描边', async ({ page }) => {
// 注意:data-testid 透传给了内层 StkTable(组件 inheritAttrs: false),
// 外框与圆角在我们自己的根节点 .g3-table 上,所以要按类名定位。
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
await expect(root).toHaveClass(/is-bordered/)
const radius = await root.evaluate((el) => getComputedStyle(el).borderRadius)
expect(radius).not.toBe('0px')
const borderWidth = await root.evaluate((el) => getComputedStyle(el).borderTopWidth)
expect(borderWidth).toBe('1px')
})
test('表头显示序号列(seq 默认开启)', async ({ page }) => {
const table = page.locator('[data-testid="table-wrap"]')
await expect(table.locator('th', { hasText: '序号' })).toBeVisible()
})
test('窄容器下不横向溢出容器宽度', async ({ page }) => {
const wrap = page.locator('[data-testid="table-narrow-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const wrapBox = await wrap.boundingBox()
const rootBox = await wrap.locator('[data-testid="table-narrow"]').boundingBox()
expect(rootBox!.width).toBeLessThanOrEqual(wrapBox!.width + 2)
})
})
test.describe('G3Table 选择列(真浏览器命中)', () => {
test('点击行复选框:勾选态可见地变化', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const firstRowCheckbox = wrap.locator('.g3-table__checkbox').first()
await firstRowCheckbox.scrollIntoViewIfNeeded()
await expect(firstRowCheckbox).not.toHaveClass(/is-checked/)
await firstRowCheckbox.click()
await expect(firstRowCheckbox).toHaveClass(/is-checked/)
})
test('每行复选框数 = 选择列 + checkbox 编辑器列(演示数据定义了两列)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 演示数据的 tableColumns 里有两个复选框列:选择列(注入)+ 启用列(editor: checkbox)。
// 所以每行 2 个是预期值——这正是为什么不能靠「数复选框个数」判断是否有重复渲染。
const bodyRows = wrap.locator('.stk-tbody-main tr')
await expect(bodyRows).toHaveCount(3)
for (let i = 0; i < 3; i += 1) {
await expect(bodyRows.nth(i).locator('.g3-table__checkbox')).toHaveCount(2)
}
})
test('无重复渲染:每行 td 数等于表头列数', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 单元格数必须与表头列数一致;固定列若被双份渲染,这里会多出来。
const headerCount = await wrap.locator('thead th').count()
const firstRowTd = await wrap.locator('.stk-tbody-main tr').first().locator('td').count()
expect(firstRowTd).toBe(headerCount)
})
test('表头全选:点击后表头与所有数据行的选择列都被勾选', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const headerCheckbox = wrap.locator('th .g3-table__checkbox').first()
await headerCheckbox.scrollIntoViewIfNeeded()
await headerCheckbox.click()
// 表头 choice 项 + 3 行选择列 = 4 个处于勾选态的选择框
// (「启用」列的 checkbox 是数据值,初始为 1 的那两行也会是勾选态,
// 所以这里按选择列的固定类名限定范围)
await expect(wrap.locator('th .g3-table__checkbox.is-checked')).toHaveCount(1)
await expect(wrap.locator('.stk-tbody-main td:first-child .g3-table__checkbox.is-checked')).toHaveCount(3)
})
})
test.describe('G3Table RTL 镜像', () => {
test('RTL 下固定列与表格布局不塌陷', async ({ page }) => {
await page.goto('/?dir=rtl')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const wrapBox = await wrap.boundingBox()
const tableBox = await wrap.locator('.stk-table').first().boundingBox()
expect(tableBox).not.toBeNull()
// 表格仍应占据容器的整宽(镜像不该把宽度算丢)
expect(tableBox!.width).toBeGreaterThan(wrapBox!.width * 0.9)
})
})