u
This commit is contained in:
1 parent
78eb811ff7
commit
97fe2ff306
83 files changed
+10083
-3662
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user