@vobs/captcha 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/package.json +28 -0
- package/src/index.test.ts +417 -0
- package/src/index.ts +279 -0
- package/src/slider.ts +566 -0
- package/src/styles/styles.css +437 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @vobs/captcha
|
|
2
|
+
|
|
3
|
+
State-driven captcha widgets for vobs: `Captcha` renders an externally supplied challenge and forwards answers to your submit handler, while `SliderCaptcha` is a slider puzzle that submits drag trail and device signals for server-side verification.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/captcha
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { state } from '@vobs/reactivity'
|
|
15
|
+
import { createElement, createText, createVobs, insertBefore } from '@vobs/vobs'
|
|
16
|
+
import { Captcha } from '@vobs/captcha'
|
|
17
|
+
import type { CaptchaChallenge, CaptchaStatus } from '@vobs/captcha'
|
|
18
|
+
|
|
19
|
+
const status = state<CaptchaStatus>('ready')
|
|
20
|
+
const challenge = state<CaptchaChallenge<{ prompt: string }> | null>({
|
|
21
|
+
id: 'challenge-1',
|
|
22
|
+
payload: { prompt: 'Answer this' },
|
|
23
|
+
expiresAt: Date.now() + 60_000
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const app = createVobs({
|
|
27
|
+
render: () => Captcha({
|
|
28
|
+
challenge,
|
|
29
|
+
status,
|
|
30
|
+
onSubmit: (answer, current) => verifyOnServer(current.id, answer),
|
|
31
|
+
renderChallenge({ challenge: current, submit }) {
|
|
32
|
+
const button = createElement('button')
|
|
33
|
+
insertBefore(button, createText(current.payload!.prompt), null)
|
|
34
|
+
button.addEventListener('click', () => submit('answer'))
|
|
35
|
+
return button
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
app.mount(document.getElementById('app')!)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Props accept plain values, signals, or getters. The component never fetches anything itself: statuses (`idle`, `loading`, `ready`, `verifying`, `verified`, `expired`, `error`) render built-in messages, retry/cancel actions appear based on status, and `submit` does nothing when no `onSubmit` is present or the status is `loading`/`verifying`. `onSubmit` returns the caller's promise unchanged, so the owner decides when to move to `verified`, `expired`, or `error`.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
| Signature | Description |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `Captcha<Challenge>(props?: CaptchaProps<Challenge>)` | Challenge host. `renderChallenge(context)` draws the challenge and receives `submit(answer)`. Options: `keepChallengeOnError`/`keepChallengeOnLoading`/`keepChallengeOnVerifying`, `messagePlacement` (`'challenge' \| 'footer' \| 'none'`), custom `retryLabel`/`cancelLabel`/`expiredLabel`/..., `retryIcon`, `showRetry`/`showCancel`. |
|
|
50
|
+
| `SliderCaptcha(props?: SliderCaptchaProps)` | Puzzle slider with target notch, optional `decoys`, and a draggable handle or piece. On drop it calls `onSubmit(result, challenge)` with `{ x, trail, deviceSignals }`. Keeps the piece position and shows an overlay error on failure, disables dragging and refresh while loading, shows a check on the handle after `verified`, and dismisses the component `successDuration` ms (default 1000) later via `onSuccessDismiss`. |
|
|
51
|
+
| `analyzeSliderTrail(trail)` | Behavior features of a drag trail: `pointCount`, `duration`, `distance`, `directionChanges`, `verticalTravel`, `looksHuman`. |
|
|
52
|
+
| `collectCaptchaDeviceSignals()` | Basic device signals (including a `sessionId`) attached to slider submissions; disable with `collectDeviceSignals: false`. |
|
|
53
|
+
|
|
54
|
+
## Types
|
|
55
|
+
|
|
56
|
+
CaptchaStatus, CaptchaAnswer, CaptchaValue, CaptchaChallenge, CaptchaSubmitContext, CaptchaChallengeRenderer, CaptchaProps, SliderShape, SliderTrailPoint, SliderTrailAnalysis, CaptchaDeviceSignals, SliderCaptchaResult, SliderCaptchaProps, SliderCaptchaValue, SliderCaptchaChallenge, SliderCaptchaPayload, SliderCaptchaDecoy
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/captcha",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"description": "Provider-agnostic captcha UI primitive for Vobs.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.ts",
|
|
16
|
+
"./styles.css": "./src/styles/styles.css",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": [
|
|
20
|
+
"./src/styles/*.css"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@vobs/vobs": "1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest --environment jsdom"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createElement, createText, createVobs, insertBefore } from '@vobs/vobs'
|
|
3
|
+
import { state } from '@vobs/reactivity'
|
|
4
|
+
import {
|
|
5
|
+
Captcha,
|
|
6
|
+
analyzeSliderTrail,
|
|
7
|
+
SliderCaptcha,
|
|
8
|
+
type CaptchaChallenge,
|
|
9
|
+
type CaptchaProps,
|
|
10
|
+
type CaptchaStatus,
|
|
11
|
+
type SliderCaptchaChallenge
|
|
12
|
+
} from './index'
|
|
13
|
+
|
|
14
|
+
function mountCaptcha<Challenge = unknown>(props: CaptchaProps<Challenge>): HTMLElement {
|
|
15
|
+
const host = document.createElement('div')
|
|
16
|
+
createVobs({ render: () => Captcha(props) }).mount(host)
|
|
17
|
+
return host
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('@vobs/captcha', () => {
|
|
21
|
+
it('只渲染外部传入的 challenge,不发起请求', () => {
|
|
22
|
+
const challenge: CaptchaChallenge<{ prompt: string }> = {
|
|
23
|
+
id: 'challenge-1',
|
|
24
|
+
type: 'custom',
|
|
25
|
+
payload: { prompt: 'Answer this' },
|
|
26
|
+
expiresAt: Date.now() + 60_000
|
|
27
|
+
}
|
|
28
|
+
const onSubmit = vi.fn()
|
|
29
|
+
const host = mountCaptcha({
|
|
30
|
+
challenge,
|
|
31
|
+
status: 'ready',
|
|
32
|
+
onSubmit,
|
|
33
|
+
renderChallenge({ challenge: current, submit }) {
|
|
34
|
+
const button = createElement('button')
|
|
35
|
+
insertBefore(button, createText(current.payload!.prompt), null)
|
|
36
|
+
button.addEventListener('click', () => submit('answer'))
|
|
37
|
+
return button
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(host.querySelector('[data-status="ready"]')).not.toBeNull()
|
|
42
|
+
expect(host.textContent).toContain('Answer this')
|
|
43
|
+
host.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
44
|
+
expect(onSubmit).toHaveBeenCalledWith('answer', challenge)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('由外部控制 expired 和 retry 动作', () => {
|
|
48
|
+
const retry = vi.fn()
|
|
49
|
+
const host = mountCaptcha({ status: 'expired', onRetry: retry, expiredLabel: '已过期' })
|
|
50
|
+
expect(host.textContent).toContain('已过期')
|
|
51
|
+
const button = host.querySelector('button')!
|
|
52
|
+
expect(button.textContent).toBe('Retry')
|
|
53
|
+
button.click()
|
|
54
|
+
expect(retry).toHaveBeenCalledTimes(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('不处理服务端结果,submit 原样返回业务回调的 Promise', async () => {
|
|
58
|
+
const result = { token: 'server-token' }
|
|
59
|
+
const onSubmit = vi.fn(async () => result)
|
|
60
|
+
let submit!: (answer: string) => void | PromiseLike<unknown>
|
|
61
|
+
mountCaptcha({
|
|
62
|
+
challenge: { id: 'c1', expiresAt: Date.now() + 60_000 },
|
|
63
|
+
status: 'ready',
|
|
64
|
+
onSubmit,
|
|
65
|
+
renderChallenge(context) {
|
|
66
|
+
submit = context.submit
|
|
67
|
+
return createElement('div')
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
await expect(submit('answer')).resolves.toBe(result)
|
|
71
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('没有 onSubmit 或处于 loading/verifying 时不会提交', () => {
|
|
75
|
+
const onSubmit = vi.fn()
|
|
76
|
+
const host = mountCaptcha({
|
|
77
|
+
challenge: { id: 'c1', expiresAt: Date.now() + 60_000 },
|
|
78
|
+
status: 'verifying',
|
|
79
|
+
onSubmit,
|
|
80
|
+
renderChallenge({ submit }) {
|
|
81
|
+
const button = createElement('button')
|
|
82
|
+
button.addEventListener('click', () => submit('answer'))
|
|
83
|
+
return button
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
expect(host.textContent).toContain('Verifying captcha…')
|
|
87
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('支持 Signal props,并随外部状态和 challenge 更新 UI', async () => {
|
|
91
|
+
const status = state<CaptchaStatus>('loading')
|
|
92
|
+
const challenge = state<CaptchaChallenge<{ prompt: string }> | null>(null)
|
|
93
|
+
const error = state<unknown>(null)
|
|
94
|
+
const host = mountCaptcha({
|
|
95
|
+
challenge,
|
|
96
|
+
status,
|
|
97
|
+
error,
|
|
98
|
+
onRetry: vi.fn(),
|
|
99
|
+
renderChallenge({ challenge: current }) {
|
|
100
|
+
return createText(current.payload!.prompt)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(host.textContent).toContain('Loading captcha…')
|
|
105
|
+
status.value = 'ready'
|
|
106
|
+
await Promise.resolve()
|
|
107
|
+
expect(host.textContent).toContain('Captcha is not ready.')
|
|
108
|
+
|
|
109
|
+
challenge.value = {
|
|
110
|
+
id: 'c2',
|
|
111
|
+
payload: { prompt: 'Solve this' },
|
|
112
|
+
expiresAt: Date.now() + 60_000
|
|
113
|
+
}
|
|
114
|
+
await Promise.resolve()
|
|
115
|
+
expect(host.textContent).toContain('Solve this')
|
|
116
|
+
|
|
117
|
+
status.value = 'error'
|
|
118
|
+
error.value = new Error('验证失败')
|
|
119
|
+
await Promise.resolve()
|
|
120
|
+
expect(host.textContent).toContain('验证失败')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('支持读取 Signal 的 getter props,并在状态更新后切换操作按钮', async () => {
|
|
124
|
+
const status = state<CaptchaStatus>('loading')
|
|
125
|
+
const retry = vi.fn()
|
|
126
|
+
const host = mountCaptcha({
|
|
127
|
+
get status() { return status.value },
|
|
128
|
+
onRetry: retry,
|
|
129
|
+
onCancel: vi.fn()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(host.querySelector('.vobs-captcha__action')?.textContent).toBe('Cancel')
|
|
133
|
+
status.value = 'expired'
|
|
134
|
+
await Promise.resolve()
|
|
135
|
+
expect(host.querySelector('.vobs-captcha__action')?.textContent).toBe('Retry')
|
|
136
|
+
host.querySelector('button')!.click()
|
|
137
|
+
expect(retry).toHaveBeenCalledTimes(1)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('渲染滑块缺口并在 Pointer 拖动结束后提交轨迹和设备信号', async () => {
|
|
141
|
+
const challenge: SliderCaptchaChallenge = {
|
|
142
|
+
id: 'slider-1',
|
|
143
|
+
payload: {
|
|
144
|
+
image: 'data:image/svg+xml,%3Csvg%20xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
|
|
145
|
+
width: 320,
|
|
146
|
+
height: 160,
|
|
147
|
+
targetX: 180,
|
|
148
|
+
targetY: 50,
|
|
149
|
+
rotation: 18,
|
|
150
|
+
decoyX: 104,
|
|
151
|
+
decoyY: 76,
|
|
152
|
+
decoyRotation: -12,
|
|
153
|
+
pieceWidth: 48,
|
|
154
|
+
pieceHeight: 48,
|
|
155
|
+
shape: 'puzzle'
|
|
156
|
+
},
|
|
157
|
+
expiresAt: Date.now() + 60_000
|
|
158
|
+
}
|
|
159
|
+
const onSubmit = vi.fn()
|
|
160
|
+
const host = document.createElement('div')
|
|
161
|
+
createVobs({
|
|
162
|
+
render: () => SliderCaptcha({ challenge, status: 'ready', onSubmit })
|
|
163
|
+
}).mount(host)
|
|
164
|
+
|
|
165
|
+
const handle = host.querySelector('[role="slider"]') as HTMLElement
|
|
166
|
+
expect(handle).not.toBeNull()
|
|
167
|
+
expect(handle.textContent).toBe('>')
|
|
168
|
+
expect(host.querySelector('.vobs-slider-captcha__track-prompt')?.textContent).toBe('向右拖动滑块完成拼图')
|
|
169
|
+
expect(host.querySelector('.vobs-slider-captcha__target')).not.toBeNull()
|
|
170
|
+
expect(host.querySelector('.vobs-slider-captcha__decoy')).not.toBeNull()
|
|
171
|
+
expect(host.querySelector('.vobs-slider-captcha__piece .vobs-slider-captcha__image')?.getAttribute('style'))
|
|
172
|
+
.toContain('background-position: -180px -50px')
|
|
173
|
+
expect(host.querySelector('.vobs-slider-captcha__target .vobs-slider-captcha__image')?.getAttribute('style'))
|
|
174
|
+
.toContain('background-position: -180px -50px')
|
|
175
|
+
expect(host.querySelector('.vobs-slider-captcha__target')?.getAttribute('style'))
|
|
176
|
+
.toContain('transform: rotate(18deg)')
|
|
177
|
+
expect(host.querySelector('.vobs-slider-captcha__target .vobs-slider-captcha__image')?.getAttribute('style'))
|
|
178
|
+
.toContain('transform: rotate(-18deg)')
|
|
179
|
+
expect(host.querySelector('.vobs-slider-captcha__piece .vobs-slider-captcha__image')?.getAttribute('style'))
|
|
180
|
+
.toContain('transform: rotate(-18deg)')
|
|
181
|
+
expect(host.querySelector('.vobs-slider-captcha__decoy')?.getAttribute('style'))
|
|
182
|
+
.toContain('transform: rotate(-12deg)')
|
|
183
|
+
expect(host.querySelector('.vobs-slider-captcha__decoy .vobs-slider-captcha__image')?.getAttribute('style'))
|
|
184
|
+
.toContain('transform: rotate(12deg)')
|
|
185
|
+
|
|
186
|
+
handle.dispatchEvent(pointerEvent('pointerdown', 10, 40))
|
|
187
|
+
handle.dispatchEvent(pointerEvent('pointermove', 120, 43))
|
|
188
|
+
handle.dispatchEvent(pointerEvent('pointerup', 120, 43))
|
|
189
|
+
|
|
190
|
+
await Promise.resolve()
|
|
191
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
192
|
+
expect(host.querySelector('.vobs-slider-captcha__track')?.getAttribute('data-has-moved')).toBe('true')
|
|
193
|
+
const [result, submittedChallenge] = onSubmit.mock.calls[0]
|
|
194
|
+
expect(result.x).toBeGreaterThan(0)
|
|
195
|
+
expect(result.trail.length).toBeGreaterThanOrEqual(2)
|
|
196
|
+
expect(result.deviceSignals?.sessionId).toBeTypeOf('string')
|
|
197
|
+
expect(submittedChallenge).toBe(challenge)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('拼图块本身也可以拖动并提交结果', async () => {
|
|
201
|
+
const challenge: SliderCaptchaChallenge = {
|
|
202
|
+
id: 'slider-piece-1',
|
|
203
|
+
payload: {
|
|
204
|
+
width: 320,
|
|
205
|
+
height: 160,
|
|
206
|
+
targetX: 180,
|
|
207
|
+
targetY: 50,
|
|
208
|
+
pieceWidth: 48,
|
|
209
|
+
pieceHeight: 48
|
|
210
|
+
},
|
|
211
|
+
expiresAt: Date.now() + 60_000
|
|
212
|
+
}
|
|
213
|
+
const onSubmit = vi.fn()
|
|
214
|
+
const host = document.createElement('div')
|
|
215
|
+
createVobs({
|
|
216
|
+
render: () => SliderCaptcha({ challenge, status: 'ready', onSubmit })
|
|
217
|
+
}).mount(host)
|
|
218
|
+
|
|
219
|
+
const piece = host.querySelector('.vobs-slider-captcha__piece') as HTMLElement
|
|
220
|
+
piece.dispatchEvent(pointerEvent('pointerdown', 10, 40))
|
|
221
|
+
piece.dispatchEvent(pointerEvent('pointermove', 120, 43))
|
|
222
|
+
piece.dispatchEvent(pointerEvent('pointerup', 120, 43))
|
|
223
|
+
|
|
224
|
+
await Promise.resolve()
|
|
225
|
+
expect(piece.getAttribute('style')).toContain('left: 110px')
|
|
226
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('支持由 challenge 提供数量不固定的多个干扰缺口', () => {
|
|
230
|
+
const challenge: SliderCaptchaChallenge = {
|
|
231
|
+
id: 'slider-decoys-1',
|
|
232
|
+
payload: {
|
|
233
|
+
width: 320,
|
|
234
|
+
height: 160,
|
|
235
|
+
targetX: 220,
|
|
236
|
+
targetY: 50,
|
|
237
|
+
decoys: [
|
|
238
|
+
{ x: 104, y: 22, rotation: -12 },
|
|
239
|
+
{ x: 154, y: 96, rotation: 18 },
|
|
240
|
+
{ x: 204, y: 24 }
|
|
241
|
+
],
|
|
242
|
+
pieceWidth: 48,
|
|
243
|
+
pieceHeight: 48
|
|
244
|
+
},
|
|
245
|
+
expiresAt: Date.now() + 60_000
|
|
246
|
+
}
|
|
247
|
+
const host = document.createElement('div')
|
|
248
|
+
createVobs({ render: () => SliderCaptcha({ challenge, status: 'ready' }) }).mount(host)
|
|
249
|
+
|
|
250
|
+
expect(host.querySelectorAll('.vobs-slider-captcha__decoy')).toHaveLength(3)
|
|
251
|
+
expect(host.querySelectorAll('.vobs-slider-captcha__decoy .vobs-slider-captcha__image')).toHaveLength(3)
|
|
252
|
+
expect(host.querySelector('.vobs-slider-captcha__decoy')?.getAttribute('style')).toContain('rotate(-12deg)')
|
|
253
|
+
expect(host.querySelectorAll('.vobs-slider-captcha__decoy')[2]?.querySelector('.vobs-slider-captcha__image')?.getAttribute('style'))
|
|
254
|
+
.not.toContain('transform: rotate(')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('支持左侧 startX 初始拼图位置,并提交图片内的绝对坐标', async () => {
|
|
258
|
+
const challenge: SliderCaptchaChallenge = {
|
|
259
|
+
id: 'slider-start-position-1',
|
|
260
|
+
payload: {
|
|
261
|
+
width: 320,
|
|
262
|
+
height: 160,
|
|
263
|
+
startX: 52,
|
|
264
|
+
targetX: 180,
|
|
265
|
+
targetY: 50,
|
|
266
|
+
pieceWidth: 48,
|
|
267
|
+
pieceHeight: 48
|
|
268
|
+
},
|
|
269
|
+
expiresAt: Date.now() + 60_000
|
|
270
|
+
}
|
|
271
|
+
const onSubmit = vi.fn()
|
|
272
|
+
const host = document.createElement('div')
|
|
273
|
+
createVobs({
|
|
274
|
+
render: () => SliderCaptcha({ challenge, status: 'ready', onSubmit })
|
|
275
|
+
}).mount(host)
|
|
276
|
+
|
|
277
|
+
const handle = host.querySelector('[role="slider"]') as HTMLElement
|
|
278
|
+
expect(host.querySelector('.vobs-slider-captcha__piece')?.getAttribute('style')).toContain('left: 52px')
|
|
279
|
+
handle.dispatchEvent(pointerEvent('pointerdown', 10, 40))
|
|
280
|
+
handle.dispatchEvent(pointerEvent('pointermove', 120, 43))
|
|
281
|
+
handle.dispatchEvent(pointerEvent('pointerup', 120, 43))
|
|
282
|
+
await Promise.resolve()
|
|
283
|
+
|
|
284
|
+
expect(host.querySelector('.vobs-slider-captcha__piece')?.getAttribute('style')).toContain('left: 162px')
|
|
285
|
+
expect(onSubmit.mock.calls[0][0].x).toBe(162)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('校验失败时保持拼图位置,并将错误覆盖在图片底部', async () => {
|
|
289
|
+
const status = state<CaptchaStatus>('ready')
|
|
290
|
+
const error = state<unknown>(null)
|
|
291
|
+
const challenge: SliderCaptchaChallenge = {
|
|
292
|
+
id: 'slider-error-1',
|
|
293
|
+
payload: {
|
|
294
|
+
width: 320,
|
|
295
|
+
height: 160,
|
|
296
|
+
targetX: 180,
|
|
297
|
+
targetY: 50,
|
|
298
|
+
decoyX: 104,
|
|
299
|
+
decoyY: 76,
|
|
300
|
+
pieceWidth: 48,
|
|
301
|
+
pieceHeight: 48
|
|
302
|
+
},
|
|
303
|
+
expiresAt: Date.now() + 60_000
|
|
304
|
+
}
|
|
305
|
+
const host = document.createElement('div')
|
|
306
|
+
createVobs({
|
|
307
|
+
render: () => SliderCaptcha({ challenge, status, error, onSubmit: vi.fn() })
|
|
308
|
+
}).mount(host)
|
|
309
|
+
|
|
310
|
+
const piece = host.querySelector('.vobs-slider-captcha__piece') as HTMLElement
|
|
311
|
+
piece.dispatchEvent(pointerEvent('pointerdown', 10, 40))
|
|
312
|
+
piece.dispatchEvent(pointerEvent('pointermove', 120, 43))
|
|
313
|
+
await Promise.resolve()
|
|
314
|
+
|
|
315
|
+
status.value = 'error'
|
|
316
|
+
error.value = '滑块位置不正确,请重试。'
|
|
317
|
+
await Promise.resolve()
|
|
318
|
+
|
|
319
|
+
expect(host.querySelector('.vobs-slider-captcha__piece')?.getAttribute('style')).toContain('left: 110px')
|
|
320
|
+
expect(host.querySelector('.vobs-slider-captcha__error')?.textContent).toContain('滑块位置不正确')
|
|
321
|
+
expect(host.querySelector('.vobs-captcha__message-host')?.textContent).toBe('')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('刷新时保留画布和面板尺寸,并禁用拖动与刷新操作', async () => {
|
|
325
|
+
const status = state<CaptchaStatus>('ready')
|
|
326
|
+
const challenge: SliderCaptchaChallenge = {
|
|
327
|
+
id: 'slider-loading-1',
|
|
328
|
+
payload: {
|
|
329
|
+
width: 320,
|
|
330
|
+
height: 160,
|
|
331
|
+
targetX: 180,
|
|
332
|
+
targetY: 50,
|
|
333
|
+
pieceWidth: 48,
|
|
334
|
+
pieceHeight: 48
|
|
335
|
+
},
|
|
336
|
+
expiresAt: Date.now() + 60_000
|
|
337
|
+
}
|
|
338
|
+
const host = document.createElement('div')
|
|
339
|
+
createVobs({
|
|
340
|
+
render: () => SliderCaptcha({
|
|
341
|
+
challenge,
|
|
342
|
+
status,
|
|
343
|
+
onSubmit: vi.fn(),
|
|
344
|
+
onRetry: vi.fn(),
|
|
345
|
+
retryLabel: '刷新',
|
|
346
|
+
refreshingLabel: '刷新中',
|
|
347
|
+
loadingLabel: '正在刷新验证码…'
|
|
348
|
+
})
|
|
349
|
+
}).mount(host)
|
|
350
|
+
|
|
351
|
+
status.value = 'loading'
|
|
352
|
+
await Promise.resolve()
|
|
353
|
+
|
|
354
|
+
expect(host.querySelector('.vobs-slider-captcha__visual')).not.toBeNull()
|
|
355
|
+
expect((host.querySelector('[role="slider"]') as HTMLButtonElement).disabled).toBe(true)
|
|
356
|
+
expect((host.querySelector('.vobs-captcha__action') as HTMLButtonElement).disabled).toBe(true)
|
|
357
|
+
expect(host.querySelector('.vobs-captcha__action')?.textContent).toBe('刷新中')
|
|
358
|
+
expect(host.querySelector('.vobs-slider-captcha__notice')?.textContent).toContain('正在刷新验证码')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('验证成功后按可配置延迟隐藏组件', async () => {
|
|
362
|
+
vi.useFakeTimers()
|
|
363
|
+
try {
|
|
364
|
+
const status = state<CaptchaStatus>('ready')
|
|
365
|
+
const challenge: SliderCaptchaChallenge = {
|
|
366
|
+
id: 'slider-success-1',
|
|
367
|
+
payload: {
|
|
368
|
+
width: 320,
|
|
369
|
+
height: 160,
|
|
370
|
+
targetX: 180,
|
|
371
|
+
targetY: 50,
|
|
372
|
+
pieceWidth: 48,
|
|
373
|
+
pieceHeight: 48
|
|
374
|
+
},
|
|
375
|
+
expiresAt: Date.now() + 60_000
|
|
376
|
+
}
|
|
377
|
+
const host = document.createElement('div')
|
|
378
|
+
createVobs({
|
|
379
|
+
render: () => SliderCaptcha({ challenge, status, successDuration: 300, onSubmit: vi.fn() })
|
|
380
|
+
}).mount(host)
|
|
381
|
+
|
|
382
|
+
status.value = 'verified'
|
|
383
|
+
await Promise.resolve()
|
|
384
|
+
expect(host.querySelector('.vobs-slider-captcha')?.className).not.toContain('--dismissed')
|
|
385
|
+
expect(host.querySelector('[role="slider"]')?.textContent).toBe('√')
|
|
386
|
+
expect(host.querySelector('[role="slider"]')?.className).toContain('--verified')
|
|
387
|
+
vi.advanceTimersByTime(300)
|
|
388
|
+
await Promise.resolve()
|
|
389
|
+
expect(host.querySelector('.vobs-slider-captcha')?.className).toContain('--dismissed')
|
|
390
|
+
} finally {
|
|
391
|
+
vi.useRealTimers()
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('计算滑块轨迹的基础行为特征,并标记明显不完整的轨迹', () => {
|
|
396
|
+
const analysis = analyzeSliderTrail([
|
|
397
|
+
{ x: 0, y: 10, t: 0 },
|
|
398
|
+
{ x: 40, y: 11, t: 100 },
|
|
399
|
+
{ x: 80, y: 9, t: 220 },
|
|
400
|
+
{ x: 72, y: 10, t: 360 }
|
|
401
|
+
])
|
|
402
|
+
|
|
403
|
+
expect(analysis.pointCount).toBe(4)
|
|
404
|
+
expect(analysis.duration).toBe(360)
|
|
405
|
+
expect(analysis.distance).toBeGreaterThan(0)
|
|
406
|
+
expect(analysis.directionChanges).toBe(1)
|
|
407
|
+
expect(analysis.verticalTravel).toBe(4)
|
|
408
|
+
expect(analysis.looksHuman).toBe(true)
|
|
409
|
+
expect(analyzeSliderTrail([{ x: 0, y: 0, t: 0 }]).looksHuman).toBe(false)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
function pointerEvent(type: string, clientX: number, clientY: number): Event {
|
|
414
|
+
const event = new Event(type, { bubbles: true })
|
|
415
|
+
Object.assign(event, { clientX, clientY, button: 0, pointerId: 1 })
|
|
416
|
+
return event
|
|
417
|
+
}
|