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 写到 ``),不依赖点击开关的顺序。 */ 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 { return (await page.locator('html').getAttribute('dir')) === 'rtl' } /** * 用真实鼠标把滑块从起点拖向行内结束端;`releaseRatio` 传 0.5 表示拖到一半就松手。 */ async function dragHandle( page: Page, root: Locator, options: { releaseRatio?: number } = {}, ): Promise { 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) }) }) })