From b535839408ebb8e6b188f6513112b38aa3ed0dd1 Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 12 Mar 2026 19:04:58 -0400 Subject: [PATCH 1/5] Normalize WebView config handling and expand regression coverage - serialize generated WebView config as data and normalize the legacy checkbox size alias\n- forward phone MFA props through ConfirmHcaptcha and align size docs with runtime behavior\n- expand unit coverage and add an Android emulator e2e harness for dark theme rendering --- .gitignore | 1 + Hcaptcha.js | 102 ++- README.md | 2 +- __e2e__/rn-android-dark-theme.e2e.mjs | 483 ++++++++++++ __mocks__/react-native-webview.js | 15 + __tests__/ConfirmHcaptcha.test.js | 219 +++++- __tests__/Hcaptcha.test.js | 639 ++++++++++++--- .../ConfirmHcaptcha.test.js.snap | 380 +-------- __tests__/__snapshots__/Hcaptcha.test.js.snap | 744 +----------------- index.js | 8 + package-lock.json | 11 + package.json | 2 + 12 files changed, 1347 insertions(+), 1259 deletions(-) create mode 100644 __e2e__/rn-android-dark-theme.e2e.mjs diff --git a/.gitignore b/.gitignore index f78c883..f5511bc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ yarn.lock # Reassure performance testing files .reassure/ +output/ diff --git a/Hcaptcha.js b/Hcaptcha.js index 1e1cbfa..d480fca 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -20,6 +20,42 @@ const patchPostMessageJsCode = `(${String(function () { window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; +const serializeForInlineScript = (value) => + JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + +const normalizeTheme = (value) => { + if (value == null) { + return null; + } + + if (typeof value === 'object') { + return value; + } + + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (_) { + return value; + } + } + + return value; +}; + +const normalizeSize = (value) => { + if (value == null) { + return 'invisible'; + } + + return value === 'checkbox' ? 'normal' : value; +}; + const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation) => { var url = `${jsSrc || 'https://hcaptcha.com/1/api.js'}?render=explicit&onload=onloadCallback`; @@ -43,7 +79,7 @@ const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, * * @param {*} onMessage: callback after receiving response, error, or when user cancels * @param {*} siteKey: your hCaptcha sitekey - * @param {string} size: The size of the checkbox, can be 'invisible', 'compact' or 'checkbox', Default: 'invisible' + * @param {string} size: The size of the widget, can be 'invisible', 'compact' or 'normal'. 'checkbox' is kept as a legacy alias for 'normal'. Default: 'invisible' * @param {*} style: custom style * @param {*} url: base url * @param {*} languageCode: can be found at https://docs.hcaptcha.com/languages @@ -90,26 +126,15 @@ const Hcaptcha = ({ phonePrefix, phoneNumber, }) => { - const apiUrl = buildHcaptchaApiUrl(jsSrc, siteKey, languageCode, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation); const tokenTimeout = 120000; const loadingTimeout = 15000; const [isLoading, setIsLoading] = useState(true); - - if (theme && typeof theme === 'string') { - try { - JSON.parse(theme); - } catch (_) { - theme = `"${theme}"`; - } - } - - if (theme && typeof theme === 'object') { - theme = `${JSON.stringify(theme)}`; - } - - if (rqdata && typeof rqdata === 'string') { - rqdata = `"${rqdata}"`; - } + const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]); + const normalizedSize = useMemo(() => normalizeSize(size), [size]); + const apiUrl = useMemo( + () => buildHcaptchaApiUrl(jsSrc, siteKey, languageCode, normalizedTheme, host, sentry, endpoint, assethost, imghost, reportapi, orientation), + [jsSrc, siteKey, languageCode, normalizedTheme, host, sentry, endpoint, assethost, imghost, reportapi, orientation] + ); const debugInfo = useMemo( () => { @@ -128,6 +153,21 @@ const Hcaptcha = ({ [debug] ); + const serializedWebViewConfig = useMemo( + () => serializeForInlineScript({ + apiUrl, + backgroundColor: backgroundColor ?? '', + debugInfo, + phoneNumber: phoneNumber ?? null, + phonePrefix: phonePrefix ?? null, + rqdata: rqdata ?? null, + siteKey: siteKey || '', + size: normalizedSize, + theme: normalizedTheme, + }), + [apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, phoneNumber, phonePrefix, rqdata, siteKey] + ); + const generateTheWebViewContent = useMemo( () => ` @@ -137,14 +177,21 @@ const Hcaptcha = ({ -
`, - [debugInfo, apiUrl, siteKey, theme, size, backgroundColor, rqdata, phonePrefix, phoneNumber] + [serializedWebViewConfig] ); useEffect(() => { @@ -261,7 +309,7 @@ const Hcaptcha = ({ data: 'sms-open-failed', description: err.message, }, - success: false + success: false, }); }); return false; diff --git a/README.md b/README.md index d58d62c..41357a3 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ The SDK supports phone prefix and phone number parameters for MFA (Multi-Factor | **Name** | **Type** | **Description** | |:---|:---|:---| | siteKey _(required)_ | string | The hCaptcha siteKey | -| size | string | The size of the checkbox, can be 'invisible', 'compact' or 'checkbox', Default: 'invisible' | +| size | string | The size of the widget, can be 'invisible', 'compact' or 'normal'. `checkbox` is also accepted as a legacy alias for `normal`. Default: 'invisible' | | onMessage | Function (see [here](https://github.com/react-native-webview/react-native-webview/blob/master/src/WebViewTypes.ts#L299)) | The callback function that runs after receiving a response, error, or when user cancels. | | languageCode | string | Default language for hCaptcha; overrides phone defaults. A complete list of supported languages and their codes can be found [here](https://docs.hcaptcha.com/languages/) | | showLoading | boolean | Whether to show a loading indicator while the hCaptcha web content loads | diff --git a/__e2e__/rn-android-dark-theme.e2e.mjs b/__e2e__/rn-android-dark-theme.e2e.mjs new file mode 100644 index 0000000..04a5be2 --- /dev/null +++ b/__e2e__/rn-android-dark-theme.e2e.mjs @@ -0,0 +1,483 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; + +import { PNG } from 'pngjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const outputDir = path.join(repoRoot, 'output', 'android-e2e'); +const fixtureAppDir = process.env.HCAPTCHA_E2E_APP_DIR || path.join(os.tmpdir(), 'react-native-hcaptcha-android-e2e'); +const fixtureAppName = 'RNHcaptchaE2E'; +const fixturePackageName = 'com.hcaptcha.rne2e'; +const metroPort = process.env.RCT_METRO_PORT || '8088'; +const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || path.join(os.homedir(), 'Library', 'Android', 'sdk'); +const adbPath = path.join(sdkRoot, 'platform-tools', 'adb'); +const emulatorPath = path.join(sdkRoot, 'emulator', 'emulator'); +const rnVersion = JSON.parse(await fs.readFile(path.join(repoRoot, 'node_modules', 'react-native', 'package.json'), 'utf8')).version; +const cliVersion = process.env.HCAPTCHA_E2E_CLI_VERSION || '20.1.2'; +const preferredAvd = process.env.HCAPTCHA_E2E_AVD || 'Medium_Phone'; +const screenshotPath = path.join(outputDir, 'android-dark-theme.png'); +const metroLogPath = path.join(outputDir, 'metro.log'); +const appLogPath = path.join(outputDir, 'run-android.log'); + +const fixtureAppSource = `import React from 'react'; +import { SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import Hcaptcha from '@hcaptcha/react-native-hcaptcha/Hcaptcha'; + +const siteKey = '10000000-ffff-ffff-ffff-000000000001'; +const baseUrl = 'https://hcaptcha.com'; + +const WidgetCard = ({ label, theme }) => ( + + {label} + + {}} + /> + + +); + +const App = () => ( + + RN hCaptcha theme e2e + + + +); + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: 'center', + backgroundColor: '#eef2ff', + paddingHorizontal: 16, + gap: 24, + }, + title: { + textAlign: 'center', + fontSize: 24, + fontWeight: '700', + color: '#0f172a', + }, + card: { + alignItems: 'center', + gap: 10, + }, + label: { + fontSize: 18, + fontWeight: '600', + color: '#1e293b', + }, + widgetFrame: { + width: '100%', + maxWidth: 360, + height: 118, + overflow: 'hidden', + borderRadius: 18, + borderWidth: 1, + borderColor: '#cbd5e1', + backgroundColor: '#ffffff', + }, + widget: { + flex: 1, + height: '100%', + }, +}); + +export default App; +`; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const ensureFile = async (targetPath) => { + try { + await fs.access(targetPath); + } catch (_) { + throw new Error(`Required tool not found: ${targetPath}`); + } +}; + +const run = (command, args, options = {}) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: options.stdio || 'inherit', + shell: false, + }); + + const stdoutChunks = []; + const stderrChunks = []; + + if (child.stdout) { + child.stdout.on('data', (chunk) => { + stdoutChunks.push(Buffer.from(chunk)); + }); + } + + if (child.stderr) { + child.stderr.on('data', (chunk) => { + stderrChunks.push(Buffer.from(chunk)); + }); + } + + child.on('error', reject); + child.on('close', (code) => { + const stdoutBuffer = Buffer.concat(stdoutChunks); + const stderrBuffer = Buffer.concat(stderrChunks); + const stdout = options.binary ? stdoutBuffer : stdoutBuffer.toString('utf8'); + const stderr = options.binary ? stderrBuffer : stderrBuffer.toString('utf8'); + + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`${command} ${args.join(' ')} failed with code ${code}\n${stdout}\n${stderr}`)); + } + }); + }); + +const startBackgroundProcess = (command, args, logFilePath, options = {}) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + + const logChunks = []; + const appendLog = (chunk) => { + const text = chunk.toString(); + logChunks.push(text); + }; + + child.stdout.on('data', appendLog); + child.stderr.on('data', appendLog); + + const flushLog = async () => { + await fs.writeFile(logFilePath, logChunks.join(''), 'utf8'); + }; + + const stop = async () => { + if (!child.killed) { + child.kill('SIGTERM'); + await sleep(1000); + if (!child.killed) { + child.kill('SIGKILL'); + } + } + await flushLog(); + }; + + return { child, flushLog, stop }; +}; + +const waitForPort = async (port, timeoutMs) => { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port: Number(port) }); + socket.once('connect', () => { + socket.end(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); + + if (connected) { + return; + } + + await sleep(1000); + } + + throw new Error(`Timed out waiting for localhost:${port}`); +}; + +const ensureFixtureApp = async () => { + const packageJsonPath = path.join(fixtureAppDir, 'package.json'); + + let needsInit = false; + try { + const parsed = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + if (parsed.name !== fixtureAppName) { + needsInit = true; + } + } catch (_) { + needsInit = true; + } + + if (needsInit) { + await fs.rm(fixtureAppDir, { recursive: true, force: true }); + await run('npx', [ + '--yes', + `@react-native-community/cli@${cliVersion}`, + 'init', + fixtureAppName, + '--directory', + fixtureAppDir, + '--pm', + 'npm', + '--version', + rnVersion, + '--skip-git-init', + '--install-pods', + 'false', + '--package-name', + fixturePackageName, + ], { cwd: repoRoot }); + } + + await fs.writeFile(path.join(fixtureAppDir, 'App.js'), fixtureAppSource, 'utf8'); + await fs.rm(path.join(fixtureAppDir, 'App.tsx'), { force: true }); + await run('npm', [ + 'install', + `@hcaptcha/react-native-hcaptcha@file:${repoRoot}`, + 'react-native-modal', + 'react-native-webview', + ], { cwd: fixtureAppDir }); +}; + +const adb = async (...args) => run(adbPath, args, { cwd: repoRoot, stdio: 'pipe' }); + +const getBootedEmulatorId = async () => { + const { stdout } = await adb('devices'); + const emulatorLine = stdout + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('emulator-') && line.endsWith('\tdevice')); + + return emulatorLine ? emulatorLine.split('\t')[0] : null; +}; + +const getAvailableAvd = async () => { + const { stdout } = await run(emulatorPath, ['-list-avds'], { cwd: repoRoot, stdio: 'pipe' }); + const avds = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (avds.includes(preferredAvd)) { + return preferredAvd; + } + + if (avds.length === 0) { + throw new Error('No Android Virtual Device found. Create one before running the RN E2E test.'); + } + + return avds[0]; +}; + +const waitForBootCompleted = async (deviceId) => { + const started = Date.now(); + + while (Date.now() - started < 240000) { + const { stdout } = await adb('-s', deviceId, 'shell', 'getprop', 'sys.boot_completed'); + if (stdout.trim() === '1') { + return; + } + await sleep(2000); + } + + throw new Error(`Timed out waiting for Android emulator ${deviceId} to boot.`); +}; + +const ensureEmulator = async () => { + const runningId = await getBootedEmulatorId(); + if (runningId) { + return { deviceId: runningId, stop: async () => {} }; + } + + const avd = await getAvailableAvd(); + const args = ['-avd', avd, '-netfast', '-no-snapshot']; + + if (process.env.CI) { + args.push('-no-window', '-no-audio', '-gpu', 'swiftshader_indirect', '-no-boot-anim'); + } + + const emulator = startBackgroundProcess(emulatorPath, args, path.join(outputDir, 'emulator.log')); + + let deviceId = null; + const started = Date.now(); + while (Date.now() - started < 120000 && !deviceId) { + deviceId = await getBootedEmulatorId(); + if (!deviceId) { + await sleep(2000); + } + } + + if (!deviceId) { + await emulator.stop(); + throw new Error('Android emulator did not appear in adb devices.'); + } + + await waitForBootCompleted(deviceId); + await adb('-s', deviceId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'); + await adb('-s', deviceId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'); + await adb('-s', deviceId, 'shell', 'settings', 'put', 'global', 'animator_duration_scale', '0'); + + return { + deviceId, + stop: async () => { + await adb('-s', deviceId, 'emu', 'kill').catch(() => {}); + await emulator.stop(); + }, + }; +}; + +const crop = (png, leftRatio, topRatio, rightRatio, bottomRatio) => { + const left = Math.floor(png.width * leftRatio); + const top = Math.floor(png.height * topRatio); + const width = Math.floor(png.width * rightRatio) - left; + const height = Math.floor(png.height * bottomRatio) - top; + const cropped = new PNG({ width, height }); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const sourceIndex = ((top + y) * png.width + (left + x)) * 4; + const targetIndex = (y * width + x) * 4; + cropped.data[targetIndex] = png.data[sourceIndex]; + cropped.data[targetIndex + 1] = png.data[sourceIndex + 1]; + cropped.data[targetIndex + 2] = png.data[sourceIndex + 2]; + cropped.data[targetIndex + 3] = png.data[sourceIndex + 3]; + } + } + + return cropped; +}; + +const getAverageLuminance = (png) => { + let total = 0; + let count = 0; + + for (let index = 0; index < png.data.length; index += 4) { + if (png.data[index + 3] === 0) { + continue; + } + + total += 0.2126 * png.data[index] + 0.7152 * png.data[index + 1] + 0.0722 * png.data[index + 2]; + count += 1; + } + + return total / count; +}; + +const captureScreenshot = async (deviceId) => { + const { stdout } = await run(adbPath, ['-s', deviceId, 'exec-out', 'screencap', '-p'], { + cwd: repoRoot, + stdio: 'pipe', + binary: true, + }); + const buffer = stdout; + await fs.writeFile(screenshotPath, buffer); + return PNG.sync.read(buffer); +}; + +const waitForThemeDifference = async (deviceId) => { + for (let attempt = 1; attempt <= 12; attempt += 1) { + const screenshot = await captureScreenshot(deviceId); + const lightCrop = crop(screenshot, 0.12, 0.33, 0.88, 0.40); + const darkCrop = crop(screenshot, 0.12, 0.62, 0.88, 0.69); + const lightLuminance = getAverageLuminance(lightCrop); + const darkLuminance = getAverageLuminance(darkCrop); + + if (darkLuminance < lightLuminance - 25) { + await fs.writeFile(path.join(outputDir, 'light-crop.png'), PNG.sync.write(lightCrop)); + await fs.writeFile(path.join(outputDir, 'dark-crop.png'), PNG.sync.write(darkCrop)); + + return { lightLuminance, darkLuminance }; + } + + await sleep(5000); + } + + throw new Error('Timed out waiting for the dark widget to render darker than the light widget.'); +}; + +const runAndroidApp = async (deviceId) => { + const env = { + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + RCT_METRO_PORT: metroPort, + REACT_NATIVE_PACKAGER_HOSTNAME: '127.0.0.1', + }; + + return run('npx', [ + 'react-native', + 'run-android', + '--deviceId', + deviceId, + '--port', + metroPort, + '--no-packager', + ], { + cwd: fixtureAppDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); +}; + +const main = async () => { + await fs.mkdir(outputDir, { recursive: true }); + await ensureFile(adbPath); + await ensureFile(emulatorPath); + await ensureFixtureApp(); + + const env = { + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + RCT_METRO_PORT: metroPort, + }; + + const metro = startBackgroundProcess('npx', ['react-native', 'start', '--port', metroPort], metroLogPath, { + cwd: fixtureAppDir, + env, + }); + + let emulatorStop = async () => {}; + + try { + await waitForPort(metroPort, 120000); + + const emulator = await ensureEmulator(); + emulatorStop = emulator.stop; + + const runResult = await runAndroidApp(emulator.deviceId); + await fs.writeFile(appLogPath, `${runResult.stdout}\n${runResult.stderr}`, 'utf8'); + + const metrics = await waitForThemeDifference(emulator.deviceId); + console.log(JSON.stringify({ + artifacts: { + screenshot: screenshotPath, + lightCrop: path.join(outputDir, 'light-crop.png'), + darkCrop: path.join(outputDir, 'dark-crop.png'), + metroLog: metroLogPath, + runAndroidLog: appLogPath, + }, + metrics, + }, null, 2)); + } finally { + await metro.stop(); + await emulatorStop(); + } +}; + +main().catch(async (error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/__mocks__/react-native-webview.js b/__mocks__/react-native-webview.js index 8451896..5c7671f 100644 --- a/__mocks__/react-native-webview.js +++ b/__mocks__/react-native-webview.js @@ -2,13 +2,28 @@ import React from 'react'; let messageDataToSend = null; +let lastInjectJavaScriptMock = null; export const setWebViewMessageData = (data) => { messageDataToSend = data; }; +export const getLastInjectJavaScriptMock = () => lastInjectJavaScriptMock; + +export const resetWebViewMockState = () => { + messageDataToSend = null; + lastInjectJavaScriptMock = null; +}; + const WebView = React.forwardRef((props, ref) => { const { onMessage } = props; + const injectJavaScript = React.useMemo(() => jest.fn(), []); + + lastInjectJavaScriptMock = injectJavaScript; + + React.useImperativeHandle(ref, () => ({ + injectJavaScript, + }), [injectJavaScript]); React.useEffect(() => { if (messageDataToSend && onMessage) { diff --git a/__tests__/ConfirmHcaptcha.test.js b/__tests__/ConfirmHcaptcha.test.js index 6dcea5e..5df1a1e 100644 --- a/__tests__/ConfirmHcaptcha.test.js +++ b/__tests__/ConfirmHcaptcha.test.js @@ -1,8 +1,20 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; +import { SafeAreaView } from 'react-native'; + +import Hcaptcha from '../Hcaptcha'; import ConfirmHcaptcha from '../index'; -describe('ConfirmHcaptcha snapshot tests', () => { +describe('ConfirmHcaptcha', () => { + const getModal = (component) => component.UNSAFE_getByType('Modal'); + const getHcaptchaChild = (component) => component.UNSAFE_getByType(Hcaptcha); + const getInstance = (component) => component.UNSAFE_getByType(ConfirmHcaptcha).instance; + + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + it('renders ConfirmHcaptcha with minimum props', () => { const component = render( { languageCode="en" /> ); + expect(component).toMatchSnapshot(); }); - it('renders ConfirmHcaptcha with all props', () => { + it('forwards every shared prop to the embedded Hcaptcha component', () => { + const onMessage = jest.fn(); + const debug = { customDebug: true }; const component = render( { assethost="https://all.props/assethost" imghost="https://all.props/imghost" host="all-props-host" + debug={debug} + phonePrefix="44" + phoneNumber="+44123456789" /> ); - expect(component).toMatchSnapshot(); + + expect(getHcaptchaChild(component).props).toMatchObject({ + size: 'compact', + siteKey: '00000000-0000-0000-0000-000000000000', + url: 'https://hcaptcha.com', + languageCode: 'en', + orientation: 'landscape', + onMessage, + showLoading: true, + closableLoading: true, + backgroundColor: 'rgba(0.1, 0.1, 0.1, 0.4)', + loadingIndicatorColor: '#999999', + theme: 'light', + rqdata: '{"some":"data"}', + sentry: true, + jsSrc: 'https://all.props/api-endpoint', + endpoint: 'https://all.props/endpoint', + reportapi: 'https://all.props/reportapi', + assethost: 'https://all.props/assethost', + imghost: 'https://all.props/imghost', + host: 'all-props-host', + debug, + phonePrefix: '44', + phoneNumber: '+44123456789', + }); }); - it('renders ConfirmHcaptcha without safe area view', () => { + it('applies wrapper-only props to the modal and backdrop container', () => { const component = render( + ); + const modal = getModal(component); + const safeAreaView = component.UNSAFE_getByType(SafeAreaView); + + expect(modal.props.useNativeDriver).toBe(true); + expect(modal.props.hideModalContentWhileAnimating).toBe(true); + expect(modal.props.isVisible).toBe(false); + expect(modal.props.hasBackdrop).toBe(true); + expect(modal.props.coverScreen).toBe(true); + expect(modal.props.animationIn).toBe('fadeIn'); + expect(modal.props.animationOut).toBe('fadeOut'); + expect(safeAreaView.props.style).toEqual([ + expect.objectContaining({ + flex: 1, + justifyContent: 'center', + overflow: 'hidden', + }), + { backgroundColor: 'rgba(0.1, 0.1, 0.1, 0.4)' }, + ]); + }); + + it('disables modal backdrop/screen coverage when passiveSiteKey is enabled and omits wrapper backdrop color when hasBackdrop is false', () => { + const component = render( + + ); + const modal = getModal(component); + const safeAreaView = component.UNSAFE_getByType(SafeAreaView); + + expect(modal.props.style).toEqual([ + expect.objectContaining({ margin: 0, display: 'none' }), + { display: 'none' }, + ]); + expect(modal.props.hasBackdrop).toBe(false); + expect(modal.props.coverScreen).toBe(false); + expect(safeAreaView.props.style).toEqual([ + expect.objectContaining({ + flex: 1, + justifyContent: 'center', + overflow: 'hidden', + }), + {}, + ]); + }); + + it('uses SafeAreaView by default and a plain View wrapper when useSafeAreaView is false', () => { + const defaultWrapper = render( + + ); + + expect(defaultWrapper.UNSAFE_queryByType(SafeAreaView)).not.toBeNull(); + + const plainViewWrapper = render( + ); - expect(component).toMatchSnapshot(); + + expect(plainViewWrapper.UNSAFE_queryByType(SafeAreaView)).toBeNull(); + }); + + it('show() and hide() toggle modal visibility, and hide(source) emits cancel', () => { + const onMessage = jest.fn(); + const component = render( + + ); + const instance = getInstance(component); + + act(() => { + instance.show(); + }); + + expect(getModal(component).props.isVisible).toBe(true); + + act(() => { + instance.hide(); + }); + + expect(getModal(component).props.isVisible).toBe(false); + expect(onMessage).not.toHaveBeenCalled(); + + act(() => { + instance.show(); + }); + + act(() => { + instance.hide('backdrop'); + }); + + expect(getModal(component).props.isVisible).toBe(false); + expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); + }); + + it('backdrop and back-button handlers call hide(source) and emit cancel events', () => { + const onMessage = jest.fn(); + const component = render( + + ); + const instance = getInstance(component); + + act(() => { + instance.show(); + }); + + act(() => { + getModal(component).props.onBackdropPress(); + }); + + expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); + expect(getModal(component).props.isVisible).toBe(false); + + onMessage.mockClear(); + + act(() => { + instance.show(); + }); + + act(() => { + getModal(component).props.onBackButtonPress(); + }); + + expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); + expect(getModal(component).props.isVisible).toBe(false); }); }); diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index b237daa..67e571d 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -1,171 +1,568 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react-native'; +import vm from 'vm'; +import { act, render, waitFor } from '@testing-library/react-native'; +import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-native'; + import Hcaptcha from '../Hcaptcha'; -import { setWebViewMessageData } from 'react-native-webview'; +import { + getLastInjectJavaScriptMock, + resetWebViewMockState, + setWebViewMessageData, +} from 'react-native-webview'; + +const LONG_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001'; + +describe('Hcaptcha', () => { + const getWebView = (component) => component.UNSAFE_getByType('WebView'); + const getWebViewHtml = (component) => getWebView(component).props.source.html; + const getSerializedConfig = (component) => { + const match = getWebViewHtml(component).match(/var hcaptchaConfig = (.*?);\n\s*Object\.entries/s); + + expect(match).not.toBeNull(); + + return JSON.parse(match[1]); + }; + const getApiQueryParams = (component) => + Object.fromEntries(new URL(getSerializedConfig(component).apiUrl).searchParams.entries()); + const getInlineScripts = (component) => + [...getWebViewHtml(component).matchAll(/', }, }; + const component = render( + '} + url="https://hcaptcha.com" + languageCode={'en"'} + backgroundColor={'red\';window.ReactNativeWebView.postMessage("bg");//'} + theme={theme} + rqdata={'";window.ReactNativeWebView.postMessage("rqdata");//'} + sentry={true} + jsSrc={'https://example.com/api.js?x='} + endpoint={'https://example.com/endpoint?'} + reportapi={'https://example.com/reportapi?'} + assethost={'https://example.com/assethost?'} + imghost={'https://example.com/imghost?'} + host={'host"'} + debug={{ + '': '', + }} + orientation={'landscape"'} + phonePrefix={'44";window.ReactNativeWebView.postMessage("prefix");//'} + phoneNumber={'+44123\');window.ReactNativeWebView.postMessage("phone");//'} + /> + ); - [ - { - data: theme, - }, - { - data: JSON.stringify(theme), - }, - { - data: undefined, + const html = getWebViewHtml(component); + const config = getSerializedConfig(component); + const query = getApiQueryParams(component); + + expect(html).toContain('var hcaptchaConfig = '); + expect(html).toContain('const rqdata = hcaptchaConfig.rqdata;'); + expect(html).toContain('const phonePrefix = hcaptchaConfig.phonePrefix;'); + expect(html).toContain('const phoneNumber = hcaptchaConfig.phoneNumber;'); + expect(html).not.toContain(''); + expect(html).not.toContain('const rqdata = ";window.ReactNativeWebView.postMessage("rqdata")'); + expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"site\\")\\u003c/script\\u003e'); + expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"debug\\")\\u003c/script\\u003e'); + + expect(config.siteKey).toBe('site"'); + expect(config.backgroundColor).toBe('red\';window.ReactNativeWebView.postMessage("bg");//'); + expect(config.rqdata).toBe('";window.ReactNativeWebView.postMessage("rqdata");//'); + expect(config.phonePrefix).toBe('44";window.ReactNativeWebView.postMessage("prefix");//'); + expect(config.phoneNumber).toBe('+44123\');window.ReactNativeWebView.postMessage("phone");//'); + expect(config.theme).toEqual(theme); + expect(config.debugInfo['']).toBe(''); + + expect(query.hl).toBe('en"'); + expect(query.host).toBe(encodeURIComponent('host"')); + expect(query.endpoint).toBe('https://example.com/endpoint?'); + expect(query.reportapi).toBe('https://example.com/reportapi?'); + expect(query.assethost).toBe('https://example.com/assethost?'); + expect(query.imghost).toBe('https://example.com/imghost?'); + expect(query.orientation).toBe('landscape"'); + expect(query.sentry).toBe('true'); + expect(query.custom).toBe('true'); + }); + + it('does not render a loading overlay when showLoading is false', () => { + const component = render( + + ); + + expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); + expect(component.UNSAFE_queryByType(ActivityIndicator)).toBeNull(); + }); + + it('only allows dismissing the loading overlay when closableLoading is true', () => { + const onMessage = jest.fn(); + const nonClosable = render( + + ); + const nonClosableTouchTarget = nonClosable.UNSAFE_getByType(TouchableWithoutFeedback); + + act(() => { + nonClosableTouchTarget.props.onPress(); + }); + + expect(onMessage).not.toHaveBeenCalled(); + + const closable = render( + + ); + const closableTouchTarget = closable.UNSAFE_getByType(TouchableWithoutFeedback); + + act(() => { + closableTouchTarget.props.onPress(); + }); + + expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); + }); + + it('emits a loading timeout while the challenge is still loading', () => { + jest.useFakeTimers(); + const onMessage = jest.fn(); + + render( + + ); + + act(() => { + jest.advanceTimersByTime(15000); + }); + + expect(onMessage).toHaveBeenCalledWith({ + nativeEvent: { + data: 'error', + description: 'loading timeout', }, - ].forEach(({ data }) => { - it(`test ${typeof data}`, async () => { - const component = render( - - ); - expect(component).toMatchSnapshot(); + }); + }); + + it('forwards open messages, marks them as successful, and hides the loading overlay', async () => { + const onMessage = jest.fn(); + setWebViewMessageData('open'); + const component = render( + + ); + + await waitFor(() => { + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + reset: expect.any(Function), + nativeEvent: expect.objectContaining({ data: 'open' }), + })); + }); + + expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); + }); + + it('forwards token messages with reset and markUsed hooks', async () => { + jest.useFakeTimers(); + const onMessage = jest.fn(); + setWebViewMessageData(LONG_TOKEN); + + const component = render( + + ); + + await waitFor(() => { + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + reset: expect.any(Function), + markUsed: expect.any(Function), + nativeEvent: expect.objectContaining({ data: LONG_TOKEN }), + })); + }); + + const [{ reset, markUsed }] = onMessage.mock.calls[0]; + + reset(); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith('onloadCallback();'); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: 'open' } }); + }); + + markUsed(); + act(() => { + jest.advanceTimersByTime(120000); + }); + + expect(onMessage).toHaveBeenCalledTimes(2); + expect(onMessage).toHaveBeenNthCalledWith(2, expect.objectContaining({ + success: true, + nativeEvent: expect.objectContaining({ data: 'open' }), + })); + }); + + it('emits an expired message when a forwarded token is not marked used', async () => { + jest.useFakeTimers(); + const onMessage = jest.fn(); + setWebViewMessageData(LONG_TOKEN); + + const component = render( + + ); + + await waitFor(() => { + expect(onMessage).toHaveBeenCalledTimes(1); + }); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: 'open' } }); + }); + + act(() => { + jest.advanceTimersByTime(120000); + }); + + expect(onMessage).toHaveBeenNthCalledWith(3, { + nativeEvent: { data: 'expired' }, + success: false, + reset: expect.any(Function), + }); + }); + + it('marks short non-open messages as errors', async () => { + const onMessage = jest.fn(); + setWebViewMessageData('webview-error'); + + render( + + ); + + await waitFor(() => { + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + reset: expect.any(Function), + nativeEvent: expect.objectContaining({ data: 'webview-error' }), + })); + }); + }); + + it('opens hcaptcha links externally and blocks navigation in the WebView', () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + const component = render( + + ); + + const shouldStart = getWebView(component).props.onShouldStartLoadWithRequest({ + url: 'https://www.hcaptcha.com/privacy', + }); + + expect(shouldStart).toBe(false); + expect(openURL).toHaveBeenCalledWith('https://www.hcaptcha.com/privacy'); + }); + + it('opens sms links externally and reports failures back through onMessage', async () => { + const openURL = jest.spyOn(Linking, 'openURL'); + const onMessage = jest.fn(); + const component = render( + + ); + + openURL.mockResolvedValueOnce(true); + const successfulSms = getWebView(component).props.onShouldStartLoadWithRequest({ + url: 'sms:+15551234567', + }); + + expect(successfulSms).toBe(false); + expect(openURL).toHaveBeenCalledWith('sms:+15551234567'); + + openURL.mockRejectedValueOnce(new Error('sms unavailable')); + const failedSms = getWebView(component).props.onShouldStartLoadWithRequest({ + url: 'sms:+15557654321', + }); + + expect(failedSms).toBe(false); + + await waitFor(() => { + expect(onMessage).toHaveBeenCalledWith({ + nativeEvent: { + data: 'sms-open-failed', + description: 'sms unavailable', + }, + success: false, }); }); }); + + it('allows non-hcaptcha, non-sms navigations to continue inside the WebView', () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + const component = render( + + ); + + const shouldStart = getWebView(component).props.onShouldStartLoadWithRequest({ + url: 'https://example.com/path', + }); + + expect(shouldStart).toBe(true); + expect(openURL).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index 89321ce..4ace499 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -1,183 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha with all props 1`] = ` - - - - - - - - - - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", - }, - undefined, - ] - } - /> -
-
-
-`; - -exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha with minimum props 1`] = ` +exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props 1`] = ` - - - - - - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", + "current": { + "injectJavaScript": [MockFunction], }, - undefined, - ] - } - /> - - -
-`; - -exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha without safe area view 1`] = ` - - - - - @@ -525,6 +183,6 @@ exports[`ConfirmHcaptcha snapshot tests renders ConfirmHcaptcha without safe are } /> - + `; diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap index b8a1f48..c3f4162 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Hcaptcha snapshot tests Theme test object 1`] = ` +exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` - - - - - - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", - }, - undefined, - ] - } - /> -
-`; - -exports[`Hcaptcha snapshot tests Theme test string 1`] = ` - - - - - - - - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", - }, - undefined, - ] - } - /> -
-`; - -exports[`Hcaptcha snapshot tests Theme test undefined 1`] = ` - - - - - - - - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", - }, - undefined, - ] - } - /> -
-`; - -exports[`Hcaptcha snapshot tests renders Hcaptcha with all props 1`] = ` - - - - - - - - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", + "current": { + "injectJavaScript": [MockFunction], }, - undefined, - ] - } - /> - - - -
-`; - -exports[`Hcaptcha snapshot tests renders Hcaptcha with debug 1`] = ` - - - - - -
- - ", - } - } - style={ - [ - { - "backgroundColor": "transparent", - "width": "100%", - }, - undefined, - ] - } - /> -
-`; - -exports[`Hcaptcha snapshot tests renders Hcaptcha with minimum props 1`] = ` - - - - - - - - - - diff --git a/index.js b/index.js index e77a66b..3de78e3 100644 --- a/index.js +++ b/index.js @@ -46,6 +46,8 @@ class ConfirmHcaptcha extends PureComponent { hasBackdrop, debug, useSafeAreaView, + phonePrefix, + phoneNumber, } = this.props; const WrapperComponent = useSafeAreaView === false ? View : SafeAreaView; @@ -87,6 +89,8 @@ class ConfirmHcaptcha extends PureComponent { host={host} orientation={orientation} debug={debug} + phonePrefix={phonePrefix} + phoneNumber={phoneNumber} /> @@ -133,6 +137,8 @@ ConfirmHcaptcha.propTypes = { host: PropTypes.string, hasBackdrop: PropTypes.bool, debug: PropTypes.object, + phonePrefix: PropTypes.string, + phoneNumber: PropTypes.string, }; ConfirmHcaptcha.defaultProps = { @@ -154,6 +160,8 @@ ConfirmHcaptcha.defaultProps = { host: undefined, hasBackdrop: true, debug: {}, + phonePrefix: null, + phoneNumber: null, }; export default ConfirmHcaptcha; diff --git a/package-lock.json b/package-lock.json index 6110bfd..8dd72ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "eslint-plugin-react-native": "^5.0.0", "husky": "^9.1.7", "jest": "^29.7.0", + "pngjs": "^7.0.0", "prettier": "^3.6.2", "react": "*", "react-native": "*", @@ -9230,6 +9231,16 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 6b59846..cd7282a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "prepare": "husky", "test": "jest --testPathIgnorePatterns=\\.perf-test\\.js$", + "test:e2e": "node __e2e__/rn-android-dark-theme.e2e.mjs", "lint": "eslint .", "example": "node __scripts__/generate-example.js", "perf:baseline": "reassure --baseline", @@ -56,6 +57,7 @@ "eslint-plugin-react-native": "^5.0.0", "husky": "^9.1.7", "jest": "^29.7.0", + "pngjs": "^7.0.0", "prettier": "^3.6.2", "react": "*", "react-native": "*", From 2fd631767ee843ec3dd84dbb39ff9261c8dbb260 Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 12 Mar 2026 19:24:42 -0400 Subject: [PATCH 2/5] Add Android theme regression e2e --- __e2e__/rn-android-dark-theme.e2e.mjs | 106 +++++++++++++++++++++----- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/__e2e__/rn-android-dark-theme.e2e.mjs b/__e2e__/rn-android-dark-theme.e2e.mjs index 04a5be2..70fd8b5 100644 --- a/__e2e__/rn-android-dark-theme.e2e.mjs +++ b/__e2e__/rn-android-dark-theme.e2e.mjs @@ -14,9 +14,10 @@ const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const outputDir = path.join(repoRoot, 'output', 'android-e2e'); const fixtureAppDir = process.env.HCAPTCHA_E2E_APP_DIR || path.join(os.tmpdir(), 'react-native-hcaptcha-android-e2e'); +const reuseFixtureApp = Boolean(process.env.HCAPTCHA_E2E_APP_DIR); const fixtureAppName = 'RNHcaptchaE2E'; const fixturePackageName = 'com.hcaptcha.rne2e'; -const metroPort = process.env.RCT_METRO_PORT || '8088'; +let metroPort = process.env.RCT_METRO_PORT || '8088'; const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || path.join(os.homedir(), 'Library', 'Android', 'sdk'); const adbPath = path.join(sdkRoot, 'platform-tools', 'adb'); const emulatorPath = path.join(sdkRoot, 'emulator', 'emulator'); @@ -26,10 +27,11 @@ const preferredAvd = process.env.HCAPTCHA_E2E_AVD || 'Medium_Phone'; const screenshotPath = path.join(outputDir, 'android-dark-theme.png'); const metroLogPath = path.join(outputDir, 'metro.log'); const appLogPath = path.join(outputDir, 'run-android.log'); +const apkPath = path.join(fixtureAppDir, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); const fixtureAppSource = `import React from 'react'; import { SafeAreaView, StyleSheet, Text, View } from 'react-native'; -import Hcaptcha from '@hcaptcha/react-native-hcaptcha/Hcaptcha'; +import Hcaptcha from '@hcaptcha/react-native-hcaptcha/Hcaptcha.js'; const siteKey = '10000000-ffff-ffff-ffff-000000000001'; const baseUrl = 'https://hcaptcha.com'; @@ -207,13 +209,60 @@ const waitForPort = async (port, timeoutMs) => { throw new Error(`Timed out waiting for localhost:${port}`); }; +const getFreePort = async () => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to allocate a local port for Metro.')); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(String(address.port)); + }); + }); + server.on('error', reject); + }); + +const createPackageTarball = async () => { + const packageJson = JSON.parse(await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8')); + const tarballName = `${packageJson.name.replace('@', '').replace('/', '-')}-${packageJson.version}.tgz`; + const tarballPath = path.join(outputDir, tarballName); + + await fs.rm(tarballPath, { force: true }); + + await run('npm', [ + 'pack', + '--ignore-scripts', + '--pack-destination', + outputDir, + ], { + cwd: repoRoot, + stdio: 'pipe', + }); + + await ensureFile(tarballPath); + return tarballPath; +}; + const ensureFixtureApp = async () => { const packageJsonPath = path.join(fixtureAppDir, 'package.json'); let needsInit = false; + if (!reuseFixtureApp) { + await fs.rm(fixtureAppDir, { recursive: true, force: true }); + needsInit = true; + } + try { const parsed = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); - if (parsed.name !== fixtureAppName) { + if (parsed.name !== fixturePackageName) { needsInit = true; } } catch (_) { @@ -243,9 +292,11 @@ const ensureFixtureApp = async () => { await fs.writeFile(path.join(fixtureAppDir, 'App.js'), fixtureAppSource, 'utf8'); await fs.rm(path.join(fixtureAppDir, 'App.tsx'), { force: true }); + const tarballPath = await createPackageTarball(); await run('npm', [ 'install', - `@hcaptcha/react-native-hcaptcha@file:${repoRoot}`, + '--legacy-peer-deps', + tarballPath, 'react-native-modal', 'react-native-webview', ], { cwd: fixtureAppDir }); @@ -389,8 +440,8 @@ const captureScreenshot = async (deviceId) => { const waitForThemeDifference = async (deviceId) => { for (let attempt = 1; attempt <= 12; attempt += 1) { const screenshot = await captureScreenshot(deviceId); - const lightCrop = crop(screenshot, 0.12, 0.33, 0.88, 0.40); - const darkCrop = crop(screenshot, 0.12, 0.62, 0.88, 0.69); + const lightCrop = crop(screenshot, 0.08, 0.40, 0.92, 0.49); + const darkCrop = crop(screenshot, 0.08, 0.60, 0.92, 0.68); const lightLuminance = getAverageLuminance(lightCrop); const darkLuminance = getAverageLuminance(darkCrop); @@ -407,34 +458,53 @@ const waitForThemeDifference = async (deviceId) => { throw new Error('Timed out waiting for the dark widget to render darker than the light widget.'); }; -const runAndroidApp = async (deviceId) => { +const installAndLaunchAndroidApp = async (deviceId) => { const env = { ...process.env, ANDROID_HOME: sdkRoot, ANDROID_SDK_ROOT: sdkRoot, + ANDROID_NDK_HOME: path.join(sdkRoot, 'ndk', '27.1.12297006'), RCT_METRO_PORT: metroPort, REACT_NATIVE_PACKAGER_HOSTNAME: '127.0.0.1', }; - return run('npx', [ - 'react-native', - 'run-android', - '--deviceId', - deviceId, - '--port', - metroPort, - '--no-packager', + const gradleResult = await run('./gradlew', [ + 'app:assembleDebug', + '-x', + 'lint', + `-PreactNativeDevServerPort=${metroPort}`, ], { - cwd: fixtureAppDir, + cwd: path.join(fixtureAppDir, 'android'), env, stdio: ['ignore', 'pipe', 'pipe'], }); + + await ensureFile(apkPath); + const installResult = await adb('-s', deviceId, 'install', '-r', apkPath); + await adb('-s', deviceId, 'reverse', `tcp:${metroPort}`, `tcp:${metroPort}`); + const launchResult = await adb( + '-s', + deviceId, + 'shell', + 'am', + 'start', + '-n', + `${fixturePackageName}/.MainActivity` + ); + + return { + stdout: `${gradleResult.stdout}\n${installResult.stdout}\n${launchResult.stdout}`, + stderr: `${gradleResult.stderr}\n${installResult.stderr}\n${launchResult.stderr}`, + }; }; const main = async () => { await fs.mkdir(outputDir, { recursive: true }); await ensureFile(adbPath); await ensureFile(emulatorPath); + if (!process.env.RCT_METRO_PORT) { + metroPort = await getFreePort(); + } await ensureFixtureApp(); const env = { @@ -444,7 +514,7 @@ const main = async () => { RCT_METRO_PORT: metroPort, }; - const metro = startBackgroundProcess('npx', ['react-native', 'start', '--port', metroPort], metroLogPath, { + const metro = startBackgroundProcess('npx', ['react-native', 'start', '--port', metroPort, '--reset-cache'], metroLogPath, { cwd: fixtureAppDir, env, }); @@ -457,7 +527,7 @@ const main = async () => { const emulator = await ensureEmulator(); emulatorStop = emulator.stop; - const runResult = await runAndroidApp(emulator.deviceId); + const runResult = await installAndLaunchAndroidApp(emulator.deviceId); await fs.writeFile(appLogPath, `${runResult.stdout}\n${runResult.stderr}`, 'utf8'); const metrics = await waitForThemeDifference(emulator.deviceId); From 3aca5edd949a4b45825110c75b41610c11bf0a7d Mon Sep 17 00:00:00 2001 From: e271828- Date: Thu, 12 Mar 2026 19:38:11 -0400 Subject: [PATCH 3/5] Release 2.2.0 with updated maintainer runbook Bump the package version from 2.1.0 to 2.2.0 for the WebView config hardening, prop-forwarding, size-alias normalization, expanded regression coverage, and local Android theme E2E work already on this branch. Update MAINTAINER.md to document the release classification for this branch, the required verification steps, the local-only Android emulator E2E command, and the post-release checklist. Refresh tests and snapshots that encode the SDK version marker so the release branch remains self-consistent. --- MAINTAINER.md | 31 +++++++++++++++++-- __tests__/Hcaptcha.test.js | 2 +- .../ConfirmHcaptcha.test.js.snap | 2 +- __tests__/__snapshots__/Hcaptcha.test.js.snap | 2 +- package-lock.json | 4 +-- package.json | 5 +-- 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index b3168b6..932b73c 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -12,13 +12,33 @@ PATCH: bugfix only. - bump [`package.json's`](./package.json) version - run `npm i` to update `package-lock.json` +- update [`MAINTAINER.md`](./MAINTAINER.md) if release or verification steps changed +- verify: + - `npm test` + - `npm run lint` + - `CI=1 npm run test:e2e` to confirm CI skips local-only device E2E cleanly + - `npm run test:e2e:android-local` if you want the full Android emulator verification locally - commit `package.json` and `package-lock.json` +- commit any maintainer or release-documentation updates in the same PR - open the PR for review - once the PR is approved and merged to master: - - set the tag on master matching your version: git tag `vM.M.P` + - set the tag on master matching your version: `git tag vM.m.p` + - push the tag: `git push origin vM.m.p` - draft a new release https://github.com/hCaptcha/react-native-hcaptcha/releases + - summarize functional changes in the release notes, including: + - safer WebView config handling for HTML-facing props + - `ConfirmHcaptcha` forwarding of `phonePrefix` and `phoneNumber` + - `checkbox` size alias normalization to `normal` + - expanded unit coverage and local Android theme E2E coverage - once the release is created, CI will release the new version to https://www.npmjs.com/package/@hcaptcha/react-native-hcaptcha?activeTab=versions +3. Post-release verification: + +- confirm the GitHub release published successfully +- confirm npm lists the new version: + - `npm view @hcaptcha/react-native-hcaptcha version` +- smoke-test install in a disposable app if the release changed packaging or generated assets + ### Generate test app For `expo` test app @@ -33,6 +53,13 @@ For `react-native` test app - `yarn example` - `yarn android` or `npm run android` +For the local Android emulator regression E2E added in this repo: + +- `cd react-native-hcaptcha` +- ensure Android SDK, emulator, and an AVD are installed +- run `npm run test:e2e:android-local` +- inspect artifacts in [`output/android-e2e`](./output/android-e2e) if the run fails + For iOS instead the last step do: - `pushd ios; env USE_HERMES=0 pod install; popd` @@ -155,4 +182,4 @@ Usage Error: The file:../react-native-hcaptcha string didn't match the required Solution: `yarn add @hcaptcha/react-native-hcaptcha@file:../react-native-hcaptcha` -Yarn 2.10.x and above doesn't require `file:` scheme prefix https://stackoverflow.com/questions/40102686/how-to-install-package-with-local-path-by-yarn-it-couldnt-find-package \ No newline at end of file +Yarn 2.10.x and above doesn't require `file:` scheme prefix https://stackoverflow.com/questions/40102686/how-to-install-package-with-local-path-by-yarn-it-couldnt-find-package diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index 67e571d..61927b9 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -101,7 +101,7 @@ describe('Hcaptcha', () => { customDebug: 'enabled', rnver_0_0_0: true, 'dep_mocked-md5': true, - sdk_2_1_0: true, + sdk_2_2_0: true, }); expect(query).toMatchObject({ diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index 4ace499..6d25ddb 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -84,7 +84,7 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props 1`] = `