708 lines
29 KiB
TypeScript
708 lines
29 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
|
||
/**
|
||
* 富文本工具条的排版能力回归:对齐(左 / 中 / 右)与颜色(字体色 / 背景色)。
|
||
*
|
||
* 这两组能力都落在 Tiptap 扩展的 HTML 输出上(`text-align` 写块元素、
|
||
* `color` / `background-color` 写 `textStyle` 记号),happy-dom 里没有真实的
|
||
* contenteditable + 选区,只能放 E2E 验证「点按钮 → 选区真的被改了」。
|
||
*
|
||
* 取色面板与表格操作浮层一样 Teleport 到 body,所以色块要用全局定位找。
|
||
*/
|
||
|
||
const EDITOR = '[data-testid="rich-text"]'
|
||
|
||
/** 清空编辑器后输入一段文本并全选(保证选区落在同一段落内) */
|
||
async function typeAndSelectAll(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')
|
||
}
|
||
|
||
/** 光标所在段落的 style 属性(对齐写在这里) */
|
||
function paragraphStyle(page: Page): Promise<string> {
|
||
return page
|
||
.locator(`${EDITOR} .tiptap p`)
|
||
.first()
|
||
.evaluate((el) => el.getAttribute('style') ?? '')
|
||
}
|
||
|
||
/** 段落内所有 span 的实测颜色(颜色写在内联 span 上) */
|
||
function spanColors(page: Page): Promise<{ color: string; background: string }[]> {
|
||
return page.locator(`${EDITOR} .tiptap span`).evaluateAll((els) =>
|
||
els.map((el) => {
|
||
const style = getComputedStyle(el)
|
||
return { color: style.color, background: style.backgroundColor }
|
||
}),
|
||
)
|
||
}
|
||
|
||
test.describe('RichText 工具条 · 对齐', () => {
|
||
test('左 / 中 / 右写进段落,点当前档位即取消', async ({ page }) => {
|
||
await page.goto('/')
|
||
await typeAndSelectAll(page)
|
||
|
||
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||
const center = toolbar.getByRole('button', { name: '居中' })
|
||
|
||
await center.click()
|
||
await expect.poll(() => paragraphStyle(page)).toContain('text-align: center')
|
||
await expect(center).toHaveAttribute('aria-pressed', 'true')
|
||
|
||
// 换成右对齐:仍只有一个对齐属性
|
||
await toolbar.getByRole('button', { name: '右对齐' }).click()
|
||
await expect.poll(() => paragraphStyle(page)).toContain('text-align: right')
|
||
await expect.poll(() => paragraphStyle(page)).not.toContain('center')
|
||
|
||
// 再点当前档位 → 取消对齐(不需要单独的「取消对齐」按钮)
|
||
await toolbar.getByRole('button', { name: '右对齐' }).click()
|
||
await expect.poll(() => paragraphStyle(page)).not.toContain('text-align')
|
||
})
|
||
})
|
||
|
||
test.describe('RichText 工具条 · 颜色', () => {
|
||
test('字体颜色与背景颜色:色板取色写进选区,面板随即收起', async ({ page }) => {
|
||
await page.goto('/')
|
||
await typeAndSelectAll(page)
|
||
|
||
const editor = page.getByTestId('rich-text')
|
||
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||
const panel = page.locator('.g3-rich-text__color-panel')
|
||
|
||
// 取字体颜色:色块按 hex 作为 aria-label(Ant Design 的红色 6 号色)
|
||
await toolbar.getByRole('button', { name: '字体颜色' }).click()
|
||
await expect(panel).toBeVisible()
|
||
await page.locator('.g3-rich-text__color-swatch[aria-label="#f5222d"]').click()
|
||
await expect(panel).toHaveCount(0)
|
||
|
||
await expect
|
||
.poll(async () => (await spanColors(page)).map((item) => item.color))
|
||
.toContain('rgb(245, 34, 45)')
|
||
|
||
// 触发器的当前色条跟着变(未设色时是空位,不写内联 background)
|
||
await expect
|
||
.poll(() =>
|
||
page
|
||
.locator(`${EDITOR} .g3-rich-text__color-bar`)
|
||
.first()
|
||
.evaluate((el) => getComputedStyle(el).backgroundColor),
|
||
)
|
||
.toBe('rgb(245, 34, 45)')
|
||
|
||
// 取背景颜色:浅色档底色
|
||
await toolbar.getByRole('button', { name: '背景颜色' }).click()
|
||
await page.locator('.g3-rich-text__color-swatch[aria-label="#fff1f0"]').click()
|
||
await expect
|
||
.poll(async () => (await spanColors(page)).map((item) => item.background))
|
||
.toContain('rgb(255, 241, 240)')
|
||
|
||
// 两个取色面板不会同时开着
|
||
await expect(panel).toHaveCount(0)
|
||
|
||
// 清除字体颜色:记号里的 color 被移除,背景色保留
|
||
await toolbar.getByRole('button', { name: '字体颜色' }).click()
|
||
await page.getByRole('button', { name: '清除字体颜色' }).click()
|
||
await expect
|
||
.poll(async () => (await spanColors(page)).map((item) => item.color))
|
||
.not.toContain('rgb(245, 34, 45)')
|
||
await expect
|
||
.poll(async () => (await spanColors(page)).map((item) => item.background))
|
||
.toContain('rgb(255, 241, 240)')
|
||
})
|
||
})
|
||
|
||
test.describe('RichText 工具条 · 更多菜单', () => {
|
||
test('低频功能收进「更多」,打开后按组展示且可用', async ({ page }) => {
|
||
await page.goto('/')
|
||
await typeAndSelectAll(page)
|
||
|
||
const editor = page.getByTestId('rich-text')
|
||
const toolbar = editor.locator('.g3-rich-text__toolbar')
|
||
|
||
// 工具条上不再平铺这些低频按钮(平铺会把工具条顶到 928px 宽)
|
||
for (const name of ['删除线', '上标', '下标', '引用', '视频']) {
|
||
await expect(toolbar.getByRole('button', { name })).toHaveCount(0)
|
||
}
|
||
// 字体 / 字号下拉也收进菜单:工具条上只剩「段落格式」一个带当前值的触发器
|
||
await expect(toolbar.locator('.g3-rich-text__trigger')).toHaveCount(1)
|
||
|
||
await toolbar.getByRole('button', { name: '更多' }).click()
|
||
const menu = page.locator('.g3-dropdown__menu')
|
||
await expect(menu).toBeVisible()
|
||
// 菜单内容约 295px,比 Dropdown 默认的 256px 上限高 —— 面板内部不该出现滚动条
|
||
const menuBox = await menu.evaluate((el) => ({
|
||
scroll: el.scrollHeight,
|
||
client: el.clientHeight,
|
||
}))
|
||
expect(menuBox.scroll).toBeLessThanOrEqual(menuBox.client)
|
||
// 分组标题 + 快捷键提示(快捷键对着 Tiptap 的真实键位)
|
||
await expect(menu.getByRole('group')).toHaveText(['格式', '插入'])
|
||
await expect(menu.locator('.g3-dropdown__item-shortcut').first()).toHaveText('Ctrl+Shift+S')
|
||
|
||
// 点「删除线」:作用于选区,菜单随即收起
|
||
await menu.getByRole('menuitem', { name: /删除线/ }).click()
|
||
await expect(editor.locator('.tiptap s')).toHaveCount(1)
|
||
await expect(menu).toHaveCount(0)
|
||
})
|
||
|
||
test('字体 / 字号在「更多」的二级菜单里,选中态跟随光标', async ({ page }) => {
|
||
await page.goto('/')
|
||
await typeAndSelectAll(page)
|
||
|
||
const editor = page.getByTestId('rich-text')
|
||
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '更多' }).click()
|
||
await page.getByRole('menuitem', { name: /^字体/ }).hover()
|
||
|
||
const submenu = page.locator('.g3-dropdown__menu').last()
|
||
await expect(submenu).toBeVisible()
|
||
// 子菜单沿用根面板的 max-height:8 个字体正好压着默认上限,也不该出滚动条
|
||
const subBox = await submenu.evaluate((el) => ({ scroll: el.scrollHeight, client: el.clientHeight }))
|
||
expect(subBox.scroll).toBeLessThanOrEqual(subBox.client)
|
||
await submenu.getByRole('menuitem', { name: '宋体' }).click()
|
||
|
||
const span = editor.locator('.tiptap span[style*="font-family"]')
|
||
await expect(span).toHaveCount(1)
|
||
await expect(span).toHaveAttribute('style', /SimSun/)
|
||
|
||
// 再打开时当前字体带选中态
|
||
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '更多' }).click()
|
||
await page.getByRole('menuitem', { name: /^字体/ }).hover()
|
||
await expect(
|
||
page.locator('.g3-dropdown__menu').last().getByRole('menuitem', { name: '宋体' }),
|
||
).toHaveAttribute('aria-current', 'page')
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 工具条当前占几行(同一行的元素共享垂直中心)。
|
||
*
|
||
* ⚠️ 不能遍历 `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>()
|
||
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
|
||
})
|
||
}
|
||
|
||
test.describe('RichText 工具条 · 外观', () => {
|
||
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 = '1000px'
|
||
})
|
||
|
||
await expect
|
||
.poll(() => toolbarRows(page), {
|
||
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')
|
||
const toolbar = editor.locator('.g3-rich-text__toolbar')
|
||
await expect(toolbar).toBeVisible()
|
||
|
||
const style = await toolbar.evaluate((el) => {
|
||
const computed = getComputedStyle(el)
|
||
return {
|
||
shadow: computed.boxShadow,
|
||
position: computed.position,
|
||
zIndex: computed.zIndex,
|
||
}
|
||
})
|
||
|
||
// 双层柔和投影(观感对齐 TinyMCE):近层 2px 硬边 + 远层 8px 弥散,y 偏移为正 = 朝下
|
||
expect(style.shadow).toMatch(/0px 2px 2px -2px/)
|
||
expect(style.shadow).toMatch(/0px 8px 8px -4px/)
|
||
// 抬层:positioned + z-index > 0,否则滚动到工具条下沿的正文会盖住投影
|
||
expect(style.position).toBe('relative')
|
||
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)
|
||
})
|
||
})
|
||
|