Skip to content

Commit 436f91c

Browse files
Stop Turnstile from hanging Run on localhost and missing callbacks
CI hits the app at 127.0.0.1 with the production sitekey. A live Playwright repro against Cloudflare showed: - Dummy always-pass keys complete via execute() in ~1.6s - Production sitekey on 127.0.0.1 returns error 110200 (domain not authorized) and would POST - Interactive dummy key never fires callback or error-callback (30s+) - Missing window.turnstile throws; the old Promise never settled Skip the widget on loopback (same host CI uses), time out execute(), and catch a missing API so /run still leaves the browser. Co-authored-by: Max Schmitt <max@schmitt.mx>
1 parent 0e2756a commit 436f91c

3 files changed

Lines changed: 170 additions & 24 deletions

File tree

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

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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'
67
import RightPanel from '../RightPanel'
78
import Header from '../Header'
89
import Editor from '../Editor'
@@ -28,34 +29,15 @@ const App: React.FunctionComponent = () => {
2829

2930
trackEvent()
3031
const started = Date.now()
31-
console.info('[try-playwright] run: before-turnstile', {
32-
hasExecute: typeof (window as any).turnstile?.execute,
33-
elapsedMs: 0,
32+
const turnstileToken = await waitForTurnstileToken({
33+
turnstile: (window as any).turnstile,
34+
container: turnstileRef.current,
35+
sitekey: VITE_TURNSTILE_SITEKEY,
3436
})
35-
const turnstileToken = await new Promise<string>((resolve) => {
36-
try {
37-
(window as any).turnstile.reset();
38-
} catch (error) {}
39-
(window as any).turnstile.execute(turnstileRef.current, {
40-
sitekey: VITE_TURNSTILE_SITEKEY,
41-
callback: (token: string) => {
42-
console.info('[try-playwright] run: turnstile-callback', {
43-
elapsedMs: Date.now() - started,
44-
tokenLength: token ? token.length : 0,
45-
})
46-
resolve(token)
47-
},
48-
'error-callback': () => {
49-
console.info('[try-playwright] run: turnstile-error-callback', {
50-
elapsedMs: Date.now() - started,
51-
})
52-
resolve('')
53-
},
54-
});
55-
});
5637
const codeToRun = getCode()
5738
console.info('[try-playwright] run: posting', {
5839
elapsedMs: Date.now() - started,
40+
tokenLength: turnstileToken ? turnstileToken.length : 0,
5941
codeLength: codeToRun.length,
6042
})
6143
setResponse(await runCode(codeToRun, codeLanguage, turnstileToken))

‎frontend/src/turnstile.spec.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { test, expect } from '@playwright/experimental-ct-react'
2+
import { shouldSkipTurnstile, waitForTurnstileToken } from './turnstile'
3+
4+
const container = {} as HTMLElement
5+
6+
test('skips Turnstile on CI-style loopback hosts', () => {
7+
expect(shouldSkipTurnstile('127.0.0.1')).toBe(true)
8+
expect(shouldSkipTurnstile('localhost')).toBe(true)
9+
expect(shouldSkipTurnstile('try.playwright.tech')).toBe(false)
10+
})
11+
12+
test('returns empty when the Turnstile API is missing', async () => {
13+
const token = await waitForTurnstileToken({
14+
turnstile: null,
15+
container,
16+
sitekey: 'sitekey',
17+
hostname: 'try.playwright.tech',
18+
})
19+
expect(token).toBe('')
20+
})
21+
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+
},
28+
},
29+
container,
30+
sitekey: 'sitekey',
31+
hostname: 'try.playwright.tech',
32+
})
33+
expect(token).toBe('')
34+
})
35+
36+
test('resolves the success token', async () => {
37+
const token = await waitForTurnstileToken({
38+
turnstile: {
39+
execute: (_el, options) => {
40+
;(options.callback as (value: string) => void)('tok')
41+
},
42+
},
43+
container,
44+
sitekey: 'sitekey',
45+
hostname: 'try.playwright.tech',
46+
})
47+
expect(token).toBe('tok')
48+
})
49+
50+
test('resolves empty on error-callback', async () => {
51+
const token = await waitForTurnstileToken({
52+
turnstile: {
53+
execute: (_el, options) => {
54+
;(options['error-callback'] as () => void)()
55+
},
56+
},
57+
container,
58+
sitekey: 'sitekey',
59+
hostname: 'try.playwright.tech',
60+
})
61+
expect(token).toBe('')
62+
})
63+
64+
test('times out when neither callback fires (interactive / bot challenge)', async () => {
65+
const token = await waitForTurnstileToken({
66+
turnstile: {
67+
execute: () => {
68+
// never calls back — same as Cloudflare dummy interactive key in Playwright
69+
},
70+
},
71+
container,
72+
sitekey: 'sitekey',
73+
hostname: 'try.playwright.tech',
74+
timeoutMs: 50,
75+
})
76+
expect(token).toBe('')
77+
})
78+
79+
test('skips execute entirely on 127.0.0.1', async () => {
80+
let executed = false
81+
const token = await waitForTurnstileToken({
82+
turnstile: {
83+
execute: () => {
84+
executed = true
85+
},
86+
},
87+
container,
88+
sitekey: 'sitekey',
89+
hostname: '127.0.0.1',
90+
timeoutMs: 50,
91+
})
92+
expect(executed).toBe(false)
93+
expect(token).toBe('')
94+
})

‎frontend/src/turnstile.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
type TurnstileApi = {
2+
reset?: () => void
3+
execute: (container: HTMLElement, options: Record<string, unknown>) => void
4+
}
5+
6+
export type WaitForTurnstileTokenOptions = {
7+
turnstile?: TurnstileApi | null
8+
container: HTMLElement | null
9+
sitekey: string
10+
timeoutMs?: number
11+
hostname?: string
12+
}
13+
14+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
15+
16+
export function shouldSkipTurnstile(hostname: string): boolean {
17+
return LOCAL_HOSTS.has(hostname)
18+
}
19+
20+
export async function waitForTurnstileToken(options: WaitForTurnstileTokenOptions): Promise<string> {
21+
const hostname = options.hostname ?? (typeof window !== 'undefined' ? window.location.hostname : '')
22+
if (shouldSkipTurnstile(hostname)) {
23+
return ''
24+
}
25+
if (!options.turnstile || typeof options.turnstile.execute !== 'function' || !options.container) {
26+
return ''
27+
}
28+
29+
const timeoutMs = options.timeoutMs ?? 8_000
30+
const turnstile = options.turnstile
31+
const container = options.container
32+
33+
return await new Promise<string>((resolve) => {
34+
let settled = false
35+
const done = (token: string) => {
36+
if (settled) {
37+
return
38+
}
39+
settled = true
40+
resolve(token)
41+
}
42+
43+
const timer = setTimeout(() => done(''), timeoutMs)
44+
try {
45+
try {
46+
turnstile.reset?.()
47+
} catch {
48+
// reset() throws when no widget has been rendered yet
49+
}
50+
turnstile.execute(container, {
51+
sitekey: options.sitekey,
52+
callback: (token: string) => {
53+
clearTimeout(timer)
54+
done(token || '')
55+
},
56+
'error-callback': () => {
57+
clearTimeout(timer)
58+
done('')
59+
},
60+
'timeout-callback': () => {
61+
clearTimeout(timer)
62+
done('')
63+
},
64+
})
65+
} catch {
66+
clearTimeout(timer)
67+
done('')
68+
}
69+
})
70+
}

0 commit comments

Comments
 (0)