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 { 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 { 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 { return page.evaluate(() => { const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement const centers = new Set() 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() 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 { 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 初始内容含 `

`,名字就是「标题 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 { 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 的那个盒子),不是它内部的裸 (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) }) })