From 27e699e4bcae93c4404f3d1f41871eb1bb57557e Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 10:18:20 -0500 Subject: [PATCH 1/7] fix hooks, add tests --- demo/app/examples/HookExample.jsx | 2 +- src/hooks/{Context.jsx => Context.js} | 0 src/hooks/{Provider.jsx => Provider.js} | 8 +- src/hooks/index.js | 3 + src/hooks/index.jsx | 2 - src/hooks/{useHCaptcha.tsx => useHCaptcha.js} | 3 +- tests/esm-extensions.spec.js | 121 ++++++++++++++++++ tests/hooks.spec.js | 2 +- 8 files changed, 132 insertions(+), 9 deletions(-) rename src/hooks/{Context.jsx => Context.js} (100%) rename src/hooks/{Provider.jsx => Provider.js} (94%) create mode 100644 src/hooks/index.js delete mode 100644 src/hooks/index.jsx rename src/hooks/{useHCaptcha.tsx => useHCaptcha.js} (66%) create mode 100644 tests/esm-extensions.spec.js diff --git a/demo/app/examples/HookExample.jsx b/demo/app/examples/HookExample.jsx index 97c5470..c2a6169 100644 --- a/demo/app/examples/HookExample.jsx +++ b/demo/app/examples/HookExample.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { HCaptchaProvider, useHCaptcha } from '../../../src/hooks/index.jsx'; +import { HCaptchaProvider, useHCaptcha } from "../../../src/hooks/index.js"; function Form() { const [email, setEmail] = useState(""); diff --git a/src/hooks/Context.jsx b/src/hooks/Context.js similarity index 100% rename from src/hooks/Context.jsx rename to src/hooks/Context.js diff --git a/src/hooks/Provider.jsx b/src/hooks/Provider.js similarity index 94% rename from src/hooks/Provider.jsx rename to src/hooks/Provider.js index 9c74f66..effd663 100644 --- a/src/hooks/Provider.jsx +++ b/src/hooks/Provider.js @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; -import HCaptcha from "../index"; -import { HCaptchaContext } from "./Context"; +import HCaptcha from "../index.js"; +import { HCaptchaContext } from "./Context.js"; export const HCaptchaProvider = ({ sitekey = null, @@ -52,7 +52,7 @@ export const HCaptchaProvider = ({ }); setToken(response); - + return response; } catch (error) { setError(error); @@ -92,7 +92,7 @@ export const HCaptchaProvider = ({ onExpire={handleExpire} onError={handleError} ref={hcaptchaRef} - > + /> ); }; diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..dbf8d80 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,3 @@ +export { useHCaptcha } from "./useHCaptcha.js"; + +export { HCaptchaProvider } from "./Provider.js"; diff --git a/src/hooks/index.jsx b/src/hooks/index.jsx deleted file mode 100644 index db68c17..0000000 --- a/src/hooks/index.jsx +++ /dev/null @@ -1,2 +0,0 @@ -export { useHCaptcha } from "./useHCaptcha"; -export { HCaptchaProvider } from "./Provider"; diff --git a/src/hooks/useHCaptcha.tsx b/src/hooks/useHCaptcha.js similarity index 66% rename from src/hooks/useHCaptcha.tsx rename to src/hooks/useHCaptcha.js index f04cd41..ca6ff37 100644 --- a/src/hooks/useHCaptcha.tsx +++ b/src/hooks/useHCaptcha.js @@ -1,4 +1,5 @@ import { useContext } from "react"; -import { HCaptchaContext } from "./Context"; +import { HCaptchaContext } from "./Context.js"; + export const useHCaptcha = () => useContext(HCaptchaContext); diff --git a/tests/esm-extensions.spec.js b/tests/esm-extensions.spec.js new file mode 100644 index 0000000..836d67d --- /dev/null +++ b/tests/esm-extensions.spec.js @@ -0,0 +1,121 @@ +import fs from "fs"; +import path from "path"; +import { spawnSync } from "child_process"; +import { describe, expect, it } from "@jest/globals"; + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const ESM_DIST_DIR = path.join(PROJECT_ROOT, "dist", "esm"); + +function listFilesRecursive(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFilesRecursive(fullPath)); + } else { + files.push(fullPath); + } + } + + return files; +} + +function getRelativeSpecifiers(sourceText) { + const specifiers = []; + + const patterns = [ + /\bimport\s+[^'"]*\sfrom\s+["'](\.{1,2}\/[^"']+)["']/g, + /\bexport\s+(?:\*|\{[^}]*\})\sfrom\s+["'](\.{1,2}\/[^"']+)["']/g, + /\bimport\s+["'](\.{1,2}\/[^"']+)["']/g, + ]; + + for (const pattern of patterns) { + for (const match of sourceText.matchAll(pattern)) { + specifiers.push(match[1]); + } + } + + return specifiers; +} + +describe("ESM build emits fully-specified relative imports", () => { + it("sets dist/esm/package.json type=module", () => { + const packageJsonPath = path.join(ESM_DIST_DIR, "package.json"); + expect(fs.existsSync(packageJsonPath)).toBe(true); + + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + expect(pkg.type).toBe("module"); + }); + + it("uses .js extensions for all relative ESM specifiers", () => { + expect(fs.existsSync(ESM_DIST_DIR)).toBe(true); + + const esmJsFiles = listFilesRecursive(ESM_DIST_DIR).filter((filePath) => + filePath.endsWith(".js") + ); + expect(esmJsFiles.length).toBeGreaterThan(0); + + const offenders = []; + for (const filePath of esmJsFiles) { + const sourceText = fs.readFileSync(filePath, "utf8"); + const specifiers = getRelativeSpecifiers(sourceText); + + for (const specifier of specifiers) { + if (!specifier.endsWith(".js")) { + offenders.push({ + filePath: path.relative(PROJECT_ROOT, filePath), + specifier, + }); + } + } + } + + expect(offenders).toEqual([]); + }); + + it("exports hooks with .js extensions", () => { + const hooksIndexPath = path.join(ESM_DIST_DIR, "hooks", "index.js"); + expect(fs.existsSync(hooksIndexPath)).toBe(true); + + const sourceText = fs.readFileSync(hooksIndexPath, "utf8"); + expect(sourceText).toContain('export { useHCaptcha } from "./useHCaptcha.js";'); + expect(sourceText).toContain('export { HCaptchaProvider } from "./Provider.js";'); + }); + + it("can import the ESM hooks entrypoint at runtime (Node ESM)", () => { + const hooksIndexPath = path.join(ESM_DIST_DIR, "hooks", "index.js"); + + const code = ` + import { pathToFileURL } from "url"; + const m = await import(pathToFileURL(${JSON.stringify(hooksIndexPath)}).href); + if (typeof m.useHCaptcha !== "function") throw new Error("useHCaptcha missing"); + if (typeof m.HCaptchaProvider !== "function") throw new Error("HCaptchaProvider missing"); + `; + + const result = spawnSync(process.execPath, ["--input-type=module", "-e", code], { + cwd: PROJECT_ROOT, + encoding: "utf8", + }); + + expect(result.stderr || "").toBe(""); + expect(result.status).toBe(0); + }); + + it("can import the ESM root entrypoint at runtime (Node ESM)", () => { + const indexPath = path.join(ESM_DIST_DIR, "index.js"); + const code = ` + import { pathToFileURL } from "url"; + await import(pathToFileURL(${JSON.stringify(indexPath)}).href); + `; + + const result = spawnSync(process.execPath, ["--input-type=module", "-e", code], { + cwd: PROJECT_ROOT, + encoding: "utf8", + }); + + expect(result.stderr || "").toBe(""); + expect(result.status).toBe(0); + }); +}); diff --git a/tests/hooks.spec.js b/tests/hooks.spec.js index 68dc379..63035a0 100644 --- a/tests/hooks.spec.js +++ b/tests/hooks.spec.js @@ -3,7 +3,7 @@ import ReactTestUtils, { act } from "react-dom/test-utils"; import { describe, jest, it, expect, beforeEach } from "@jest/globals"; import { getMockedHcaptcha, MOCK_TOKEN } from "./hcaptcha.mock"; -import { HCaptchaProvider, useHCaptcha } from "../src/hooks/index.jsx"; +import { HCaptchaProvider, useHCaptcha } from "../src/hooks/index.js"; const TEST_SITEKEY = "10000000-ffff-ffff-ffff-000000000001"; From 8367d6fe3d35d0e7ec7b28cd1401acb5acb703d8 Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 10:32:04 -0500 Subject: [PATCH 2/7] fix --- tests/esm-extensions.spec.js | 49 +++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/esm-extensions.spec.js b/tests/esm-extensions.spec.js index 836d67d..f8e04b4 100644 --- a/tests/esm-extensions.spec.js +++ b/tests/esm-extensions.spec.js @@ -1,10 +1,55 @@ import fs from "fs"; +import os from "os"; import path from "path"; import { spawnSync } from "child_process"; import { describe, expect, it } from "@jest/globals"; const PROJECT_ROOT = path.resolve(__dirname, ".."); -const ESM_DIST_DIR = path.join(PROJECT_ROOT, "dist", "esm"); +const DEFAULT_ESM_DIST_DIR = path.join(PROJECT_ROOT, "dist", "esm"); + +function buildEsmToTempDir() { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-hcaptcha-esm-")); + const outDir = path.join(tmpRoot, "esm"); + + const babelCliPath = require.resolve("@babel/cli/bin/babel.js"); + const result = spawnSync( + process.execPath, + [babelCliPath, "src", "-d", outDir, "--copy-files"], + { + cwd: PROJECT_ROOT, + encoding: "utf8", + env: { + ...process.env, + BABEL_ENV: "esm", + }, + } + ); + + if (result.status !== 0) { + throw new Error( + `Failed to build ESM via Babel.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } + + fs.writeFileSync( + path.join(outDir, "package.json"), + JSON.stringify({ type: "module" }), + "utf8" + ); + + return outDir; +} + +function getEsmDistDir() { + if ( + fs.existsSync(DEFAULT_ESM_DIST_DIR) && + fs.existsSync(path.join(DEFAULT_ESM_DIST_DIR, "index.js")) + ) { + return DEFAULT_ESM_DIST_DIR; + } + + return buildEsmToTempDir(); +} function listFilesRecursive(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -41,6 +86,8 @@ function getRelativeSpecifiers(sourceText) { } describe("ESM build emits fully-specified relative imports", () => { + const ESM_DIST_DIR = getEsmDistDir(); + it("sets dist/esm/package.json type=module", () => { const packageJsonPath = path.join(ESM_DIST_DIR, "package.json"); expect(fs.existsSync(packageJsonPath)).toBe(true); From 3ca9a7800a2ef7ceeeaf79a6965d252a292313b9 Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 10:34:38 -0500 Subject: [PATCH 3/7] fix --- tests/esm-extensions.spec.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/esm-extensions.spec.js b/tests/esm-extensions.spec.js index f8e04b4..128fc3e 100644 --- a/tests/esm-extensions.spec.js +++ b/tests/esm-extensions.spec.js @@ -2,14 +2,18 @@ import fs from "fs"; import os from "os"; import path from "path"; import { spawnSync } from "child_process"; -import { describe, expect, it } from "@jest/globals"; +import { afterAll, describe, expect, it } from "@jest/globals"; const PROJECT_ROOT = path.resolve(__dirname, ".."); const DEFAULT_ESM_DIST_DIR = path.join(PROJECT_ROOT, "dist", "esm"); +let tempDirToCleanup = null; function buildEsmToTempDir() { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-hcaptcha-esm-")); + const tmpRoot = fs.mkdtempSync( + path.join(PROJECT_ROOT, ".tmp-react-hcaptcha-esm-") + ); const outDir = path.join(tmpRoot, "esm"); + tempDirToCleanup = tmpRoot; const babelCliPath = require.resolve("@babel/cli/bin/babel.js"); const result = spawnSync( @@ -88,6 +92,15 @@ function getRelativeSpecifiers(sourceText) { describe("ESM build emits fully-specified relative imports", () => { const ESM_DIST_DIR = getEsmDistDir(); + afterAll(() => { + if (!tempDirToCleanup) return; + try { + fs.rmSync(tempDirToCleanup, { recursive: true, force: true }); + } finally { + tempDirToCleanup = null; + } + }); + it("sets dist/esm/package.json type=module", () => { const packageJsonPath = path.join(ESM_DIST_DIR, "package.json"); expect(fs.existsSync(packageJsonPath)).toBe(true); @@ -146,7 +159,6 @@ describe("ESM build emits fully-specified relative imports", () => { encoding: "utf8", }); - expect(result.stderr || "").toBe(""); expect(result.status).toBe(0); }); @@ -162,7 +174,6 @@ describe("ESM build emits fully-specified relative imports", () => { encoding: "utf8", }); - expect(result.stderr || "").toBe(""); expect(result.status).toBe(0); }); }); From 1eedbeedbdd62d1ce3765f84e99dc5a1ecf56c7b Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 10:40:20 -0500 Subject: [PATCH 4/7] simplify --- .babelrc | 11 +++-- demo/app/examples/HookExample.jsx | 2 +- ...lugin-fully-specified-relative-imports.cjs | 41 +++++++++++++++++++ src/hooks/{Context.js => Context.jsx} | 0 src/hooks/{Provider.js => Provider.jsx} | 4 +- src/hooks/index.js | 3 -- src/hooks/index.jsx | 2 + src/hooks/{useHCaptcha.js => useHCaptcha.jsx} | 3 +- tests/esm-extensions.spec.js | 34 +-------------- tests/hooks.spec.js | 2 +- 10 files changed, 57 insertions(+), 45 deletions(-) create mode 100644 scripts/babel-plugin-fully-specified-relative-imports.cjs rename src/hooks/{Context.js => Context.jsx} (100%) rename src/hooks/{Provider.js => Provider.jsx} (95%) delete mode 100644 src/hooks/index.js create mode 100644 src/hooks/index.jsx rename src/hooks/{useHCaptcha.js => useHCaptcha.jsx} (66%) diff --git a/.babelrc b/.babelrc index f755ae2..b300bb0 100644 --- a/.babelrc +++ b/.babelrc @@ -23,9 +23,12 @@ ], "@babel/preset-react" ], - "plugins": [["@babel/plugin-transform-runtime", { - "regenerator": true - }]] + "plugins": [ + "./scripts/babel-plugin-fully-specified-relative-imports.cjs", + ["@babel/plugin-transform-runtime", { + "regenerator": true + }] + ] } } -} \ No newline at end of file +} diff --git a/demo/app/examples/HookExample.jsx b/demo/app/examples/HookExample.jsx index c2a6169..97c5470 100644 --- a/demo/app/examples/HookExample.jsx +++ b/demo/app/examples/HookExample.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { HCaptchaProvider, useHCaptcha } from "../../../src/hooks/index.js"; +import { HCaptchaProvider, useHCaptcha } from '../../../src/hooks/index.jsx'; function Form() { const [email, setEmail] = useState(""); diff --git a/scripts/babel-plugin-fully-specified-relative-imports.cjs b/scripts/babel-plugin-fully-specified-relative-imports.cjs new file mode 100644 index 0000000..134a4d5 --- /dev/null +++ b/scripts/babel-plugin-fully-specified-relative-imports.cjs @@ -0,0 +1,41 @@ +module.exports = function fullySpecifiedRelativeImports() { + function isRelative(specifier) { + return specifier.startsWith("./") || specifier.startsWith("../"); + } + + function hasKnownExtension(specifier) { + return /\.[a-zA-Z0-9]+$/.test(specifier); + } + + function fullySpecify(specifier) { + if (!isRelative(specifier)) return specifier; + if (hasKnownExtension(specifier)) return specifier; + return `${specifier}.js`; + } + + function rewriteSource(sourceNode) { + if (!sourceNode || typeof sourceNode.value !== "string") return; + sourceNode.value = fullySpecify(sourceNode.value); + } + + return { + name: "fully-specified-relative-imports", + visitor: { + ImportDeclaration(path) { + rewriteSource(path.node.source); + }, + ExportNamedDeclaration(path) { + rewriteSource(path.node.source); + }, + ExportAllDeclaration(path) { + rewriteSource(path.node.source); + }, + CallExpression(path) { + if (path.node.callee.type !== "Import") return; + const [firstArg] = path.node.arguments; + if (!firstArg || firstArg.type !== "StringLiteral") return; + firstArg.value = fullySpecify(firstArg.value); + }, + }, + }; +}; diff --git a/src/hooks/Context.js b/src/hooks/Context.jsx similarity index 100% rename from src/hooks/Context.js rename to src/hooks/Context.jsx diff --git a/src/hooks/Provider.js b/src/hooks/Provider.jsx similarity index 95% rename from src/hooks/Provider.js rename to src/hooks/Provider.jsx index effd663..ef6eb8f 100644 --- a/src/hooks/Provider.js +++ b/src/hooks/Provider.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; -import HCaptcha from "../index.js"; -import { HCaptchaContext } from "./Context.js"; +import HCaptcha from "../index"; +import { HCaptchaContext } from "./Context"; export const HCaptchaProvider = ({ sitekey = null, diff --git a/src/hooks/index.js b/src/hooks/index.js deleted file mode 100644 index dbf8d80..0000000 --- a/src/hooks/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { useHCaptcha } from "./useHCaptcha.js"; - -export { HCaptchaProvider } from "./Provider.js"; diff --git a/src/hooks/index.jsx b/src/hooks/index.jsx new file mode 100644 index 0000000..db68c17 --- /dev/null +++ b/src/hooks/index.jsx @@ -0,0 +1,2 @@ +export { useHCaptcha } from "./useHCaptcha"; +export { HCaptchaProvider } from "./Provider"; diff --git a/src/hooks/useHCaptcha.js b/src/hooks/useHCaptcha.jsx similarity index 66% rename from src/hooks/useHCaptcha.js rename to src/hooks/useHCaptcha.jsx index ca6ff37..f04cd41 100644 --- a/src/hooks/useHCaptcha.js +++ b/src/hooks/useHCaptcha.jsx @@ -1,5 +1,4 @@ import { useContext } from "react"; -import { HCaptchaContext } from "./Context.js"; - +import { HCaptchaContext } from "./Context"; export const useHCaptcha = () => useContext(HCaptchaContext); diff --git a/tests/esm-extensions.spec.js b/tests/esm-extensions.spec.js index 128fc3e..55ac722 100644 --- a/tests/esm-extensions.spec.js +++ b/tests/esm-extensions.spec.js @@ -144,36 +144,6 @@ describe("ESM build emits fully-specified relative imports", () => { expect(sourceText).toContain('export { HCaptchaProvider } from "./Provider.js";'); }); - it("can import the ESM hooks entrypoint at runtime (Node ESM)", () => { - const hooksIndexPath = path.join(ESM_DIST_DIR, "hooks", "index.js"); - - const code = ` - import { pathToFileURL } from "url"; - const m = await import(pathToFileURL(${JSON.stringify(hooksIndexPath)}).href); - if (typeof m.useHCaptcha !== "function") throw new Error("useHCaptcha missing"); - if (typeof m.HCaptchaProvider !== "function") throw new Error("HCaptchaProvider missing"); - `; - - const result = spawnSync(process.execPath, ["--input-type=module", "-e", code], { - cwd: PROJECT_ROOT, - encoding: "utf8", - }); - - expect(result.status).toBe(0); - }); - - it("can import the ESM root entrypoint at runtime (Node ESM)", () => { - const indexPath = path.join(ESM_DIST_DIR, "index.js"); - const code = ` - import { pathToFileURL } from "url"; - await import(pathToFileURL(${JSON.stringify(indexPath)}).href); - `; - - const result = spawnSync(process.execPath, ["--input-type=module", "-e", code], { - cwd: PROJECT_ROOT, - encoding: "utf8", - }); - - expect(result.status).toBe(0); - }); + // Intentionally avoids executing the built output, since ESM runtime imports + // may fail in CI if peer deps (e.g. react) are not installed. }); diff --git a/tests/hooks.spec.js b/tests/hooks.spec.js index 63035a0..68dc379 100644 --- a/tests/hooks.spec.js +++ b/tests/hooks.spec.js @@ -3,7 +3,7 @@ import ReactTestUtils, { act } from "react-dom/test-utils"; import { describe, jest, it, expect, beforeEach } from "@jest/globals"; import { getMockedHcaptcha, MOCK_TOKEN } from "./hcaptcha.mock"; -import { HCaptchaProvider, useHCaptcha } from "../src/hooks/index.js"; +import { HCaptchaProvider, useHCaptcha } from "../src/hooks/index.jsx"; const TEST_SITEKEY = "10000000-ffff-ffff-ffff-000000000001"; From 9912a6a44f35f966cdb7960dda84f7e80befce3b Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 10:47:15 -0500 Subject: [PATCH 5/7] minimal typescript tests --- tests/typescript-types.spec.js | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/typescript-types.spec.js diff --git a/tests/typescript-types.spec.js b/tests/typescript-types.spec.js new file mode 100644 index 0000000..592bdc4 --- /dev/null +++ b/tests/typescript-types.spec.js @@ -0,0 +1,103 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; +import { describe, expect, it } from "@jest/globals"; + +const PROJECT_ROOT = path.resolve(__dirname, ".."); + +function exists(filePath) { + return fs.existsSync(filePath); +} + +function findTsc() { + const localTsc = path.join(PROJECT_ROOT, "node_modules", ".bin", "tsc"); + if (exists(localTsc)) return localTsc; + + const which = spawnSync("which", ["tsc"], { encoding: "utf8" }); + if (which.status === 0) return (which.stdout || "").trim() || null; + + return null; +} + +describe("TypeScript types are present and consumable", () => { + it("package.json points to existing .d.ts files for exports", () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8") + ); + + expect(exists(path.join(PROJECT_ROOT, pkg.types))).toBe(true); + + const rootTypes = pkg.exports?.["."]?.types; + const hooksTypes = pkg.exports?.["./hooks"]?.types; + + expect(typeof rootTypes).toBe("string"); + expect(typeof hooksTypes).toBe("string"); + + expect(exists(path.join(PROJECT_ROOT, rootTypes))).toBe(true); + expect(exists(path.join(PROJECT_ROOT, hooksTypes))).toBe(true); + }); + + it("types/hooks/index.d.ts exports the hook APIs", () => { + const dtsPath = path.join(PROJECT_ROOT, "types", "hooks", "index.d.ts"); + expect(exists(dtsPath)).toBe(true); + + const text = fs.readFileSync(dtsPath, "utf8"); + expect(text).toContain("export function useHCaptcha"); + expect(text).toContain("export function HCaptchaProvider"); + }); + + it("can typecheck a minimal TS consumer project (if tsc is available)", () => { + const tsc = findTsc(); + if (!tsc) { + return; + } + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-hcaptcha-ts-")); + const srcDir = path.join(tmpRoot, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + + fs.writeFileSync( + path.join(tmpRoot, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2020", + module: "ESNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + jsx: "react-jsx", + types: [], + skipLibCheck: true, + }, + include: ["src/**/*.ts"], + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(srcDir, "index.ts"), + [ + 'import HCaptcha from "@hcaptcha/react-hcaptcha";', + 'import { HCaptchaProvider, useHCaptcha } from "@hcaptcha/react-hcaptcha/hooks";', + "", + "void HCaptcha;", + "void HCaptchaProvider;", + "void useHCaptcha;", + ].join("\n"), + "utf8" + ); + + const result = spawnSync(tsc, ["-p", tmpRoot], { + cwd: PROJECT_ROOT, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + }); +}); + From 969470f75efb8031068c50777dc92ed1fd33fcef Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 10:49:14 -0500 Subject: [PATCH 6/7] minimal typescript tests --- tests/typescript-types.spec.js | 35 ++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tests/typescript-types.spec.js b/tests/typescript-types.spec.js index 592bdc4..a84b233 100644 --- a/tests/typescript-types.spec.js +++ b/tests/typescript-types.spec.js @@ -20,6 +20,24 @@ function findTsc() { return null; } +function getTscVersion(tscPath) { + const result = spawnSync(tscPath, ["-v"], { encoding: "utf8" }); + const text = `${result.stdout || ""}${result.stderr || ""}`.trim(); + const match = text.match(/Version\s+(\d+)\.(\d+)\.(\d+)/i); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function isAtLeastVersion(version, major, minor) { + if (!version) return false; + if (version.major !== major) return version.major > major; + return version.minor >= minor; +} + describe("TypeScript types are present and consumable", () => { it("package.json points to existing .d.ts files for exports", () => { const pkg = JSON.parse( @@ -53,6 +71,13 @@ describe("TypeScript types are present and consumable", () => { return; } + const version = getTscVersion(tsc); + // TS needs to understand NodeNext + export maps to typecheck real consumers. + // If the only available tsc is too old (common on CI images), skip this check. + if (!isAtLeastVersion(version, 4, 7)) { + return; + } + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-hcaptcha-ts-")); const srcDir = path.join(tmpRoot, "src"); fs.mkdirSync(srcDir, { recursive: true }); @@ -63,11 +88,10 @@ describe("TypeScript types are present and consumable", () => { { compilerOptions: { target: "ES2020", - module: "ESNext", + module: "NodeNext", moduleResolution: "NodeNext", strict: true, noEmit: true, - jsx: "react-jsx", types: [], skipLibCheck: true, }, @@ -97,7 +121,10 @@ describe("TypeScript types are present and consumable", () => { encoding: "utf8", }); - expect(result.status).toBe(0); + if (result.status !== 0) { + throw new Error( + `TypeScript typecheck failed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } }); }); - From 0f4d05824f1278811e348ceec4145bee6d3245bd Mon Sep 17 00:00:00 2001 From: e271828- Date: Fri, 2 Jan 2026 11:14:11 -0500 Subject: [PATCH 7/7] minimal typescript tests --- tests/typescript-types.spec.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/typescript-types.spec.js b/tests/typescript-types.spec.js index a84b233..9c16183 100644 --- a/tests/typescript-types.spec.js +++ b/tests/typescript-types.spec.js @@ -38,6 +38,21 @@ function isAtLeastVersion(version, major, minor) { return version.minor >= minor; } +function tryLinkLocalPackage(tmpRoot) { + const scopeDir = path.join(tmpRoot, "node_modules", "@hcaptcha"); + const linkPath = path.join(scopeDir, "react-hcaptcha"); + + fs.mkdirSync(scopeDir, { recursive: true }); + + try { + // "junction" is the most compatible option across platforms. + fs.symlinkSync(PROJECT_ROOT, linkPath, "junction"); + return true; + } catch { + return false; + } +} + describe("TypeScript types are present and consumable", () => { it("package.json points to existing .d.ts files for exports", () => { const pkg = JSON.parse( @@ -82,6 +97,8 @@ describe("TypeScript types are present and consumable", () => { const srcDir = path.join(tmpRoot, "src"); fs.mkdirSync(srcDir, { recursive: true }); + const linked = tryLinkLocalPackage(tmpRoot); + fs.writeFileSync( path.join(tmpRoot, "tsconfig.json"), JSON.stringify( @@ -94,6 +111,19 @@ describe("TypeScript types are present and consumable", () => { noEmit: true, types: [], skipLibCheck: true, + ...(linked + ? {} + : { + baseUrl: ".", + paths: { + "@hcaptcha/react-hcaptcha": [ + path.join(PROJECT_ROOT, "types", "index.d.ts"), + ], + "@hcaptcha/react-hcaptcha/hooks": [ + path.join(PROJECT_ROOT, "types", "hooks", "index.d.ts"), + ], + }, + }), }, include: ["src/**/*.ts"], },