diff --git a/.prettierignore b/.prettierignore index 4ee7dc0ac..66043ccf7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,6 @@ /coverage /.nx __snapshots__ + +# Test fixtures with intentional syntax errors +packages/utils/mocks/fixtures/actually-invalid.js diff --git a/package-lock.json b/package-lock.json index 5d0ca9f7f..1f114372d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,12 +64,13 @@ "@types/benchmark": "^2.1.5", "@types/debug": "^4.1.12", "@types/eslint": "^8.44.2", + "@types/jsdom": "^27.0.0", "@types/node": "^22.13.4", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", "@vitejs/plugin-react": "^5.0.0", "@vitest/coverage-v8": "1.3.1", - "@vitest/eslint-plugin": "^1.1.38", + "@vitest/eslint-plugin": "^1.6.6", "@vitest/ui": "1.3.1", "benchmark": "^2.1.4", "chrome-launcher": "^1.1.2", @@ -92,7 +93,7 @@ "husky": "^8.0.0", "inquirer": "^9.3.7", "jest-extended": "^6.0.0", - "jiti": "2.4.2", + "jiti": "^2.4.2", "jsdom": "~24.0.0", "jsonc-eslint-parser": "^2.4.0", "knip": "^5.33.3", @@ -3209,9 +3210,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -9278,6 +9280,18 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -9380,6 +9394,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -9430,6 +9451,67 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.0.tgz", @@ -9447,6 +9529,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.0.tgz", @@ -9548,16 +9647,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.0.tgz", - "integrity": "sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.29.0", - "@typescript-eslint/types": "8.29.0", - "@typescript-eslint/typescript-estree": "8.29.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9568,18 +9667,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz", - "integrity": "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.29.0", - "@typescript-eslint/visitor-keys": "8.29.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9590,9 +9689,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.0.tgz", - "integrity": "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -9604,20 +9703,21 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz", - "integrity": "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.29.0", - "@typescript-eslint/visitor-keys": "8.29.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9627,18 +9727,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz", - "integrity": "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.29.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9648,10 +9748,28 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9677,10 +9795,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -10550,15 +10688,21 @@ } }, "node_modules/@vitest/eslint-plugin": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.1.38.tgz", - "integrity": "sha512-KcOTZyVz8RiM5HyriiDVrP1CyBGuhRxle+lBsmSs6NTJEO/8dKVAq+f5vQzHj1/Kc7bYXSDO6yBe62Zx0t5iaw==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.6.tgz", + "integrity": "sha512-bwgQxQWRtnTVzsUHK824tBmHzjV0iTx3tZaiQIYDjX3SA7TsQS8CuDVqxXrRY3FaOUMgbGavesCxI9MOfFLm7Q==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.51.0", + "@typescript-eslint/utils": "^8.51.0" + }, + "engines": { + "node": ">=18" + }, "peerDependencies": { - "@typescript-eslint/utils": "^8.24.0", - "eslint": ">= 8.57.0", - "typescript": ">= 5.0.0", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", "vitest": "*" }, "peerDependenciesMeta": { @@ -10570,6 +10714,69 @@ } } }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/expect": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz", @@ -22271,6 +22478,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", "integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==", "dev": true, + "license": "MIT", "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", diff --git a/package.json b/package.json index 832e128fc..4d6904073 100644 --- a/package.json +++ b/package.json @@ -74,12 +74,13 @@ "@types/benchmark": "^2.1.5", "@types/debug": "^4.1.12", "@types/eslint": "^8.44.2", + "@types/jsdom": "^27.0.0", "@types/node": "^22.13.4", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", "@vitejs/plugin-react": "^5.0.0", "@vitest/coverage-v8": "1.3.1", - "@vitest/eslint-plugin": "^1.1.38", + "@vitest/eslint-plugin": "^1.6.6", "@vitest/ui": "1.3.1", "benchmark": "^2.1.4", "chrome-launcher": "^1.1.2", @@ -102,7 +103,7 @@ "husky": "^8.0.0", "inquirer": "^9.3.7", "jest-extended": "^6.0.0", - "jiti": "2.4.2", + "jiti": "^2.4.2", "jsdom": "~24.0.0", "jsonc-eslint-parser": "^2.4.0", "knip": "^5.33.3", diff --git a/packages/cli/mocks/core-config-middleware.int-helper.ts b/packages/cli/mocks/core-config-middleware.int-helper.ts new file mode 100644 index 000000000..4ddb6813c --- /dev/null +++ b/packages/cli/mocks/core-config-middleware.int-helper.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +/** + * Helper script for testing coreConfigMiddleware with tsconfig path resolution. + * This script is executed in a subprocess to test the middleware in isolation. + * + * Usage: tsx core-config-middleware.int-helper.ts [tsconfigPath] + * + * Note: Logger output is redirected to stderr to avoid interfering with JSON output on stdout. + */ +import { coreConfigMiddleware } from '../src/lib/implementation/core-config.middleware.js'; + +// Redirect console.log and process.stdout.write to stderr to prevent logger output +// from interfering with JSON output +const originalLog = console.log; +const originalWrite = process.stdout.write.bind(process.stdout); + +console.log = (...args: unknown[]) => { + process.stderr.write(args.join(' ') + '\n'); +}; + +process.stdout.write = ((chunk: any, ...args: any[]): boolean => { + return process.stderr.write(chunk, ...args); +}) as typeof process.stdout.write; + +const [configPath, tsconfigPath] = process.argv.slice(2); + +if (!configPath) { + console.error('Error: configPath is required'); + process.exit(1); +} + +try { + const result = await coreConfigMiddleware({ + config: configPath, + ...(tsconfigPath && { tsconfig: tsconfigPath }), + plugins: [], + onlyPlugins: [], + skipPlugins: [], + }); + + // Restore original stdout.write before outputting JSON + process.stdout.write = originalWrite; + + // Use originalLog to write JSON to stdout + originalLog( + JSON.stringify({ + success: true, + config: result.config, + }), + ); + process.exit(0); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts b/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts index ccb7f62d4..d4969315a 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { executeProcess } from '@code-pushup/utils'; import { coreConfigMiddleware } from './core-config.middleware.js'; const configDirPath = path.join( @@ -12,7 +13,33 @@ const configDirPath = path.join( 'mocks', 'configs', ); - +const helperPath = path.join( + fileURLToPath(path.dirname(import.meta.url)), + '..', + '..', + '..', + '..', + 'cli', + 'mocks', + 'core-config-middleware.int-helper.ts', +); +const runMiddlewareInCwd = async (configPath: string, tsconfigPath?: string) => + await executeProcess({ + command: 'npx', + args: [ + 'tsx', + helperPath, + configPath, + ...(tsconfigPath ? [tsconfigPath] : []), + ], + cwd: configDirPath, + env: { + ...process.env, + // Disable all logger output to avoid interfering with JSON output + CP_VERBOSE: 'false', + CI: 'false', + }, + }); describe('coreConfigMiddleware', () => { const CLI_DEFAULTS = { plugins: [], @@ -30,15 +57,16 @@ describe('coreConfigMiddleware', () => { }); it('should load config which relies on provided --tsconfig', async () => { - await expect( - coreConfigMiddleware({ - config: path.join( - configDirPath, - 'code-pushup.needs-tsconfig.config.ts', - ), - tsconfig: path.join(configDirPath, 'tsconfig.json'), - ...CLI_DEFAULTS, - }), - ).resolves.toBeTruthy(); + const { stdout, code } = await runMiddlewareInCwd( + 'code-pushup.needs-tsconfig.config.ts', + path.join(configDirPath, 'tsconfig.json'), + ); + + expect(code).toBe(0); + const output = JSON.parse(stdout); + expect(output).toStrictEqual({ + success: true, + config: expect.any(String), + }); }); }); diff --git a/packages/plugin-axe/src/lib/axe-core-polyfilled.ts b/packages/plugin-axe/src/lib/axe-core-polyfilled.ts new file mode 100644 index 000000000..748453e52 --- /dev/null +++ b/packages/plugin-axe/src/lib/axe-core-polyfilled.ts @@ -0,0 +1,46 @@ +/** + * Axe-core Polyfilled Import + * + * This file ensures the jsdom polyfill runs BEFORE axe-core is imported. + * Static imports are evaluated in order, so the polyfill import must come first. + * + * WHY THIS EXISTS: + * axe-core has side effects on import - it expects global `window` and `document` objects + * to be available when the module is loaded. In Node.js environments, these don't exist + * by default. This polyfill creates a virtual DOM using JSDOM and sets these globals + * before axe-core is imported. + * + * IMPORT ORDER IS CRITICAL: + * 1. jsdom.polyfill.ts is imported first (sets up globalThis.window and globalThis.document) + * 2. axe-core is imported second (now globals exist) + * + * IMPORT CHAIN: + * 1. This file (imports polyfill, then imports axe-core) + * 2. safe-axe-core-import.ts (re-exports for clean imports) + * + * USAGE: + * Do NOT import from this file directly. Use safe-axe-core-import.ts instead. + * + * @see https://github.com/dequelabs/axe-core/issues/3962 + */ +// CRITICAL: This import MUST come before axe-core import +// It sets up globalThis.window and globalThis.document as side effects +// eslint-disable-next-line import/no-unassigned-import +// Now safe to import axe-core - globals exist due to polyfill above +// This import MUST come after the polyfill import +import axe from 'axe-core'; +import './jsdom.polyfill.js'; + +// Re-export axe as default +export default axe; + +// Re-export all types used throughout the codebase +export type { + AxeResults, + NodeResult, + Result, + IncompleteResult, + RuleMetadata, + ImpactValue, + CrossTreeSelector, +} from 'axe-core'; diff --git a/packages/plugin-axe/src/lib/groups.int.test.ts b/packages/plugin-axe/src/lib/groups.int.test.ts index acf24e541..79932c4d0 100644 --- a/packages/plugin-axe/src/lib/groups.int.test.ts +++ b/packages/plugin-axe/src/lib/groups.int.test.ts @@ -1,5 +1,5 @@ -import axe from 'axe-core'; import { axeCategoryGroupSlugSchema, axeWcagTagSchema } from './groups.js'; +import axe from './safe-axe-core-import.js'; describe('axeCategoryGroupSlugSchema', () => { const axeCategoryTags = axe diff --git a/packages/plugin-axe/src/lib/jsdom.polyfill.ts b/packages/plugin-axe/src/lib/jsdom.polyfill.ts new file mode 100644 index 000000000..717076ce1 --- /dev/null +++ b/packages/plugin-axe/src/lib/jsdom.polyfill.ts @@ -0,0 +1,32 @@ +/** + * JSDOM Polyfill Setup + * + * WHY THIS EXISTS: + * axe-core has side effects on import - it expects global `window` and `document` objects + * to be available when the module is loaded. In Node.js environments, these don't exist + * by default. This polyfill creates a virtual DOM using JSDOM and sets these globals + * before axe-core is imported. + * + * HOW IT WORKS: + * - Creates a minimal JSDOM instance with a basic HTML document + * - Sets globalThis.window and globalThis.document to the JSDOM window/document + * - This must be imported BEFORE any axe-core imports to ensure globals exist + * + * IMPORT CHAIN: + * This file is imported first by axe-core-polyfilled.ts, which then safely imports + * axe-core. All other files should import from safe-axe-core-import.ts, not directly + * from this file or from 'axe-core'. + * + * @see https://github.com/dequelabs/axe-core/issues/3962 + */ +// @ts-ignore - jsdom types are in devDependencies at root level +import { JSDOM } from 'jsdom'; + +const html = `\n`; +const { window: jsdomWindow } = new JSDOM(html); + +// Set globals for axe-core compatibility +// eslint-disable-next-line functional/immutable-data +globalThis.window = jsdomWindow as unknown as Window & typeof globalThis; +// eslint-disable-next-line functional/immutable-data +globalThis.document = jsdomWindow.document; diff --git a/packages/plugin-axe/src/lib/meta/transform.ts b/packages/plugin-axe/src/lib/meta/transform.ts index f4e8ad03e..274e5cc62 100644 --- a/packages/plugin-axe/src/lib/meta/transform.ts +++ b/packages/plugin-axe/src/lib/meta/transform.ts @@ -1,4 +1,3 @@ -import axe from 'axe-core'; import type { Audit, Group } from '@code-pushup/models'; import { objectToEntries, wrapTags } from '@code-pushup/utils'; import type { AxePreset } from '../config.js'; @@ -7,6 +6,7 @@ import { CATEGORY_GROUPS, getWcagPresetTags, } from '../groups.js'; +import axe from '../safe-axe-core-import.js'; /** Loads Axe rules filtered by the specified preset. */ export function loadAxeRules(preset: AxePreset): axe.RuleMetadata[] { diff --git a/packages/plugin-axe/src/lib/runner/run-axe.ts b/packages/plugin-axe/src/lib/runner/run-axe.ts index ccc6712a9..3410cedf8 100644 --- a/packages/plugin-axe/src/lib/runner/run-axe.ts +++ b/packages/plugin-axe/src/lib/runner/run-axe.ts @@ -1,6 +1,5 @@ import { AxeBuilder } from '@axe-core/playwright'; import ansis from 'ansis'; -import type { AxeResults } from 'axe-core'; import { createRequire } from 'node:module'; import path from 'node:path'; import { @@ -17,6 +16,7 @@ import { logger, pluralizeToken, } from '@code-pushup/utils'; +import type { AxeResults } from '../safe-axe-core-import.js'; import { type SetupFunction, runSetup } from './setup.js'; import { toAuditOutputs } from './transform.js'; diff --git a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts index cf742b512..2b4af8d5a 100644 --- a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts +++ b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts @@ -1,5 +1,9 @@ -import type { AxeResults, IncompleteResult, Result } from 'axe-core'; import { type AuditOutput, DEFAULT_PERSIST_CONFIG } from '@code-pushup/models'; +import type { + AxeResults, + IncompleteResult, + Result, +} from '../safe-axe-core-import.js'; import type { AxeUrlResult } from './run-axe.js'; import { createRunnerFunction } from './runner.js'; import * as setup from './setup.js'; diff --git a/packages/plugin-axe/src/lib/runner/transform.ts b/packages/plugin-axe/src/lib/runner/transform.ts index 1e29e40dc..f96a9f517 100644 --- a/packages/plugin-axe/src/lib/runner/transform.ts +++ b/packages/plugin-axe/src/lib/runner/transform.ts @@ -1,4 +1,3 @@ -import axe from 'axe-core'; import type { AuditOutput, AuditOutputs, @@ -10,6 +9,7 @@ import { pluralizeToken, truncateIssueMessage, } from '@code-pushup/utils'; +import axe from '../safe-axe-core-import.js'; /** * Transforms Axe results into audit outputs. diff --git a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts index df7e1a13b..d6727f237 100644 --- a/packages/plugin-axe/src/lib/runner/transform.unit.test.ts +++ b/packages/plugin-axe/src/lib/runner/transform.unit.test.ts @@ -1,5 +1,9 @@ -import type { AxeResults, CheckResult, NodeResult, Result } from 'axe-core'; import type { AuditOutput } from '@code-pushup/models'; +import type { + AxeResults, + NodeResult, + Result, +} from '../safe-axe-core-import.js'; import { toAuditOutputs } from './transform.js'; function createMockCheck(overrides: Partial = {}): CheckResult { diff --git a/packages/plugin-axe/src/lib/safe-axe-core-import.ts b/packages/plugin-axe/src/lib/safe-axe-core-import.ts new file mode 100644 index 000000000..00ec99165 --- /dev/null +++ b/packages/plugin-axe/src/lib/safe-axe-core-import.ts @@ -0,0 +1,33 @@ +/** + * Safe Axe-core Import Entry Point + * + * This is the ONLY safe way to import axe-core in this codebase. + * All files should import from this module instead of importing directly from 'axe-core'. + * + * WHY THIS EXISTS: + * axe-core requires global `window` and `document` objects to exist when imported. + * Due to ES module import hoisting, we need a fixed import chain to ensure the + * jsdom polyfill runs before axe-core loads. + * + * IMPORT CHAIN: + * jsdom.polyfill.ts → axe-core-polyfilled.ts → safe-axe-core-import.ts → your code + * + * USAGE: + * Instead of: import axe from 'axe-core'; + * Use: import axe from './safe-axe-core-import.js'; + * + * Instead of: import type { AxeResults } from 'axe-core'; + * Use: import type { AxeResults } from './safe-axe-core-import.js'; + */ + +// Re-export everything from the polyfilled version +export { default } from './axe-core-polyfilled.js'; +export type { + AxeResults, + NodeResult, + Result, + IncompleteResult, + RuleMetadata, + ImpactValue, + CrossTreeSelector, +} from './axe-core-polyfilled.js'; diff --git a/packages/utils/mocks/fixtures/actually-invalid.js b/packages/utils/mocks/fixtures/actually-invalid.js new file mode 100644 index 000000000..6911b2dbc --- /dev/null +++ b/packages/utils/mocks/fixtures/actually-invalid.js @@ -0,0 +1,4 @@ +// This is an actually invalid JavaScript file with syntax errors +const x = { + missing: "closing brace" +// Unclosed object diff --git a/packages/utils/mocks/fixtures/helper-module.ts b/packages/utils/mocks/fixtures/helper-module.ts new file mode 100644 index 000000000..f88914af3 --- /dev/null +++ b/packages/utils/mocks/fixtures/helper-module.ts @@ -0,0 +1,2 @@ +export const helperValue = 'helper-module-value'; +export default helperValue; diff --git a/packages/utils/mocks/fixtures/tsconfig-paths-test.json b/packages/utils/mocks/fixtures/tsconfig-paths-test.json new file mode 100644 index 000000000..6d3ac4926 --- /dev/null +++ b/packages/utils/mocks/fixtures/tsconfig-paths-test.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@test/helper": ["./helper-module.ts"] + } + } +} diff --git a/packages/utils/mocks/fixtures/uses-path-alias.ts b/packages/utils/mocks/fixtures/uses-path-alias.ts new file mode 100644 index 000000000..41965179c --- /dev/null +++ b/packages/utils/mocks/fixtures/uses-path-alias.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/no-unresolved +import helperValue from '@test/helper'; + +const config = { + value: helperValue, +}; + +export default config; diff --git a/packages/utils/package.json b/packages/utils/package.json index 64584db35..9d5316dce 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -30,14 +30,15 @@ "@code-pushup/models": "0.113.1", "ansis": "^3.3.0", "build-md": "^0.4.2", - "bundle-require": "^5.1.0", "esbuild": "^0.25.2", "ora": "^9.0.0", "semver": "^7.6.0", "simple-git": "^3.20.0", "string-width": "^8.1.0", "wrap-ansi": "^9.0.2", - "zod": "^4.2.1" + "zod": "^4.2.1", + "jiti": "^2.4.2", + "typescript": "5.7.3" }, "peerDependencies": { "@nx/devkit": ">=17.0.0" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 991771532..ec92f46e1 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,6 +32,7 @@ export { type ProcessObserver, type ProcessResult, } from './lib/execute-process.js'; +export { loadTargetConfig } from './lib/load-ts-config.js'; export { crawlFileSystem, createReportPath, diff --git a/packages/utils/src/lib/execute-process.ts b/packages/utils/src/lib/execute-process.ts index 4bf00cb8c..c4afee5a0 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -162,9 +162,10 @@ export function executeProcess(cfg: ProcessConfig): Promise { const worker = () => new Promise((resolve, reject) => { const spawnedProcess = spawn(command, args ?? [], { - // shell:true tells Windows to use shell command for spawning a child process + // shell:true is only needed on Windows to spawn child processes correctly // https://stackoverflow.com/questions/60386867/node-spawn-child-process-not-working-in-windows - shell: true, + // Using shell conditionally avoids Node.js DEP0190 deprecation warning + shell: process.platform === 'win32', windowsHide: true, ...options, }) as ChildProcessByStdio; diff --git a/packages/utils/src/lib/import-module.int.test.ts b/packages/utils/src/lib/import-module.int.test.ts index 181c85ce0..eeed2cbb8 100644 --- a/packages/utils/src/lib/import-module.int.test.ts +++ b/packages/utils/src/lib/import-module.int.test.ts @@ -47,7 +47,7 @@ describe('importModule', () => { it('should throw if the file does not exist', async () => { await expect( importModule({ filepath: 'path/to/non-existent-export.mjs' }), - ).rejects.toThrow("File 'path/to/non-existent-export.mjs' does not exist"); + ).rejects.toThrow(/path\/to\/non-existent-export\.mjs' does not exist$/); }); it('should throw if path is a directory', async () => { @@ -58,9 +58,18 @@ describe('importModule', () => { it('should throw if file is not valid JS', async () => { await expect( - importModule({ filepath: path.join(mockDir, 'invalid-js-file.json') }), - ).rejects.toThrow( - `${path.join(mockDir, 'invalid-js-file.json')} is not a valid JS file`, - ); + importModule({ filepath: path.join(mockDir, 'actually-invalid.js') }), + ).rejects.toThrow(); + }); + + it('should load a TS module using tsconfig paths', async () => { + await expect( + importModule({ + filepath: path.join(mockDir, 'uses-path-alias.ts'), + tsconfig: path.join(mockDir, 'tsconfig-paths-test.json'), + }), + ).resolves.toEqual({ + value: 'helper-module-value', + }); }); }); diff --git a/packages/utils/src/lib/import-module.ts b/packages/utils/src/lib/import-module.ts index 6f43faaab..c62894d8a 100644 --- a/packages/utils/src/lib/import-module.ts +++ b/packages/utils/src/lib/import-module.ts @@ -1,23 +1,224 @@ -import { type Options, bundleRequire } from 'bundle-require'; +import { createJiti as createJitiSource } from 'jiti'; import { stat } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { CompilerOptions } from 'typescript'; +import { loadTargetConfig } from './load-ts-config.js'; import { settlePromise } from './promises.js'; -export async function importModule(options: Options): Promise { - const resolvedStats = await settlePromise(stat(options.filepath)); +// For unknown reason, we can't import `JitiOptions` directly in this repository +type JitiOptions = Exclude[1], undefined>; + +/** + * Known packages that must be loaded natively (not transformed by jiti). + * These packages rely on import.meta.url being a real file:// URL, + * or have critical import side effects that must execute in order. + * When jiti transforms modules, import.meta.url becomes 'about:blank', + * causing errors in packages that use new URL(..., import.meta.url). + */ +export const JITI_NATIVE_MODULES = [ + //'@vitest/eslint-plugin', + //'@code-pushup/eslint-config', + //'lighthouse', + 'axe-core', // Has side effects that require window/document globals at import time + 'jsdom', // Needed for axe-core polyfill - must execute its side effects before axe-core loads +] as const; + +export type ImportModuleOptions = JitiOptions & { + filepath: string; + tsconfig?: string; +}; + +export function toFileUrl(filepath: string): string { + // Handle Windows absolute paths (C:\Users\... or C:/Users/...) on all platforms + // pathToFileURL on non-Windows systems treats Windows paths as relative paths + const windowsAbsolutePathMatch = filepath.match(/^([A-Za-z]:)([\\/].*)$/); + if (windowsAbsolutePathMatch) { + const [, drive, rest] = windowsAbsolutePathMatch; + // Normalize backslashes to forward slashes and construct file URL manually + const normalizedPath = `${drive}${rest?.replace(/\\/g, '/')}`; + return `file:///${normalizedPath}`; + } + return pathToFileURL(filepath).href; +} + +export async function importModule( + options: ImportModuleOptions, +): Promise { + const { filepath, tsconfig, ...jitiOptions } = options; + + if (!filepath) { + throw new Error( + `Importing module failed. File '${filepath}' does not exist`, + ); + } + + const absoluteFilePath = path.resolve(process.cwd(), filepath); + const resolvedStats = await settlePromise(stat(absoluteFilePath)); if (resolvedStats.status === 'rejected') { - throw new Error(`File '${options.filepath}' does not exist`); + throw new Error(`File '${absoluteFilePath}' does not exist`); } if (!resolvedStats.value.isFile()) { - throw new Error(`Expected '${options.filepath}' to be a file`); + throw new Error(`Expected '${filepath}' to be a file`); } - const { mod } = await bundleRequire({ - format: 'esm', - ...options, + const jitiInstance = await createTsJiti(import.meta.url, { + ...jitiOptions, + tsconfigPath: tsconfig, }); - if (typeof mod === 'object' && 'default' in mod) { - return mod.default as T; - } - return mod as T; + return (await jitiInstance.import(absoluteFilePath, { + default: true, + })) as T; +} + +/** + * Converts TypeScript paths configuration to jiti alias format + * @param paths TypeScript paths object from compiler options + * @param baseUrl Base URL for resolving relative paths + * @returns Jiti alias object with absolute paths + */ +export function mapTsPathsToJitiAlias( + paths: Record, + baseUrl: string, +): Record { + return Object.entries(paths).reduce( + (aliases, [pathPattern, pathMappings]) => { + if (!Array.isArray(pathMappings) || pathMappings.length === 0) { + return aliases; + } + // Jiti does not support overloads (multiple mappings for the same path pattern) + if (pathMappings.length > 1) { + throw new Error( + `TypeScript path overloads are not supported by jiti. Path pattern '${pathPattern}' has ${pathMappings.length} mappings: ${pathMappings.join(', ')}. Jiti only supports a single alias mapping per pattern.`, + ); + } + const aliasKey = pathPattern.replace(/\/\*$/, ''); + const aliasValue = (pathMappings.at(0) as string).replace(/\/\*$/, ''); + return { + ...aliases, + [aliasKey]: path.isAbsolute(aliasValue) + ? aliasValue + : path.resolve(baseUrl, aliasValue), + }; + }, + {} satisfies Record, + ); +} + +/** + * Maps TypeScript JSX emit mode to Jiti JSX boolean option + * @param tsJsxMode TypeScript JsxEmit enum value (0-5) + * @returns true if JSX processing should be enabled, false otherwise + */ +export const mapTsJsxToJitiJsx = (tsJsxMode: number): boolean => + tsJsxMode !== 0; + +/** + * Possible TS to jiti options mapping + * | Jiti Option | Jiti Type | TS Option | TS Type | Description | + * |-------------------|-------------------------|-----------------------|--------------------------|-------------| + * | alias | Record | paths | Record | Module path aliases for module resolution. | + * | interopDefault | boolean | esModuleInterop | boolean | Enable default import interop. | + * | sourceMaps | boolean | sourceMap | boolean | Enable sourcemap generation. | + * | jsx | boolean | jsx | JsxEmit (0-5) | TS JsxEmit enum (0-5) => boolean JSX processing. | + * | nativeModules | string[] | - | - | Modules to load natively without jiti transformation. | + */ +export type MappableJitiOptions = Partial< + Pick< + JitiOptions, + 'alias' | 'interopDefault' | 'sourceMaps' | 'jsx' | 'nativeModules' + > +>; +/** + * Parse TypeScript compiler options to mappable jiti options + * @param compilerOptions TypeScript compiler options + * @param tsconfigDir Directory of the tsconfig file (for resolving relative baseUrl) + * @returns Mappable jiti options + */ +export function parseTsConfigToJitiConfig( + compilerOptions: CompilerOptions, + tsconfigDir?: string, +): MappableJitiOptions { + const paths = compilerOptions.paths || {}; + const baseUrl = compilerOptions.baseUrl + ? path.isAbsolute(compilerOptions.baseUrl) + ? compilerOptions.baseUrl + : tsconfigDir + ? path.resolve(tsconfigDir, compilerOptions.baseUrl) + : path.resolve(process.cwd(), compilerOptions.baseUrl) + : tsconfigDir || process.cwd(); + + return { + ...(Object.keys(paths).length > 0 + ? { + alias: mapTsPathsToJitiAlias(paths, baseUrl), + } + : {}), + ...(compilerOptions.esModuleInterop == null + ? {} + : { interopDefault: compilerOptions.esModuleInterop }), + ...(compilerOptions.sourceMap == null + ? {} + : { sourceMaps: compilerOptions.sourceMap }), + ...(compilerOptions.jsx == null + ? {} + : { jsx: mapTsJsxToJitiJsx(compilerOptions.jsx) }), + }; +} + +/** + * Create a jiti instance with options derived from tsconfig. + * Used instead of direct jiti.createJiti to allow tsconfig integration. + * @param id + * @param options + * @param jiti + */ +export async function createTsJiti( + id: string, + options: JitiOptions & { tsconfigPath?: string } = {}, + createJiti: (typeof import('jiti'))['createJiti'] = createJitiSource, +) { + const { tsconfigPath, ...jitiOptions } = options; + const validPath: null | string = + tsconfigPath != null ? path.resolve(process.cwd(), tsconfigPath) : null; + const tsDerivedJitiOptions: MappableJitiOptions = validPath + ? await jitiOptionsFromTsConfig(validPath) + : {}; + + const mergedAlias = { + ...jitiOptions.alias, + ...tsDerivedJitiOptions.alias, + }; + + return createJiti(id, { + ...jitiOptions, + ...tsDerivedJitiOptions, + alias: mergedAlias, + nativeModules: [ + ...new Set([ + ...JITI_NATIVE_MODULES, + ...(jitiOptions.nativeModules ?? []), + ]), + ], + // Use tryNative: false by default for consistent, predictable behavior + // Native imports don't support: + // - TypeScript files (.ts) + // - Path aliases from tsconfig + // - Non-standard JavaScript features that jiti can transpile + // Since this is used for config file loading where these features are common, + // it's safer to always let jiti handle the transformation + tryNative: false, + }); +} + +/** + * Read tsconfig file and parse options to jiti options + * @param tsconfigPath + */ +export async function jitiOptionsFromTsConfig( + tsconfigPath: string, +): Promise { + const { options } = loadTargetConfig(tsconfigPath); + return parseTsConfigToJitiConfig(options, path.dirname(tsconfigPath)); } diff --git a/packages/utils/src/lib/import-module.unit.test.ts b/packages/utils/src/lib/import-module.unit.test.ts new file mode 100644 index 000000000..db952531f --- /dev/null +++ b/packages/utils/src/lib/import-module.unit.test.ts @@ -0,0 +1,144 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { CompilerOptions } from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { + mapTsPathsToJitiAlias, + parseTsConfigToJitiConfig, + toFileUrl, +} from './import-module.js'; + +describe('mapTsPathsToJitiAlias', () => { + it('returns empty object when paths is empty', () => { + expect(mapTsPathsToJitiAlias({}, '/base')).toStrictEqual({}); + }); + + it('returns empty object when all path mappings are empty arrays', () => { + expect(mapTsPathsToJitiAlias({ '@/*': [] }, '/base')).toStrictEqual({}); + }); + + it('maps single path pattern without wildcards', () => { + expect(mapTsPathsToJitiAlias({ '@': ['src'] }, '/base')).toStrictEqual({ + '@': expect.pathToEndWith('base/src'), + }); + }); + + it('strips /* from path pattern and mapping', () => { + expect(mapTsPathsToJitiAlias({ '@/*': ['src/*'] }, '/base')).toStrictEqual({ + '@': expect.pathToEndWith('base/src'), + }); + }); + + it('resolves relative path mappings to absolute', () => { + expect(mapTsPathsToJitiAlias({ '@/*': ['src/*'] }, '/app')).toStrictEqual({ + '@': expect.pathToEndWith('app/src'), + }); + }); + + it('keeps absolute path mappings as-is', () => { + expect( + mapTsPathsToJitiAlias({ '@/*': ['/absolute/path/*'] }, '/base'), + ).toStrictEqual({ '@': '/absolute/path' }); + }); + + it('throws error when path overloads exist (multiple mappings)', () => { + expect(() => + mapTsPathsToJitiAlias({ '@/*': ['first/*', 'second/*'] }, '/base'), + ).toThrow( + "TypeScript path overloads are not supported by jiti. Path pattern '@/*' has 2 mappings: first/*, second/*. Jiti only supports a single alias mapping per pattern.", + ); + }); + + it('maps multiple path patterns', () => { + expect( + mapTsPathsToJitiAlias( + { + '@/*': ['src/*'], + '~/*': ['lib/*'], + }, + '/base', + ), + ).toStrictEqual({ + '@': expect.pathToEndWith('base/src'), + '~': expect.pathToEndWith('base/lib'), + }); + }); + + it('filters out invalid mappings and keeps valid ones', () => { + expect( + mapTsPathsToJitiAlias( + { + 'invalid/*': [], + '@/*': ['src/*'], + 'also-invalid': [], + }, + '/base', + ), + ).toStrictEqual({ + '@': expect.pathToEndWith('src'), + }); + }); +}); + +describe('parseTsConfigToJitiConfig', () => { + it('returns empty object when compiler options are empty', () => { + expect(parseTsConfigToJitiConfig({})).toStrictEqual({}); + }); + + it('includes all options jiti can use', () => { + const compilerOptions: CompilerOptions = { + paths: { + '@app/*': ['src/*'], + '@lib/*': ['lib/*'], + }, + esModuleInterop: true, + sourceMap: true, + jsx: 2, // JsxEmit.React + include: ['**/*.ts'], + + baseUrl: '/base', + }; + + expect(parseTsConfigToJitiConfig(compilerOptions)).toStrictEqual({ + alias: { + '@app': expect.pathToEndWith('src'), + '@lib': expect.pathToEndWith('lib'), + }, + interopDefault: true, + sourceMaps: true, + jsx: true, + }); + }); + + it('handles path alias without wildcards and no baseUrl', () => { + const compilerOptions: CompilerOptions = { + paths: { + '@test/helper': ['./helper-module.ts'], + }, + }; + + const tsconfigDir = '/Users/test/project/mocks'; + const result = parseTsConfigToJitiConfig(compilerOptions, tsconfigDir); + + expect(result).toStrictEqual({ + alias: { + '@test/helper': expect.pathToEndWith('mocks/helper-module.ts'), + }, + }); + }); +}); + +describe('toFileUrl', () => { + it('returns a file:// URL for an absolute path', () => { + const absolutePath = path.resolve('some', 'config.ts'); + expect(toFileUrl(absolutePath)).toBe(pathToFileURL(absolutePath).href); + }); + + it('normalizes Windows absolute paths to file URLs for native import()', () => { + // Note: This is only needed for native import() calls, not for jiti.import() + // jiti handles Windows paths directly without URL conversion + // We should avoid native import calls in general + const windowsPath = path.win32.join('C:\\', 'Users', 'me', 'config.ts'); + expect(toFileUrl(windowsPath)).toBe('file:///C:/Users/me/config.ts'); + }); +}); diff --git a/packages/utils/src/lib/load-ts-config.ts b/packages/utils/src/lib/load-ts-config.ts new file mode 100644 index 000000000..010732ebf --- /dev/null +++ b/packages/utils/src/lib/load-ts-config.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; +import { parseJsonConfigFileContent, readConfigFile, sys } from 'typescript'; + +export function loadTargetConfig(tsConfigPath: string) { + const resolvedConfigPath = path.resolve(tsConfigPath); + const { config, error } = readConfigFile(resolvedConfigPath, sys.readFile); + + if (error) { + throw new Error( + `Error reading TypeScript config file at ${tsConfigPath}:\n${error.messageText}`, + ); + } + + const parsedConfig = parseJsonConfigFileContent( + config, + sys, + path.dirname(resolvedConfigPath), + {}, + resolvedConfigPath, + ); + + if (parsedConfig.fileNames.length === 0) { + throw new Error( + 'No files matched by the TypeScript configuration. Check your "include", "exclude" or "files" settings.', + ); + } + + return parsedConfig; +} diff --git a/testing/test-setup-config/src/lib/vitest-setup-files.ts b/testing/test-setup-config/src/lib/vitest-setup-files.ts index 5119b8bdc..e165e7168 100644 --- a/testing/test-setup-config/src/lib/vitest-setup-files.ts +++ b/testing/test-setup-config/src/lib/vitest-setup-files.ts @@ -42,6 +42,7 @@ const INT_TEST_SETUP_FILES = [ '../../testing/test-setup/src/lib/reset.setup-file.ts', '../../testing/test-setup/src/lib/logger.setup-file.ts', '../../testing/test-setup/src/lib/chrome-path.setup-file.ts', + '../../testing/test-setup/src/lib/jiti.setup-file.ts', ...CUSTOM_MATCHERS, ] as const; diff --git a/testing/test-setup/src/lib/jiti.setup-file.ts b/testing/test-setup/src/lib/jiti.setup-file.ts new file mode 100644 index 000000000..b128c7536 --- /dev/null +++ b/testing/test-setup/src/lib/jiti.setup-file.ts @@ -0,0 +1,26 @@ +import { beforeEach, vi } from 'vitest'; +import type { ImportModuleOptions } from '@code-pushup/utils'; + +// Integration test setup - disable jiti caching to avoid stale module resolution +vi.mock('@code-pushup/utils', async () => { + const utils = + await vi.importActual( + '@code-pushup/utils', + ); + + return { + ...utils, + importModule: async (options: ImportModuleOptions) => + // Disable caching in integration tests + utils.importModule({ + ...options, + fsCache: false, + moduleCache: false, + }), + }; +}); + +beforeEach(() => { + // Clear any cached modules between tests + vi.resetModules(); +});