diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 90a3793..fb8f48b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -93,24 +93,25 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: path: react-native-hcaptcha - - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - if: matrix.platform == 'android' + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 with: java-version: 17 distribution: adopt - - if: contains(matrix.os, 'macos') + - if: matrix.platform == 'ios' run: sudo xcode-select -s /Applications/Xcode_16.4.app - - run: | - npm run example -- --pm ${{ matrix.pm }} + - run: npm run example -- --pm ${{ matrix.pm }} working-directory: react-native-hcaptcha env: YARN_ENABLE_IMMUTABLE_INSTALLS: false - id: rn-version working-directory: react-native-hcaptcha-example run: | - RN_VERSION=$(cat package.json | jq ".dependencies.\"react-native\"" -r) + RN_VERSION=$(jq -r ".dependencies.\"react-native\"" package.json) echo "value=${RN_VERSION}" >> $GITHUB_OUTPUT - - run: cat package.json - working-directory: react-native-hcaptcha-example - run: yarn test --config ./jest.config.js working-directory: react-native-hcaptcha-example - run: npx react-native build-${{ matrix.platform }} @@ -118,6 +119,64 @@ jobs: - run: npx --yes check-peer-dependencies --yarn --runOnlyOnRootDependencies working-directory: react-native-hcaptcha-example + e2e: + needs: build + permissions: + contents: read + runs-on: ${{ matrix.os }} + concurrency: + group: 'e2e-${{ matrix.platform }}-${{ github.head_ref || github.ref_name }}' + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: android + - os: macos-latest + platform: ios + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - if: matrix.platform == 'android' + uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 + with: + java-version: 17 + distribution: adopt + - run: npm install + - name: Generate E2E host app + run: npm run test:e2e:setup -- --pm npm --platform ${{ matrix.platform }} + + - if: matrix.platform == 'android' + name: Run Android E2E tests + uses: hCaptcha/hcaptcha-android-sdk/.github/actions/android-emulator-run@dc2e0fc978424322c977b68ae06f8dd54e571e22 + with: + target: google_apis + api-level: '34' + profile: pixel_7 + script: npm run test:e2e:android + + - if: matrix.platform == 'ios' + name: Boot iOS simulator + run: | + UDID=$(xcrun simctl list devices available --json | \ + jq -r '.devices["com.apple.CoreSimulator.SimRuntime.iOS-18-5"][] | select(.name=="iPhone 16") | .udid') + echo "Booting iPhone 16 (iOS 18.5): $UDID" + xcrun simctl bootstatus "$UDID" -b + + - if: matrix.platform == 'ios' + name: Run iOS E2E tests + run: npm run test:e2e:ios + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: e2e-results-${{ matrix.platform }} + path: __e2e__/__image_snapshots__/ + retention-days: 14 + create-an-issue: permissions: contents: read diff --git a/.gitignore b/.gitignore index f78c883..534900d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules/* yarn.lock +.DS_Store # Reassure performance testing files .reassure/ +output/ + +# Generated E2E host app +__e2e__/host/ 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 = ({ -