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

708 lines
29 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'
/**
* 富文本工具条的排版能力回归:对齐(左 / 中 / 右)与颜色(字体色 / 背景色)。
*
* 这两组能力都落在 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)
})
})