Files
workspace/code/g3soft-libs/packages/ui/e2e/captcha.spec.ts
T
2026-10-11 22:14:13 +08:00

156 lines
6.6 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 Locator, type Page } from '@playwright/test'
/**
* 滑块验证码(L3,真浏览器)。
*
* 为什么必须在真实浏览器里跑:这一层的断言全是**几何**与**指针轨迹** —— 手柄最终停在哪、
* 进度条盖住多少、拖到容器外松手会不会收尾。happy-dom 没有布局引擎,单测只能把 rect
* 钉死后验逻辑(见 src/captcha/SliderCaptcha.test.ts)。
*
* 两个踩过的坑(写在这里防复发):
* 1. playground 是一整页长文档,滑块区在首屏之下,而 `boundingBox()` 返回**视口坐标** ——
* 不先 `scrollIntoViewIfNeeded()` 就往那儿发鼠标事件,会落到视口外,表现为「拖了没反应」;
* 2. 拖拽终点要**越过容器边缘**几像素:行程是按 padding box 算的,贴着边缘算容易因为
* 1px 取整「差一点不通过」,越界部分本来就会被组件夹住。
*
* 进 RTL 统一走 `?dir=rtl`(playground 写到 `<html dir>`),不依赖点击开关的顺序。
*/
const url = (dir: 'ltr' | 'rtl') => `/?dir=${dir}`
async function gotoDir(page: Page, dir: 'ltr' | 'rtl') {
await page.goto(url(dir))
await expect(page.locator('html')).toHaveAttribute('dir', dir)
}
async function isRtl(page: Page): Promise<boolean> {
return (await page.locator('html').getAttribute('dir')) === 'rtl'
}
/**
* 用真实鼠标把滑块从起点拖向行内结束端;`releaseRatio` 传 0.5 表示拖到一半就松手。
*/
async function dragHandle(
page: Page,
root: Locator,
options: { releaseRatio?: number } = {},
): Promise<void> {
await root.scrollIntoViewIfNeeded()
const rtl = await isRtl(page)
const handle = root.locator('.g3-slider-captcha__action')
const handleBox = (await handle.boundingBox())!
const rootBox = (await root.boundingBox())!
const y = handleBox.y + handleBox.height / 2
const startX = handleBox.x + handleBox.width / 2
// 越界 8px:行内结束端在 LTR 是右边缘、RTL 是左边缘
const endX = rtl ? rootBox.x - 8 : rootBox.x + rootBox.width + 8
const targetX = startX + (endX - startX) * (options.releaseRatio ?? 1)
await page.mouse.move(startX, y)
await page.mouse.down()
await page.mouse.move(targetX, y, { steps: 12 })
await page.mouse.up()
}
test.describe('SliderCaptcha', () => {
test('真实拖拽到末端即通过:进度 100、文案切换、状态回传', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="slider-captcha"]')
const handle = root.locator('.g3-slider-captcha__action')
await root.scrollIntoViewIfNeeded()
await expect(root.locator('.g3-slider-captcha__text')).toHaveText('按住滑块拖动')
await expect(handle).toHaveAttribute('aria-valuenow', '0')
// 未通过时才可聚焦,通过后要让出 Tab 顺序
await expect(handle).toHaveAttribute('tabindex', '0')
await dragHandle(page, root)
await expect(root).toHaveClass(/is-passed/)
await expect(root.locator('.g3-slider-captcha__text')).toHaveText('验证通过')
await expect(handle).toHaveAttribute('aria-valuenow', '100')
await expect(handle).toHaveAttribute('tabindex', '-1')
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('passed')
// 手柄右缘贴住轨道末端(取整误差 1px 内)
const rootBox = (await root.boundingBox())!
const handleBox = (await handle.boundingBox())!
expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(rootBox.x + rootBox.width + 1)
// 进度条盖住手柄的一半
const barBox = (await root.locator('.g3-slider-captcha__bar').boundingBox())!
expect(barBox.width).toBeGreaterThan(rootBox.width - handleBox.width)
})
test('中途松手回弹到起点,不报通过', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="slider-captcha"]')
const handle = root.locator('.g3-slider-captcha__action')
await dragHandle(page, root, { releaseRatio: 0.5 })
await expect(root).not.toHaveClass(/is-passed/)
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('idle')
await expect(handle).toHaveAttribute('aria-valuenow', '0')
// 回弹有 300ms 过渡,用 poll 等它归位到行程起点
const rootBox = (await root.boundingBox())!
await expect
.poll(async () => (await handle.boundingBox())!.x, { timeout: 3000 })
.toBeLessThanOrEqual(rootBox.x + 2)
})
test('禁用态拖不动,且带 is-disabled 类', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="slider-captcha-disabled"]')
const handle = root.locator('.g3-slider-captcha__action')
await root.scrollIntoViewIfNeeded()
await expect(root).toHaveClass(/is-disabled/)
await expect(handle).toHaveAttribute('tabindex', '-1')
await dragHandle(page, root)
await expect(root).not.toHaveClass(/is-passed/)
})
test('键盘:End 直达终点并通过,Home 复位', async ({ page }) => {
await page.goto('/')
const root = page.locator('[data-testid="slider-captcha"]')
const handle = root.locator('.g3-slider-captcha__action')
await root.scrollIntoViewIfNeeded()
await handle.focus()
await page.keyboard.press('End')
await expect(root).toHaveClass(/is-passed/)
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('passed')
})
test.describe('RTL', () => {
test('手柄从右侧起步,向左拖到末端才通过', async ({ page }) => {
await gotoDir(page, 'rtl')
const root = page.locator('[data-testid="slider-captcha"]')
const handle = root.locator('.g3-slider-captcha__action')
await root.scrollIntoViewIfNeeded()
const rootBox = (await root.boundingBox())!
const handleBox = (await handle.boundingBox())!
// 起点贴行内起始边 = RTL 下的右边
expect(handleBox.x + handleBox.width).toBeGreaterThanOrEqual(rootBox.x + rootBox.width - 2)
// 未通过时的方向性图标按全库规则翻面(chevrons-right 在 RTL 下镜像);
// 必须在拖拽前断言 —— 通过后图标会换成 Check,那条镜像规则就不适用了
const iconTransform = await handle
.locator('svg')
.evaluate((el) => getComputedStyle(el).transform)
expect(iconTransform).toBe('matrix(-1, 0, 0, 1, 0, 0)')
await dragHandle(page, root)
await expect(root).toHaveClass(/is-passed/)
// 终点贴行内结束边 = RTL 下的左边
await expect
.poll(async () => (await handle.boundingBox())!.x, { timeout: 3000 })
.toBeLessThanOrEqual(rootBox.x + 2)
})
})
})