From d106698773b14ceeb7aedc04e1fbd023ec9fb6bb Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:08:47 +0900 Subject: [PATCH 1/5] fix(codegen): anchor tsconfig detection at generation cwd, not install dir openapi-ts auto-detects moduleResolution by walking up from its own install location when no tsConfigPath is given. In this pnpm monorepo that walk reaches this repo's own root tsconfig (NodeNext, meant for the library's own source) instead of the caller's project config, silently adding `.js` extensions to every generated relative import regardless of what the target project actually uses. Anchor the search at process.cwd() instead, which is the project the CLI is actually being run against. For a normal (non-monorepo) consumer this resolves to the same tsconfig as before; it only changes behavior for this repo's own examples, which were getting the wrong tsconfig by accident. The hand-written services.gen.ts backward-compat shim previously hardcoded `.js` on its re-exports, which not only assumed NodeNext but also didn't match its own bundler-resolution examples. It now mirrors whatever extension convention openapi-ts used for sdk.gen.ts's own imports. Verified: all three examples regenerate with extension-less imports (matching their bundler moduleResolution) and pass tsc/build; a standalone NodeNext project generates with `.js` extensions on both sdk.gen.ts and the shim, and passes tsc. --- src/generate.mts | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/generate.mts b/src/generate.mts index 6a04b62..bf30a67 100644 --- a/src/generate.mts +++ b/src/generate.mts @@ -1,4 +1,5 @@ -import { writeFile } from "node:fs/promises"; +import { readdirSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { type UserConfig, createClient } from "@hey-api/openapi-ts"; import type { LimitedUserConfig } from "./cli.mjs"; @@ -11,6 +12,27 @@ import { createSource } from "./createSource.mjs"; import { formatOutput, processOutput } from "./format.mjs"; import { print } from "./print.mjs"; +// openapi-ts's own tsconfig auto-detection walks up from its *install* +// location, which in this monorepo reaches this repo's own root tsconfig +// (NodeNext) instead of the caller's project config. Anchoring the search +// at cwd instead finds the tsconfig that actually applies to the generated +// output, matching the resolution the caller's own `tsc` will use. +function findNearestTsConfigPath(startDir: string): string | undefined { + let dir = startDir; + while (true) { + const candidates = readdirSync(dir).filter( + (file) => file.startsWith("tsconfig") && file.endsWith(".json"), + ); + if (candidates.length > 0) { + candidates.sort((a) => (a === "tsconfig.json" ? -1 : 1)); + return path.join(dir, candidates[0]); + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + export async function generate(options: LimitedUserConfig, version: string) { const openApiOutputPath = buildRequestsOutputPath(options.output); const formattedOptions = formatOptions(options); @@ -61,14 +83,25 @@ export async function generate(options: LimitedUserConfig, version: string) { const config: UserConfig = { dryRun: false, input: formattedOptions.input, - output: openApiOutputPath, + output: { + path: openApiOutputPath, + tsConfigPath: findNearestTsConfigPath(process.cwd()) ?? null, + }, plugins, }; await createClient(config); // Generate backward-compatible services.gen.ts shim - // client.gen.ts has the `client` instance; sdk.gen.ts has SDK functions - const shimContent = `// This file is auto-generated for backward compatibility\nexport * from './client.gen.js';\nexport * from './sdk.gen.js';\n`; + // client.gen.ts has the `client` instance; sdk.gen.ts has SDK functions. + // Mirror whatever extension convention openapi-ts used for its own + // cross-file imports (e.g. `.js` for NodeNext, none for bundler resolution). + const sdkGenContent = await readFile( + path.join(openApiOutputPath, "sdk.gen.ts"), + "utf-8", + ); + const importExtension = + sdkGenContent.match(/from ['"]\.\/client\.gen(\.\S*)?['"]/)?.[1] ?? ""; + const shimContent = `// This file is auto-generated for backward compatibility\nexport * from './client.gen${importExtension}';\nexport * from './sdk.gen${importExtension}';\n`; await writeFile(path.join(openApiOutputPath, "services.gen.ts"), shimContent); const source = await createSource({ From e7c3edb071531419b36cc4c643280c2fca6a79fb Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:01 +0900 Subject: [PATCH 2/5] fix(deps): upgrade next.js 15.x to 16.x in nextjs-app Turbopack is now the default bundler for both `next dev` and `next build`. Combined with the tsconfig-detection fix in src/generate.mts (the previous `.js`-extension imports were never resolvable by Turbopack anyway, since it has no equivalent of webpack's resolve.extensionAlias), the example now builds natively with no bundler-specific config or flags needed. `next build` rewrote tsconfig.json's `jsx` to `react-jsx` (mandatory for the React automatic runtime) and added `.next/dev/types/**/*.ts` to `include`; kept the existing compact array formatting via biome. Verified: `next build` (Turbopack) and `next dev` both work with no config, tsc passes, and the full test suite + biome check still pass. --- examples/nextjs-app/package.json | 2 +- examples/nextjs-app/tsconfig.json | 10 ++- pnpm-lock.yaml | 102 ++++++++++++++++-------------- 3 files changed, 64 insertions(+), 50 deletions(-) diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 3aa3201..632bbe3 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -16,7 +16,7 @@ "@hey-api/client-fetch": "^0.6.0", "@tanstack/react-query": "^5.59.13", "@tanstack/react-query-devtools": "^5.32.1", - "next": "^15.5.16", + "next": "^16.2.10", "react": "^18", "react-dom": "^18" }, diff --git a/examples/nextjs-app/tsconfig.json b/examples/nextjs-app/tsconfig.json index 86824be..7830c0c 100644 --- a/examples/nextjs-app/tsconfig.json +++ b/examples/nextjs-app/tsconfig.json @@ -11,7 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -22,6 +22,12 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], "exclude": ["node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b743ae..86f98e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,8 +132,8 @@ importers: specifier: ^5.32.1 version: 5.45.0(@tanstack/react-query@5.59.13(react@18.3.1))(react@18.3.1) next: - specifier: ^15.5.16 - version: 15.5.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^16.2.10 + version: 16.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18 version: 18.3.1 @@ -1318,53 +1318,53 @@ packages: '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} - '@next/env@15.5.20': - resolution: {integrity: sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==} + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} - '@next/swc-darwin-arm64@15.5.20': - resolution: {integrity: sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==} + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.20': - resolution: {integrity: sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==} + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.20': - resolution: {integrity: sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==} + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.20': - resolution: {integrity: sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==} + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.20': - resolution: {integrity: sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==} + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.20': - resolution: {integrity: sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==} + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.20': - resolution: {integrity: sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==} + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.20': - resolution: {integrity: sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==} + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2186,6 +2186,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} @@ -3893,9 +3898,9 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} - next@15.5.20: - resolution: {integrity: sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -6040,12 +6045,12 @@ snapshots: '@emnapi/runtime@1.11.2': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 optional: true '@emnapi/runtime@1.3.1': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 optional: true '@esbuild/aix-ppc64@0.25.0': @@ -6481,30 +6486,30 @@ snapshots: '@mjackson/node-fetch-server@0.2.0': {} - '@next/env@15.5.20': {} + '@next/env@16.2.10': {} - '@next/swc-darwin-arm64@15.5.20': + '@next/swc-darwin-arm64@16.2.10': optional: true - '@next/swc-darwin-x64@15.5.20': + '@next/swc-darwin-x64@16.2.10': optional: true - '@next/swc-linux-arm64-gnu@15.5.20': + '@next/swc-linux-arm64-gnu@16.2.10': optional: true - '@next/swc-linux-arm64-musl@15.5.20': + '@next/swc-linux-arm64-musl@16.2.10': optional: true - '@next/swc-linux-x64-gnu@15.5.20': + '@next/swc-linux-x64-gnu@16.2.10': optional: true - '@next/swc-linux-x64-musl@15.5.20': + '@next/swc-linux-x64-musl@16.2.10': optional: true - '@next/swc-win32-arm64-msvc@15.5.20': + '@next/swc-win32-arm64-msvc@16.2.10': optional: true - '@next/swc-win32-x64-msvc@15.5.20': + '@next/swc-win32-x64-msvc@16.2.10': optional: true '@nodelib/fs.scandir@2.1.5': @@ -6756,7 +6761,7 @@ snapshots: lodash: 4.18.0 openapi3-ts: 2.0.2 postman-collection: 4.4.0 - tslib: 2.6.3 + tslib: 2.8.1 type-is: 1.6.18 transitivePeerDependencies: - encoding @@ -6913,7 +6918,7 @@ snapshots: '@stoplight/ordered-object-literal': 1.0.5 '@stoplight/types': 14.1.1 '@stoplight/yaml-ast-parser': 0.0.50 - tslib: 2.6.3 + tslib: 2.8.1 '@swc/helpers@0.5.15': dependencies: @@ -7528,6 +7533,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.43: {} + basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 @@ -9648,24 +9655,25 @@ snapshots: neotraverse@0.6.18: {} - next@15.5.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.5.20 + '@next/env': 16.2.10 '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.43 caniuse-lite: 1.0.30001667 postcss: 8.5.10 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.20 - '@next/swc-darwin-x64': 15.5.20 - '@next/swc-linux-arm64-gnu': 15.5.20 - '@next/swc-linux-arm64-musl': 15.5.20 - '@next/swc-linux-x64-gnu': 15.5.20 - '@next/swc-linux-x64-musl': 15.5.20 - '@next/swc-win32-arm64-msvc': 15.5.20 - '@next/swc-win32-x64-msvc': 15.5.20 + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -10992,7 +11000,7 @@ snapshots: typescript-auto-import-cache@0.3.3: dependencies: - semver: 7.7.3 + semver: 7.8.4 typescript@5.4.5: {} From fed425604df35bfe56bcef5f77de1148fd639648 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:17 +0900 Subject: [PATCH 3/5] ci: verify nextjs-app production build generate:api + tsc alone never caught that `next build` was broken (webpack couldn't resolve the generated client's imports, later fixed in a separate commit). Run the real production build so this class of regression is caught going forward, including for the tsconfig detection this build step now implicitly guards. --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb06a2d..70792b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,6 +54,9 @@ jobs: - name: Run tsc in nextjs-app run: pnpm --filter nextjs-app test:generated + - name: Run next build in nextjs-app + run: pnpm --filter nextjs-app build + - name: Run tsc in tanstack-router-app run: pnpm --filter tanstack-router-app test:generated From 42cd850a58bc36e83b636f68a1565bf4cd9235bb Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:26:01 +0900 Subject: [PATCH 4/5] fix(nextjs-app): remove dead lint script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next lint` was removed from the Next.js 16 CLI, so `pnpm --filter nextjs-app lint` failed with "Invalid project directory provided" (Codex review on PR #199). This example never had an ESLint config to begin with, and its source is already covered by the workspace-root `pnpm biome check .` (already run in CI) — no other example carries a per-package lint script for the same reason. Dropped rather than wired up a redundant local Biome/ESLint invocation. --- examples/nextjs-app/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 632bbe3..97558a0 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -8,7 +8,6 @@ "dev:mock": "prism mock ../petstore.yaml --dynamic", "build": "next build", "start": "next start", - "lint": "next lint", "generate:api": "rimraf ./openapi && node ../../dist/cli.mjs -i ../petstore.yaml --format=biome --lint=biome", "test:generated": "tsc -p ./tsconfig.json --noEmit" }, From 79b7b9e642ae1ef9da4ea21b87484b4cdab67bcf Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:36:52 +0900 Subject: [PATCH 5/5] test(codegen): cover tsconfig-based import extension detection No existing test exercised the requests/ output that openapi-ts generates (only the queries/ react-query wrapper was snapshotted), so neither the original .js-extension bug nor this session's fix to it would have been caught by the suite. Adds: - Unit tests for findNearestTsConfigPath (now exported), covering same-dir match, walking up to an ancestor, preferring exact tsconfig.json over tsconfig.*.json variants, and the not-found case. - End-to-end generate() tests in isolated tmpdir fixtures with their own tsconfig.json, chdir'd into for the duration of the call, asserting sdk.gen.ts and the services.gen.ts shim get no extension under bundler resolution and `.js` under NodeNext, and that the two always agree with each other. --- src/generate.mts | 2 +- tests/generate.test.ts | 112 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/generate.mts b/src/generate.mts index bf30a67..827ec7a 100644 --- a/src/generate.mts +++ b/src/generate.mts @@ -17,7 +17,7 @@ import { print } from "./print.mjs"; // (NodeNext) instead of the caller's project config. Anchoring the search // at cwd instead finds the tsconfig that actually applies to the generated // output, matching the resolution the caller's own `tsc` will use. -function findNearestTsConfigPath(startDir: string): string | undefined { +export function findNearestTsConfigPath(startDir: string): string | undefined { let dir = startDir; while (true) { const candidates = readdirSync(dir).filter( diff --git a/tests/generate.test.ts b/tests/generate.test.ts index 4088028..f5a3b35 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import type { LimitedUserConfig } from "../src/cli.mts"; -import { generate } from "../src/generate.mjs"; +import { findNearestTsConfigPath, generate } from "../src/generate.mjs"; const readOutput = (fileName: string) => { return readFileSync( @@ -132,3 +133,110 @@ describe("generate - noSchemas option", () => { expect(readNoSchemasOutput("queries.ts")).toMatchSnapshot(); }); }); + +describe("findNearestTsConfigPath", () => { + let fixtureRoot: string; + + beforeAll(async () => { + fixtureRoot = await mkdtemp(path.join(tmpdir(), "openapi-rq-tsconfig-")); + }); + + afterAll(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); + }); + + test("finds tsconfig.json in the start directory", async () => { + const dir = await mkdtemp(path.join(fixtureRoot, "same-dir-")); + await writeFile(path.join(dir, "tsconfig.json"), "{}"); + expect(findNearestTsConfigPath(dir)).toBe(path.join(dir, "tsconfig.json")); + }); + + test("walks up to an ancestor directory", async () => { + const ancestor = await mkdtemp(path.join(fixtureRoot, "ancestor-")); + await writeFile(path.join(ancestor, "tsconfig.json"), "{}"); + const nested = path.join(ancestor, "packages", "app"); + await mkdir(nested, { recursive: true }); + expect(findNearestTsConfigPath(nested)).toBe( + path.join(ancestor, "tsconfig.json"), + ); + }); + + test("prefers exact tsconfig.json over other tsconfig*.json files", async () => { + const dir = await mkdtemp(path.join(fixtureRoot, "prefers-")); + await writeFile(path.join(dir, "tsconfig.build.json"), "{}"); + await writeFile(path.join(dir, "tsconfig.json"), "{}"); + expect(findNearestTsConfigPath(dir)).toBe(path.join(dir, "tsconfig.json")); + }); + + test("returns undefined when no ancestor has a tsconfig", async () => { + const dir = await mkdtemp(path.join(fixtureRoot, "empty-")); + // Real filesystem walk-up, so this only holds if no ancestor of `dir` + // (up to and including `/`) happens to contain a tsconfig*.json — true + // for a fresh os.tmpdir() fixture in CI and local dev. + expect(findNearestTsConfigPath(dir)).toBeUndefined(); + }); +}); + +describe("generate - import extension follows the caller's tsconfig", () => { + const readGenerated = async (outputDir: string, fileName: string) => + readFile(path.join(outputDir, "requests", fileName), "utf-8"); + + const runInFixture = async (moduleResolution: string) => { + const fixtureDir = await mkdtemp( + path.join(tmpdir(), "openapi-rq-generate-"), + ); + await writeFile( + path.join(fixtureDir, "tsconfig.json"), + JSON.stringify({ compilerOptions: { moduleResolution } }), + ); + const originalCwd = process.cwd(); + try { + process.chdir(fixtureDir); + await generate( + { + input: path.join(__dirname, "inputs", "petstore.yaml"), + // `generate()` joins this against `process.cwd()` internally, so + // it must be relative (matching how the CLI is always invoked). + output: "output", + client: "@hey-api/client-fetch", + pageParam: "page", + nextPageParam: "meta.next", + initialPageParam: "initial", + }, + "1.0.0", + ); + } finally { + process.chdir(originalCwd); + } + return { fixtureDir, outputDir: path.join(fixtureDir, "output") }; + }; + + test("bundler resolution: no extension on relative imports", async () => { + const { fixtureDir, outputDir } = await runInFixture("bundler"); + try { + const sdkGen = await readGenerated(outputDir, "sdk.gen.ts"); + const shim = await readGenerated(outputDir, "services.gen.ts"); + expect(sdkGen).toContain("from './client.gen'"); + expect(sdkGen).not.toContain("from './client.gen.js'"); + expect(shim).toBe( + "// This file is auto-generated for backward compatibility\nexport * from './client.gen';\nexport * from './sdk.gen';\n", + ); + } finally { + await rm(fixtureDir, { recursive: true, force: true }); + } + }); + + test("NodeNext resolution: .js extension on relative imports, mirrored by the shim", async () => { + const { fixtureDir, outputDir } = await runInFixture("nodenext"); + try { + const sdkGen = await readGenerated(outputDir, "sdk.gen.ts"); + const shim = await readGenerated(outputDir, "services.gen.ts"); + expect(sdkGen).toContain("from './client.gen.js'"); + expect(shim).toBe( + "// This file is auto-generated for backward compatibility\nexport * from './client.gen.js';\nexport * from './sdk.gen.js';\n", + ); + } finally { + await rm(fixtureDir, { recursive: true, force: true }); + } + }); +});