Skip to content

Commit 8bec22c

Browse files
Align Turnstile client with documented render then execute flow
Call turnstile.ready/render with execution=execute and appearance=interaction-only, then reset/execute(widgetId) on Run. Stop hiding the widget with display:none so interactive challenges can appear. Keep skipping the widget on loopback for CI. Co-authored-by: Max Schmitt <max@schmitt.mx>
1 parent 67604b4 commit 8bec22c

5 files changed

Lines changed: 239 additions & 60 deletions

File tree

‎frontend/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<meta name="theme-color" content="#000000" />
88
<meta name="description" content="An interactive playground for Playwright with various examples available."/>
99
<title>Try Playwright</title>
10+
<link rel="preconnect" href="https://challenges.cloudflare.com" />
1011
<script defer src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
1112
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-34156117-11"></script>
1213
<script>

‎frontend/src/components/App/index.module.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
float: right
1111
}
1212

13+
.turnstile {
14+
display: inline-block;
15+
vertical-align: middle;
16+
margin-right: 10px;
17+
}
18+
1319
.codeHeaderButtons > button {
1420
margin-left: 10px
1521
}

‎frontend/src/components/App/index.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useState, useContext, useRef } from 'react';
1+
import { useState, useContext, useEffect, useRef } from 'react';
22
import { Col, Grid, IconButton, Loader, Panel, CustomProvider } from 'rsuite'
33
import PlayIcon from '@rsuite/icons/PlayOutline';
44

55
import { ExecutionResponse, runCode, trackEvent } from '../../utils'
6-
import { waitForTurnstileToken } from '../../turnstile'
6+
import { removeTurnstileWidget, waitForTurnstileToken, type TurnstileApi } from '../../turnstile'
77
import RightPanel from '../RightPanel'
88
import Header from '../Header'
99
import Editor from '../Editor'
@@ -22,6 +22,13 @@ const App: React.FunctionComponent = () => {
2222
const handleExecutionRef = useRef<() => Promise<void>>(undefined)
2323
const [darkMode] = useDarkMode()
2424
const turnstileRef = useRef<HTMLDivElement>(null)
25+
const turnstileWidgetIdRef = useRef<string | null>(null)
26+
27+
useEffect(() => {
28+
return () => {
29+
removeTurnstileWidget((window as unknown as { turnstile?: TurnstileApi }).turnstile, turnstileWidgetIdRef)
30+
}
31+
}, [])
2532

2633
const handleExecution = async (): Promise<void> => {
2734
setLoading(true)
@@ -31,9 +38,10 @@ const App: React.FunctionComponent = () => {
3138
const started = Date.now()
3239
try {
3340
const turnstileToken = await waitForTurnstileToken({
34-
turnstile: (window as any).turnstile,
41+
turnstile: (window as unknown as { turnstile?: TurnstileApi }).turnstile,
3542
container: turnstileRef.current,
3643
sitekey: VITE_TURNSTILE_SITEKEY,
44+
widgetIdRef: turnstileWidgetIdRef,
3745
})
3846
const codeToRun = getCode()
3947
console.info(`[try-playwright] run: posting ${JSON.stringify({
@@ -67,7 +75,6 @@ const App: React.FunctionComponent = () => {
6775

6876
return (
6977
<CustomProvider theme={darkMode ? 'dark' : 'light'}>
70-
<div ref={turnstileRef} style={{ display: 'none' }} />
7178
<Header />
7279
<Grid fluid className={styles.grid}>
7380
<Col span={{ xs: 24, md: 12 }}>
@@ -79,6 +86,7 @@ const App: React.FunctionComponent = () => {
7986
<>
8087
Editor
8188
<div className={styles.codeHeaderButtons}>
89+
<div ref={turnstileRef} className={styles.turnstile} />
8290
<CodeLanguageSelector codeLanguage={codeLanguage} onLanguageChange={onLanguageChange} />
8391
<IconButton onClick={handleExecution} icon={<PlayIcon />}>
8492
Run

‎frontend/src/turnstile.spec.ts‎

Lines changed: 105 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
import { test, expect } from '@playwright/experimental-ct-react'
2-
import { shouldSkipTurnstile, waitForTurnstileToken } from './turnstile'
2+
import { shouldSkipTurnstile, waitForTurnstileToken, type TurnstileApi, type TurnstileRenderOptions } from './turnstile'
33

44
const container = {} as HTMLElement
55

6+
function mockTurnstile(overrides: Partial<TurnstileApi> = {}): { api: TurnstileApi; options: TurnstileRenderOptions[] } {
7+
const rendered: TurnstileRenderOptions[] = []
8+
const api: TurnstileApi = {
9+
ready: (callback) => callback(),
10+
render: (_container, options) => {
11+
rendered.push(options)
12+
return 'widget-1'
13+
},
14+
execute: () => {
15+
rendered.at(-1)?.callback?.('tok')
16+
},
17+
reset: () => undefined,
18+
remove: () => undefined,
19+
...overrides,
20+
}
21+
return { api, options: rendered }
22+
}
23+
624
test('skips Turnstile on CI-style loopback hosts', () => {
725
expect(shouldSkipTurnstile('127.0.0.1')).toBe(true)
826
expect(shouldSkipTurnstile('localhost')).toBe(true)
@@ -19,41 +37,93 @@ test('returns empty when the Turnstile API is missing', async () => {
1937
expect(token).toBe('')
2038
})
2139

22-
test('does not hang when execute throws', async () => {
23-
const token = await waitForTurnstileToken({
24-
turnstile: {
25-
execute: () => {
26-
throw new Error('no widget')
27-
},
40+
test('does not hang when render throws', async () => {
41+
const { api } = mockTurnstile({
42+
render: () => {
43+
throw new Error('no widget')
2844
},
45+
})
46+
const token = await waitForTurnstileToken({
47+
turnstile: api,
2948
container,
3049
sitekey: 'sitekey',
3150
hostname: 'try.playwright.tech',
3251
})
3352
expect(token).toBe('')
3453
})
3554

36-
test('resolves the success token', async () => {
55+
test('renders with execution=execute then execute(widgetId)', async () => {
56+
const { api, options } = mockTurnstile()
57+
let executedWith: unknown
58+
let resetWith: unknown
59+
api.execute = (target) => {
60+
executedWith = target
61+
options.at(-1)?.callback?.('tok')
62+
}
63+
api.reset = (target) => {
64+
resetWith = target
65+
}
66+
67+
const widgetIdRef = { current: null as string | null }
3768
const token = await waitForTurnstileToken({
38-
turnstile: {
39-
execute: (_el, options) => {
40-
;(options.callback as (value: string) => void)('tok')
41-
},
42-
},
69+
turnstile: api,
4370
container,
4471
sitekey: 'sitekey',
4572
hostname: 'try.playwright.tech',
73+
widgetIdRef,
4674
})
75+
4776
expect(token).toBe('tok')
77+
expect(widgetIdRef.current).toBe('widget-1')
78+
expect(executedWith).toBe('widget-1')
79+
expect(resetWith).toBe('widget-1')
80+
expect(options[0]).toMatchObject({
81+
sitekey: 'sitekey',
82+
execution: 'execute',
83+
appearance: 'interaction-only',
84+
})
85+
})
86+
87+
test('reuses the rendered widget on a later execute', async () => {
88+
let renderCount = 0
89+
const { api, options } = mockTurnstile({
90+
render: (_container, renderOptions) => {
91+
renderCount += 1
92+
options.push(renderOptions)
93+
return 'widget-1'
94+
},
95+
})
96+
api.execute = () => {
97+
options.at(-1)?.callback?.(`tok-${renderCount}`)
98+
}
99+
const widgetIdRef = { current: null as string | null }
100+
101+
await waitForTurnstileToken({
102+
turnstile: api,
103+
container,
104+
sitekey: 'sitekey',
105+
hostname: 'try.playwright.tech',
106+
widgetIdRef,
107+
})
108+
const token = await waitForTurnstileToken({
109+
turnstile: api,
110+
container,
111+
sitekey: 'sitekey',
112+
hostname: 'try.playwright.tech',
113+
widgetIdRef,
114+
})
115+
116+
expect(renderCount).toBe(1)
117+
expect(token).toBe('tok-1')
48118
})
49119

50120
test('resolves empty on error-callback', async () => {
121+
const { api, options } = mockTurnstile()
122+
api.execute = () => {
123+
options.at(-1)?.['error-callback']?.()
124+
}
51125
const token = await waitForTurnstileToken({
52-
turnstile: {
53-
execute: (_el, options) => {
54-
;(options['error-callback'] as () => void)()
55-
},
56-
},
126+
turnstile: api,
57127
container,
58128
sitekey: 'sitekey',
59129
hostname: 'try.playwright.tech',
@@ -62,12 +132,11 @@ test('resolves empty on error-callback', async () => {
62132
})
63133

64134
test('times out when neither callback fires (interactive / bot challenge)', async () => {
135+
const { api } = mockTurnstile({
136+
execute: () => undefined,
137+
})
65138
const token = await waitForTurnstileToken({
66-
turnstile: {
67-
execute: () => {
68-
// never calls back — same as Cloudflare dummy interactive key in Playwright
69-
},
70-
},
139+
turnstile: api,
71140
container,
72141
sitekey: 'sitekey',
73142
hostname: 'try.playwright.tech',
@@ -76,19 +145,26 @@ test('times out when neither callback fires (interactive / bot challenge)', asyn
76145
expect(token).toBe('')
77146
})
78147

79-
test('skips execute entirely on 127.0.0.1', async () => {
148+
test('skips render and execute entirely on 127.0.0.1', async () => {
149+
let rendered = false
80150
let executed = false
81-
const token = await waitForTurnstileToken({
82-
turnstile: {
83-
execute: () => {
84-
executed = true
85-
},
151+
const { api } = mockTurnstile({
152+
render: () => {
153+
rendered = true
154+
return 'widget-1'
155+
},
156+
execute: () => {
157+
executed = true
86158
},
159+
})
160+
const token = await waitForTurnstileToken({
161+
turnstile: api,
87162
container,
88163
sitekey: 'sitekey',
89164
hostname: '127.0.0.1',
90165
timeoutMs: 50,
91166
})
167+
expect(rendered).toBe(false)
92168
expect(executed).toBe(false)
93169
expect(token).toBe('')
94170
})

0 commit comments

Comments
 (0)