This commit is contained in:
oneao committed 2026-10-08 22:38:34 +08:00
1 parent f572dce2f6
commit e99a9fb274
356 files changed
+28877 -1055

No files matched your search

@@ -0,0 +1,171 @@
import { expect, test } from '@playwright/test'
/**
* 数据展示类组件的交互回归。
*
* 注意:用 page.mouse.* 之前必须先 scrollIntoViewIfNeeded ——
* mouse 事件按视口坐标派发,元素在视口外时事件会落到别的元素上
* (表现为「拖拽/点击完全没反应」,很容易被误判成组件 bug)。
*/
test.describe('Tabs', () => {
test('面板懒渲染,切换后保留旧面板(表单状态不丢)', async ({ page }) => {
await page.goto('/')
const tabs = page.getByTestId('tabs')
await expect(tabs.locator('.g3-tabs__pane')).toHaveCount(1)
await tabs.locator('.g3-tabs__tab').nth(1).click()
await expect(tabs.locator('.g3-tabs__pane')).toHaveCount(2)
await expect(tabs.locator('.g3-tabs__pane').first()).toBeHidden()
})
test('禁用标签不可点击(回归:空值布尔属性被 Boolean("") 判成 false)', async ({ page }) => {
await page.goto('/')
const tabs = page.getByTestId('tabs')
const disabled = tabs.locator('.g3-tabs__tab').nth(2)
await expect(disabled).toBeDisabled()
await expect(disabled).toHaveAttribute('aria-disabled', 'true')
await expect(tabs.locator('.g3-tabs__pane')).toHaveCount(1)
})
})
test.describe('Tree', () => {
test('勾选父节点后子节点全选(父子联动)', async ({ page }) => {
await page.goto('/')
const tree = page.getByTestId('tree')
const checkboxes = tree.locator('.g3-tree-node__checkbox .g3-checkbox')
await expect(checkboxes).toHaveCount(6)
await checkboxes.first().click()
const checkedCount = await tree.locator('.g3-tree-node__checkbox .g3-checkbox.is-checked').count()
// 「前端」及其 3 个子孙(Vue / 组合式 API / React)
expect(checkedCount).toBe(4)
})
test('收起父节点后其子孙不再渲染(后端仍展开,所以剩下 3 个)', async ({ page }) => {
await page.goto('/')
const tree = page.getByTestId('tree')
await expect(tree.locator('.g3-tree-node')).toHaveCount(6)
await tree.locator('.g3-tree-node__switcher').first().click()
await expect(tree.locator('.g3-tree-node')).toHaveCount(3)
})
})
test.describe('Pagination', () => {
test('省略号点击跳 5 页', async ({ page }) => {
await page.goto('/')
const pagination = page.getByTestId('pagination')
await expect(pagination.locator('[aria-current="page"]')).toHaveText('1')
await pagination.locator('.g3-pagination__ellipsis').click()
await expect(pagination.locator('[aria-current="page"]')).toHaveText('6')
})
test('显示总数与跳页控件', async ({ page }) => {
await page.goto('/')
const pagination = page.getByTestId('pagination')
await expect(pagination.locator('.g3-pagination__total')).toContainText('126')
await expect(pagination.locator('.g3-pagination__jumper')).toBeVisible()
})
})
test.describe('Splitter', () => {
test('拖拽分隔条同时改变分隔条位置与两侧尺寸', async ({ page }) => {
await page.goto('/')
const splitter = page.getByTestId('splitter')
await splitter.scrollIntoViewIfNeeded()
const panels = splitter.locator('.g3-splitter-panel')
const bar = splitter.locator('.g3-splitter__bar')
// 探针:区分「pointerdown 没落到分隔条上」与「落上了但逻辑没生效」
await page.evaluate(() => {
;(window as unknown as { __barDown: number }).__barDown = 0
document.querySelector('.g3-splitter__bar')?.addEventListener('pointerdown', () => {
;(window as unknown as { __barDown: number }).__barDown += 1
})
})
const before = (await panels.first().boundingBox())!.width
const box = (await bar.boundingBox())!
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await page.mouse.move(centerX, centerY)
await page.mouse.down()
await page.mouse.move(centerX + 40, centerY, { steps: 3 })
await page.mouse.move(centerX + 80, centerY, { steps: 3 })
await page.mouse.up()
await page.waitForTimeout(150)
const barDown = await page.evaluate(() => (window as unknown as { __barDown: number }).__barDown)
expect(barDown).toBe(1)
expect((await panels.first().boundingBox())!.width).toBeGreaterThan(before + 20)
})
test('分隔条可键盘调整(方向键 ±10px)', async ({ page }) => {
await page.goto('/')
const splitter = page.getByTestId('splitter')
await splitter.scrollIntoViewIfNeeded()
const panels = splitter.locator('.g3-splitter-panel')
const before = (await panels.first().boundingBox())!.width
await splitter.locator('.g3-splitter__bar').focus()
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
expect((await panels.first().boundingBox())!.width).toBeGreaterThan(before + 10)
})
})
test.describe('Scrollbar', () => {
test('点击轨道跳转滚动(clickScroll 默认开启)', async ({ page }) => {
await page.goto('/')
const scrollbar = page.getByTestId('scrollbar')
await scrollbar.scrollIntoViewIfNeeded()
const view = scrollbar.locator('.g3-scrollbar__view')
expect(await view.evaluate((el) => el.scrollTop)).toBe(0)
const track = (await scrollbar.locator('.g3-scrollbar__track-v').boundingBox())!
await page.mouse.click(track.x + track.width / 2, track.y + track.height * 0.7)
await page.waitForTimeout(100)
expect(await view.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
})
test('拖动滑块同步滚动位置', async ({ page }) => {
await page.goto('/')
const scrollbar = page.getByTestId('scrollbar')
await scrollbar.scrollIntoViewIfNeeded()
const view = scrollbar.locator('.g3-scrollbar__view')
const box = (await scrollbar.locator('.g3-scrollbar__thumb-v').boundingBox())!
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await page.mouse.move(centerX, centerY)
await page.mouse.down()
await page.mouse.move(centerX, centerY + 60, { steps: 5 })
await page.mouse.up()
await page.waitForTimeout(100)
expect(await view.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
})
})
test.describe('Segmented', () => {
test('点击选项切换选中态(滑块跟随)', async ({ page }) => {
await page.goto('/')
const segmented = page.getByTestId('segmented')
await expect(segmented.locator('.g3-segmented__item.is-selected')).toHaveText('日')
await segmented.locator('.g3-segmented__item').filter({ hasText: '周' }).click()
await expect(segmented.locator('.g3-segmented__item.is-selected')).toHaveText('周')
})
})
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test'
/**
* Drawer 四个方向的几何回归。
*
* 漏测教训:之前只验了默认的 `right`,导致「上下方向的抽屉宽度不是 100%」一直没被发现。
* 根因是容器用 flex 的 justify-content / align-items 做吸附 —— 两条轴各管一个方向,
* 上下面板在主轴(水平)方向没有约束,宽度缩到了内容宽。
* 现在改为按方向定边(上下 `left/right: 0`、左右 `top/bottom: 0`),与 fms 一致。
*
* 断言以 `.g3-drawer__wrap`(定位容器)为参照,而不是写死视口尺寸:
* 视口高度受 `devices['Desktop Chrome']` 与滚动条影响,写死会得到假失败。
* 这类断言必须放 E2E:真实布局结果在 happy-dom 里量不出来。
*/
/** 打开指定方向的抽屉,返回面板与定位容器的几何 */
async function openDrawer(page: import('@playwright/test').Page, testId: string) {
await page.getByTestId(testId).click()
const drawer = page.locator('.g3-drawer')
await expect(drawer).toBeVisible()
// 等入场动画结束(transform 归零),否则量到的是滑入途中的位置
await page.waitForTimeout(450)
const panel = (await drawer.boundingBox())!
const wrap = (await page.locator('.g3-drawer__wrap').boundingBox())!
return { drawer, panel, wrap }
}
test.describe('Drawer 方向与尺寸', () => {
test('右侧:贴右边缘、纵向撑满,宽度取 width', async ({ page }) => {
await page.goto('/')
const { panel, wrap } = await openDrawer(page, 'open-drawer')
expect(Math.round(panel.x + panel.width)).toBeCloseTo(Math.round(wrap.x + wrap.width), 0)
expect(Math.round(panel.height)).toBeCloseTo(Math.round(wrap.height), 0)
expect(Math.round(panel.width)).toBe(378)
})
test('左侧:贴左边缘、纵向撑满', async ({ page }) => {
await page.goto('/')
const { panel, wrap } = await openDrawer(page, 'open-drawer-left')
expect(Math.round(panel.x)).toBeCloseTo(Math.round(wrap.x), 0)
expect(Math.round(panel.height)).toBeCloseTo(Math.round(wrap.height), 0)
expect(Math.round(panel.width)).toBe(378)
})
test('上方:贴顶、横向撑满(回归:宽度曾是内容宽)', async ({ page }) => {
await page.goto('/')
const { panel, wrap } = await openDrawer(page, 'open-drawer-top')
expect(Math.round(panel.y)).toBeCloseTo(Math.round(wrap.y), 0)
expect(Math.round(panel.width)).toBeCloseTo(Math.round(wrap.width), 0)
expect(Math.round(panel.height)).toBe(378)
})
test('下方:贴底、横向撑满(回归:宽度曾是内容宽)', async ({ page }) => {
await page.goto('/')
const { panel, wrap } = await openDrawer(page, 'open-drawer-bottom')
expect(Math.round(panel.y + panel.height)).toBeCloseTo(Math.round(wrap.y + wrap.height), 0)
expect(Math.round(panel.width)).toBeCloseTo(Math.round(wrap.width), 0)
expect(Math.round(panel.height)).toBe(378)
})
test('四个方向都能撑满对应轴,且面板不被自身遮罩压住', async ({ page }) => {
await page.goto('/')
for (const [testId, name, axis] of [
['open-drawer', '右', 'height'],
['open-drawer-left', '左', 'height'],
['open-drawer-top', '上', 'width'],
['open-drawer-bottom', '下', 'width'],
] as const) {
const { drawer, panel, wrap } = await openDrawer(page, testId)
if (axis === 'height') {
expect(Math.round(panel.height), `${name}侧抽屉未撑满高度`).toBeCloseTo(
Math.round(wrap.height),
0,
)
} else {
expect(Math.round(panel.width), `${name}侧抽屉未撑满宽度`).toBeCloseTo(
Math.round(wrap.width),
0,
)
}
const hit = await page.evaluate(
([x, y]) => {
const el = document.elementFromPoint(x as number, y as number)
return el ? String(el.className || el.tagName) : null
},
[panel.x + panel.width / 2, panel.y + Math.min(20, panel.height / 2)],
)
expect(hit, `${name}侧抽屉面板被遮挡`).toContain('g3-drawer')
await page.keyboard.press('Escape')
await expect(drawer).toBeHidden()
}
})
test('面板尺寸由组件自己决定,不受宿主页面全局样式污染', async ({ page }) => {
await page.goto('/')
// 模拟用户页面的「全局元素选择器」:面板本体是 <section>,header / footer 同理
await page.addStyleTag({
content: `
section { margin: 16px; padding: 24px; border: 2px solid red; }
header { margin: 12px; padding: 20px; }
footer { margin: 12px; padding: 20px; }
`,
})
const { panel, wrap } = await openDrawer(page, 'open-drawer')
const styles = await page.$eval('.g3-drawer', (el) => {
const cs = getComputedStyle(el)
return {
margin: [cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft],
padding: [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft],
borderWidth: cs.borderTopWidth,
}
})
expect(styles.margin).toEqual(['0px', '0px', '0px', '0px'])
expect(styles.padding).toEqual(['0px', '0px', '0px', '0px'])
expect(styles.borderWidth).toBe('0px')
// 仍然撑满:宿主 margin 不该从高度里扣掉 16px
expect(Math.round(panel.height)).toBeCloseTo(Math.round(wrap.height), 0)
})
})
@@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
/**
* 反馈类组件回归。
*
* 曾经踩过的坑:
* - Message 的方位类名手写成 `g3-message-list--*`(样式表是 `g3-message__list--*`),
* 9 个方位全部失效、消息一律堆在左上角;
* - Modal 面板不是弹性布局,缩放后内容溢出面板而不是内部滚动。
*/
test.describe('Message 方位', () => {
test('默认方位是顶部居中', async ({ page }) => {
await page.goto('/')
await page.getByTestId('toast-default').click()
const message = page.locator('.g3-message').first()
await expect(message).toBeVisible()
const messageBox = (await message.boundingBox())!
const viewport = page.viewportSize()!
const center = messageBox.x + messageBox.width / 2
expect(Math.abs(center - viewport.width / 2)).toBeLessThan(2)
expect(messageBox.y).toBeLessThan(viewport.height * 0.25)
})
test('指定方位生效:左上 / 右下', async ({ page }) => {
await page.goto('/')
const viewport = page.viewportSize()!
await page.getByTestId('toast-top-left').click()
const topLeft = (await page.locator('.g3-message').first().boundingBox())!
expect(topLeft.x).toBeLessThan(48)
expect(topLeft.y).toBeLessThan(viewport.height * 0.25)
await page.waitForTimeout(3200)
await page.getByTestId('toast-bottom-right').click()
const bottomRight = (await page.locator('.g3-message').first().boundingBox())!
expect(bottomRight.x + bottomRight.width).toBeGreaterThan(viewport.width - 48)
expect(bottomRight.y).toBeGreaterThan(viewport.height * 0.6)
})
})
test.describe('Notification', () => {
test('默认出现在右上角', async ({ page }) => {
await page.goto('/')
const viewport = page.viewportSize()!
await page.getByTestId('notify').click()
const notification = page.locator('.g3-notification').first()
await expect(notification).toBeVisible()
const box = (await notification.boundingBox())!
expect(box.x + box.width).toBeGreaterThan(viewport.width - 48)
expect(box.y).toBeLessThan(viewport.height * 0.3)
})
})
test.describe('Modal', () => {
test('缩放变矮后内容区内部滚动,而不是溢出面板', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-modal').click()
const modal = page.locator('.g3-modal')
await expect(modal).toBeVisible()
// 把南侧手柄往上拖,压缩面板高度
const handle = (await page.locator('.g3-modal__resizer-s').boundingBox())!
const startX = handle.x + handle.width / 2
await page.mouse.move(startX, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(startX, handle.y - 400, { steps: 8 })
await page.mouse.up()
const panel = (await modal.boundingBox())!
const body = await page.evaluate(() => {
const el = document.querySelector('.g3-modal__body') as HTMLElement
return { client: el.clientHeight, scroll: el.scrollHeight }
})
// 面板被压矮,内容比可视区高 → 必须内部滚动
expect(panel.height).toBeLessThan(700)
expect(body.scroll).toBeGreaterThan(body.client)
})
test('Esc 关闭,遮罩点击可关闭', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-modal').click()
await expect(page.locator('.g3-modal')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.locator('.g3-modal')).toBeHidden()
await page.getByTestId('open-modal').click()
await page.mouse.click(60, 400)
await expect(page.locator('.g3-modal')).toBeHidden()
})
test('焦点被限制在面板内(Tab 循环)', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-modal').click()
await expect(page.locator('.g3-modal')).toBeVisible()
for (let index = 0; index < 8; index += 1) {
await page.keyboard.press('Tab')
}
const inside = await page.evaluate(() => {
const panel = document.querySelector('.g3-modal')
return panel ? panel.contains(document.activeElement) : false
})
expect(inside).toBe(true)
})
})
test.describe('Modal.confirm(命令式)', () => {
test('点确定后按钮进入 loading,完成后自动关闭', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-confirm').click()
const modal = page.locator('.g3-modal')
await expect(modal).toBeVisible()
await expect(modal.locator('.g3-modal__title')).toHaveText('删除确认')
const confirmButton = modal.locator('.g3-modal__footer button').last()
await expect(confirmButton).toHaveText('确定')
await confirmButton.click()
// loading 期间按钮禁用且带 aria-busy
await expect(confirmButton).toBeDisabled()
await expect(confirmButton).toHaveAttribute('aria-busy', 'true')
// onConfirm 的 Promise resolve 后自动关闭
await expect(modal).toBeHidden({ timeout: 5000 })
})
})
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test'
/**
* 表单类组件的真实布局回归。
*
* 曾经踩过的坑:
* - textarea 的 footer(计数 + 拖拽手柄)在文档流里,被挤到框外;
* - 日期时间面板上下排列而不是「左日期 / 右时间」;
* - 日期面板宽度跟随输入框被拉伸,日历格子间距被撑散;
* - 非法日期(2026-02-30)被原生解析静默顺延成 3 月 2 日。
*/
test.describe('Input / textarea', () => {
test('拖拽手柄落在输入框内部右下角(不再被挤到框外)', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="textarea-count"]')
const box = (await root.boundingBox())!
const handle = (await root.locator('.g3-input__resize-handle').boundingBox())!
expect(handle.x + handle.width).toBeLessThanOrEqual(box.x + box.width + 1)
expect(handle.y + handle.height).toBeLessThanOrEqual(box.y + box.height + 1)
expect(handle.y).toBeGreaterThan(box.y + box.height / 2)
})
test('字数统计与手柄同处一行,且有可访问语义', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="textarea-count"]')
await expect(root.locator('.g3-input__count')).toHaveText(/\d+ \/ 100/)
await expect(root.locator('.g3-input__resize-handle')).toHaveAttribute('role', 'slider')
await expect(root.locator('.g3-input__resize-handle')).toHaveAttribute('aria-orientation', 'vertical')
})
test('number 输入实时净化,只保留一个小数点', async ({ page }) => {
await page.goto('/')
const input = page.locator('[data-testid="input-number"] input')
await input.fill('12.3.4')
await expect(input).toHaveValue('12.34')
})
test('password 可切换明文', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="input-password"]')
await expect(root.locator('input')).toHaveAttribute('type', 'password')
await root.locator('.g3-input__password-toggle').click()
await expect(root.locator('input')).toHaveAttribute('type', 'text')
})
})
test.describe('DatePicker', () => {
test('日期时间面板呈「左日历 / 右时间」,且日历定宽不被拉伸', async ({ page }) => {
await page.goto('/')
const trigger = page.locator('[data-testid="date-picker"] .g3-date-picker__trigger')
const triggerWidth = (await trigger.boundingBox())!.width
await trigger.click()
const panel = page.locator('.g3-date-picker__panel')
await expect(panel).toBeVisible()
const calendar = panel.locator('.g3-calendar__main')
const time = panel.locator('.g3-calendar__time')
await expect(calendar).toBeVisible()
await expect(time).toBeVisible()
const calendarBox = (await calendar.boundingBox())!
const timeBox = (await time.boundingBox())!
// 日历在左、时间列在右,同一行(纵向重叠)
expect(timeBox.x).toBeGreaterThan(calendarBox.x + calendarBox.width - 1)
expect(timeBox.y).toBeLessThan(calendarBox.y + calendarBox.height)
// 日历定宽,面板不跟随输入框拉宽
expect(calendarBox.width).toBeLessThan(260)
const panelBox = (await panel.boundingBox())!
expect(panelBox.width).toBeLessThan(triggerWidth)
})
test('面板内的确定按钮只有点选后才能用', async ({ page }) => {
await page.goto('/')
await page.locator('[data-testid="date-picker"] .g3-date-picker__trigger').click()
const confirm = page.locator('.g3-calendar__footer button')
await expect(confirm).toBeEnabled()
})
test('非法日期不会被接受(不再静默顺延)', async ({ page }) => {
await page.goto('/')
const input = page.locator('[data-testid="date-picker"] .g3-date-picker__input')
await input.click()
await input.fill('2026-02-30')
await input.press('Enter')
// 回退到原值,不能变成 2026-03-02
await expect(input).not.toHaveValue('2026-03-02')
await expect(page.locator('[data-testid="date-picker"]')).toHaveClass(/is-invalid/)
})
})
test.describe('RangePicker', () => {
test('区间面板打开且两端都有值', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="range-picker"]')
await expect(root.locator('input').first()).toHaveValue('2026-10-01')
await expect(root.locator('input').nth(1)).toHaveValue('2026-10-08')
await root.locator('.g3-range-picker__trigger').click()
await expect(page.locator('.g3-range-picker__panel')).toBeVisible()
})
})
@@ -0,0 +1,121 @@
import { expect, test } from '@playwright/test'
/**
* 弹层层级回归 —— 这类问题单测覆盖不到(需要真实层叠上下文与命中测试)。
*
* 曾经踩过的坑:
* 1. 档位顺序写反(popup < modal)→ 弹窗/抽屉内的下拉被遮罩盖住;
* 2. 受控打开 / 挂载即展开的浮层根本没分配 z-index;
* 3. Drawer 的 wrap 是 fixed 但没 z-index → 整个抽屉被自己的遮罩压住。
*
* 注意:playground 里有两个「挂载即展开」的常驻 Popover,
* 所以定位浮层时要用 :has-text / filter 精确命中,不能直接 `.g3-popover__popup`。
*/
/** 读取视口内某点最上层的元素类名(判断「谁盖住谁」的唯一可靠方式) */
async function topElementAt(page: import('@playwright/test').Page, x: number, y: number) {
return page.evaluate(
([px, py]) => {
const el = document.elementFromPoint(px as number, py as number)
if (!el) return null
return typeof el.className === 'string' ? el.className : el.tagName
},
[x, y],
)
}
test.describe('浮层层级', () => {
test('挂载即展开的浮层同样有层级,且同档按打开顺序递增', async ({ page }) => {
await page.goto('/')
const popupA = page.locator('.g3-popover__popup', { hasText: '浮层 A' })
const popupB = page.locator('.g3-popover__popup', { hasText: '浮层 B' })
await expect(popupA).toBeVisible()
await expect(popupB).toBeVisible()
const zA = Number(await popupA.evaluate((el) => (el as HTMLElement).style.zIndex))
const zB = Number(await popupB.evaluate((el) => (el as HTMLElement).style.zIndex))
expect(zA).toBeGreaterThan(3000)
expect(zB).toBeGreaterThan(zA)
})
test('抽屉面板不会被自身遮罩盖住,点遮罩仍可关闭', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-drawer').click()
const drawer = page.locator('.g3-drawer')
await expect(drawer).toBeVisible()
// 等入场动画结束(transform 归零)再做命中测试,否则面板还在视口外
await page.waitForTimeout(450)
const panelBox = (await drawer.boundingBox())!
const inside = await topElementAt(
page,
panelBox.x + panelBox.width / 2,
panelBox.y + panelBox.height / 2,
)
expect(inside).not.toBeNull()
expect(inside).toContain('g3-drawer')
// 遮罩与 wrap 同级(同一实例共用层级)
const maskZ = Number(await page.$eval('.g3-drawer__mask', (el) => (el as HTMLElement).style.zIndex))
const wrapZ = Number(await page.$eval('.g3-drawer__wrap', (el) => (el as HTMLElement).style.zIndex))
expect(wrapZ).toBe(maskZ)
// 点面板外(遮罩区域)关闭
const maskBox = (await page.locator('.g3-drawer__mask').boundingBox())!
await page.mouse.click(maskBox.x + 40, maskBox.y + maskBox.height / 2)
await expect(drawer).toBeHidden()
})
test('弹窗内的下拉浮层高于遮罩,且可正常点选', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-modal').click()
await expect(page.locator('.g3-modal')).toBeVisible()
const select = page.getByTestId('modal-select')
await select.locator('.g3-select__trigger').click()
const popup = page.locator('.g3-popover__popup').filter({ has: page.locator('.g3-select__dropdown') })
await expect(popup).toBeVisible()
const modalZ = Number(await page.$eval('.g3-modal__mask', (el) => (el as HTMLElement).style.zIndex))
const popupZ = Number(await popup.evaluate((el) => (el as HTMLElement).style.zIndex))
expect(popupZ).toBeGreaterThan(modalZ)
// 浮层区域最上层必须是选项本体,而不是遮罩
const optionBox = (await page.locator('.g3-select__option').first().boundingBox())!
const hit = await topElementAt(page, optionBox.x + 10, optionBox.y + optionBox.height / 2)
expect(hit).toContain('g3-select__option')
})
test('反馈类提示盖在浮层与弹窗之上', async ({ page }) => {
await page.goto('/')
await page.getByTestId('open-modal').click()
const select = page.getByTestId('modal-select')
await select.locator('.g3-select__trigger').click()
await expect(
page.locator('.g3-popover__popup').filter({ has: page.locator('.g3-select__dropdown') }),
).toBeVisible()
// 触发按钮此时在弹窗遮罩之后:真实用户点不到它(这正是层级正确的表现),
// 所以直接派发 click 到元素上,本用例只验证「提示出现后压在最上层」
await page.getByTestId('toast-default').dispatchEvent('click')
await expect(page.locator('.g3-message').first()).toBeVisible()
const messageZ = Number(
await page.$eval('.g3-message__container', (el) => (el as HTMLElement).style.zIndex),
)
const popupZ = Number(
await page
.locator('.g3-popover__popup')
.filter({ has: page.locator('.g3-select__dropdown') })
.evaluate((el) => (el as HTMLElement).style.zIndex),
)
const modalZ = Number(await page.$eval('.g3-modal__mask', (el) => (el as HTMLElement).style.zIndex))
expect(messageZ).toBeGreaterThan(popupZ)
expect(popupZ).toBeGreaterThan(modalZ)
})
})
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test'
/**
* Select 回归。
*
* 最严重的一次事故:unwrapped 触发器每次渲染都换新的组件对象,
* `<component :is>` 判定类型变化 → 卸载重建触发器 DOM → 焦点丢失派发 focusout
* → 处理器改状态 → 再重建……自我驱动成死循环,点击一下就把主线程打满。
* 所以这里用 MutationObserver 数「DOM 节点增删次数」来锁住「触发器不被重建」。
*/
test.describe('Select 交互', () => {
test('打开下拉不会重建触发器 DOM(死循环回归)', async ({ page }) => {
await page.goto('/')
await page.evaluate(() => {
;(window as unknown as { __mut: number }).__mut = 0
new MutationObserver((records) => {
const target = window as unknown as { __mut: number }
for (const record of records) {
target.__mut += record.addedNodes.length + record.removedNodes.length
}
}).observe(document.body, { childList: true, subtree: true })
})
const trigger = page.locator('[data-testid="select-basic"] .g3-select__trigger')
await trigger.click()
await expect(page.locator('.g3-select__option').first()).toBeVisible()
await page.waitForTimeout(400)
const mutations = await page.evaluate(() => (window as unknown as { __mut: number }).__mut)
// 只有浮层挂载带来的少量节点变化;重建触发器会产生成百上千次
expect(mutations).toBeLessThan(20)
// 焦点应稳定停留在触发器输入框上
const focused = await page.evaluate(() => document.activeElement?.className ?? '')
expect(focused).toContain('g3-select__input')
})
test('选中选项后回填并关闭', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="select-basic"]')
await root.locator('.g3-select__trigger').click()
await page.locator('.g3-select__option').filter({ hasText: '会员' }).click()
await expect(root.locator('input')).toHaveValue('会员')
await expect(page.locator('.g3-select__dropdown')).toHaveCount(0)
})
test('多选以「、」拼接回显,且选中后保持展开', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="select-multiple"]')
await root.locator('.g3-select__trigger').click()
await page.locator('.g3-select__option').filter({ hasText: '管理员' }).click()
await page.locator('.g3-select__option').filter({ hasText: '会员' }).click()
await expect(root.locator('input')).toHaveValue('管理员、会员')
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
})
test('下拉宽度跟随触发框,且不超过上限', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="select-basic"]')
const triggerWidth = (await root.locator('.g3-select__trigger').boundingBox())!.width
await root.locator('.g3-select__trigger').click()
// playground 里还有两个常驻 Popover,用内容过滤精确定位本用例的浮层
const popup = page.locator('.g3-popover__popup').filter({ has: page.locator('.g3-select__dropdown') })
await expect(popup).toBeVisible()
const popupWidth = (await popup.boundingBox())!.width
// 下拉宽度 = max(触发框宽, 内容宽):触发框更宽时下拉跟着变宽(antd 行为),
// 样式里的 max-width 只在触发框较窄、内容很长时起限制作用
expect(popupWidth).toBeGreaterThanOrEqual(Math.floor(triggerWidth) - 1)
const viewport = page.viewportSize()!
expect(popupWidth).toBeLessThanOrEqual(viewport.width)
})
})
@@ -0,0 +1,224 @@
import { expect, test } from '@playwright/test'
/**
* 组件细节回归 —— 每一条都对应一个真实报障,之前全部漏测。
*
* 共同点:它们要么靠布局计算(滑块尺寸、等高、线性位移),要么靠伪类层叠
* (:hover 的特异性、相邻边框的 z-index),要么靠 CSS 变量的实际解析结果
* (color-mix 里的 var())。这些在 happy-dom 里量不出来,必须放 E2E。
*/
test.describe('ButtonGroup', () => {
test('横向:hover 的按钮浮到上层,被压住的那条边框仍可见', async ({ page }) => {
await page.goto('/')
const second = page.getByTestId('btn-group-plain').locator('.g3-button').nth(1)
await second.hover()
// 相邻按钮用 -1px 负边距重叠,不抬 z-index 时后一个按钮的边框会盖住它
await expect(second).toHaveCSS('z-index', '1')
// 边框色有 0.2s 过渡:必须用会重试的断言,直接 evaluate 会读到过渡中间值
await expect(second).toHaveCSS('border-top-color', 'rgb(24, 24, 27)')
})
test('纵向:hover 的按钮同样抬升', async ({ page }) => {
await page.goto('/')
const second = page.getByTestId('btn-group-vertical').locator('.g3-button').nth(1)
await second.hover()
await expect(second).toHaveCSS('z-index', '1')
await expect(second).toHaveCSS('border-top-color', 'rgb(24, 24, 27)')
})
})
test.describe('Segmented', () => {
test('选中滑块与选中项同尺寸同位置(滑块曾塌成 0 高,看不出选中)', async ({ page }) => {
await page.goto('/')
// 必须限定在同一个实例内:页面上有多组 Segmented,跨实例比较会拿到别人的滑块
const group = page.locator('.g3-segmented__group').first()
const thumb = group.locator('.g3-segmented__thumb')
const item = group.locator('.g3-segmented__item.is-selected')
const t = (await thumb.boundingBox())!
const i = (await item.boundingBox())!
// 滑块尺寸来自 offsetWidth/offsetHeight(整数),实际布局是亚像素,允许 1px 误差
for (const [name, a, b] of [
['width', t.width, i.width],
['height', t.height, i.height],
['x', t.x, i.x],
['y', t.y, i.y],
] as const) {
expect(Math.abs(a - b), `滑块与选中项的 ${name} 不一致`).toBeLessThanOrEqual(1)
}
expect(t.height).toBeGreaterThan(0)
// 滑块就是选中底色,不能是透明的
expect(await thumb.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(
'rgb(255, 255, 255)',
)
})
})
test.describe('Pagination', () => {
test('hover 当前页仍保持主色填充(曾被通用 hover 覆盖成白底白字)', async ({ page }) => {
await page.goto('/')
const current = page.locator('.g3-pagination__page.is-page-current')
await current.hover()
await expect(current).toHaveCSS('background-color', 'rgb(24, 24, 27)')
await expect(current).toHaveCSS('color', 'rgb(250, 250, 250)')
})
})
test.describe('Tooltip', () => {
test('气泡没有描边(投影由 popup 的 drop-shadow 统一施加)', async ({ page }) => {
await page.goto('/')
await page.getByTestId('tooltip-trigger').hover()
const content = page.locator('.g3-popover__content').first()
await expect(content).toBeVisible()
await expect(content).toHaveCSS('border-top-width', '0px')
await expect(content).toHaveCSS('border-left-width', '0px')
})
})
test.describe('Tabs 垂直方向', () => {
for (const placement of ['left', 'right'] as const) {
test(`${placement}:标签栏与内容区之间有竖向分隔线,选中项带侧边指示线与底色`, async ({
page,
}) => {
await page.goto('/')
const tabs = page.locator(`.g3-tabs--${placement}`).first()
const nav = tabs.locator('.g3-tabs__nav')
const active = tabs.locator('.g3-tabs__tab.is-tab-active').first()
if (placement === 'left') {
await expect(nav).toHaveCSS('border-right-width', '1px')
await expect(active).toHaveCSS('border-right-width', '2px')
} else {
await expect(nav).toHaveCSS('border-left-width', '1px')
await expect(active).toHaveCSS('border-left-width', '2px')
}
// 选中项底色不再是 transparent
const bg = await active.evaluate((el) => getComputedStyle(el).backgroundColor)
expect(bg).not.toBe('rgba(0, 0, 0, 0)')
})
}
})
test.describe('Tree', () => {
test('点击节点有选中底色(token color-mix 曾解析失败导致背景透明)', async ({ page }) => {
await page.goto('/')
const node = page.locator('.g3-tree-node', { hasText: '节点一' }).first()
await node.click()
await expect(node).toHaveClass(/is-selected/)
const bg = await node.evaluate((el) => getComputedStyle(el).backgroundColor)
expect(bg, `选中底色不应为透明:${bg}`).not.toBe('rgba(0, 0, 0, 0)')
})
test('整层都是叶子时不渲染展开箭头占位', async ({ page }) => {
await page.goto('/')
// 扁平树:两个节点都没有子节点
const flatTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' }).first()
await expect(flatTree.locator('.g3-tree-node')).toHaveCount(2)
await expect(flatTree.locator('.g3-tree-node__switcher')).toHaveCount(0)
})
test('拖拽需要长按武装:直接拖动不会产生落点', async ({ page }) => {
await page.goto('/')
const draggableTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' })
// 用带 draggable 的那棵树里的第一行
const row = page.locator('.g3-tree-node').first()
const box = (await row.boundingBox())!
await page.mouse.move(box.x + 40, box.y + box.height / 2)
await page.mouse.down()
// 立刻移动(未到 400ms 阈值):draggable 属性还没武装
await page.mouse.move(box.x + 60, box.y + box.height / 2 + 30, { steps: 3 })
expect(await row.getAttribute('draggable')).toBeNull()
await page.mouse.up()
expect(await draggableTree.count()).toBeGreaterThan(0)
})
})
test.describe('DatePicker 时间列', () => {
test('时/分/秒三列并排:定宽、列间分隔线、列表与日历等高且可滚动', async ({ page }) => {
await page.goto('/')
await page.locator('.g3-date-picker__input').first().click()
const calendar = page.locator('.g3-calendar')
await expect(calendar).toBeVisible()
const cols = calendar.locator('.g3-time-select__col')
await expect(cols).toHaveCount(3)
await expect(cols.first()).toHaveCSS('width', '56px')
await expect(cols.nth(1)).toHaveCSS('border-left-width', '1px')
const main = (await calendar.locator('.g3-calendar__main').boundingBox())!
const time = (await calendar.locator('.g3-calendar__time').boundingBox())!
expect(Math.round(main.height)).toBe(Math.round(time.height))
const list = calendar.locator('.g3-time-select__list').first()
const listBox = (await list.boundingBox())!
expect(Math.round(listBox.height)).toBe(Math.round(main.height))
expect(
await list.evaluate((el) => el.scrollHeight > el.clientHeight),
'时间列应当内部滚动而不是撑开面板',
).toBe(true)
// 单个选项 28px 高
await expect(calendar.locator('.g3-time-select__item').first()).toHaveCSS('height', '28px')
})
test('年 / 月选择面板:3 列 × 4 行,标题显示年份区间,当前年有标记', async ({ page }) => {
await page.goto('/')
await page.locator('.g3-date-picker__input').first().click()
await page.locator('.g3-calendar__title-button').first().click()
const selector = page.locator('.g3-calendar__selector')
await expect(selector).toBeVisible()
await expect(selector.locator('.g3-calendar__selector-item')).toHaveCount(12)
await expect(selector).toHaveCSS('grid-template-columns', /(\d+(\.\d+)?px ){2}/)
const title = await page.locator('.g3-calendar__title-button').first().textContent()
expect(title).toMatch(/\d{4}\s*-\s*\d{4}/)
await expect(selector.locator('.g3-calendar__selector-item.is-selected')).toHaveCount(1)
})
})
test.describe('Splitter 拖拽', () => {
test('面板宽度随指针线性变化(曾经一拖就跳)', async ({ page }) => {
await page.goto('/')
const bar = page.locator('.g3-splitter__bar').first()
// mouse API 用视口坐标且不会自动滚动,元素不在视口内时拖拽会落空
await bar.scrollIntoViewIfNeeded()
const box = (await bar.boundingBox())!
const widths = () =>
page.$$eval('.g3-splitter-panel', (els) =>
els.map((el) => Math.round(el.getBoundingClientRect().width)),
)
const before = await widths()
const startX = box.x + box.width / 2
const y = box.y + box.height / 2
await page.mouse.move(startX, y)
await page.mouse.down()
const samples: number[][] = []
for (const dx of [20, 40, 60]) {
await page.mouse.move(startX + dx, y)
await page.waitForTimeout(60)
samples.push(await widths())
}
await page.mouse.up()
// 每移动 20px,左侧面板就该宽 20px(严格 1:1,不是跳到某个值)
samples.forEach((sample, index) => {
const delta = sample[0] - before[0]
expect(delta, `第 ${index + 1} 次移动后左侧宽度增量`).toBeGreaterThanOrEqual((index + 1) * 20 - 2)
expect(delta).toBeLessThanOrEqual((index + 1) * 20 + 2)
})
})
})