@gfdelarue/testkit 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +109 -22
  2. package/bin/testkit-windows-owner-arm64.exe +0 -0
  3. package/bin/testkit-windows-owner-x64.exe +0 -0
  4. package/dist/app/bugs/categories.js +1 -1
  5. package/dist/app/bugs/draft.js +3 -3
  6. package/dist/app/bugs/facts.d.ts +3 -2
  7. package/dist/app/bugs/facts.js +2 -2
  8. package/dist/app/bugs/guide.js +1 -1
  9. package/dist/app/bugs/run-reading.js +1 -1
  10. package/dist/app/db-schema/prepare.js +1 -1
  11. package/dist/app/destroy/index.js +1 -1
  12. package/dist/app/doctor/index.js +1 -1
  13. package/dist/app/environments/config-driven.d.ts +5 -0
  14. package/dist/app/environments/config-driven.js +1 -0
  15. package/dist/app/environments/routing.d.ts +6 -0
  16. package/dist/app/environments/routing.js +1 -0
  17. package/dist/app/environments/supplied.d.ts +3 -0
  18. package/dist/app/environments/supplied.js +1 -0
  19. package/dist/app/file-selection.js +1 -1
  20. package/dist/app/lint/native.js +1 -1
  21. package/dist/app/lint/options.d.ts +0 -1
  22. package/dist/app/lint/options.js +1 -1
  23. package/dist/app/lint/shared.d.ts +0 -2
  24. package/dist/app/lint/shared.js +1 -1
  25. package/dist/app/package-inspection.js +1 -1
  26. package/dist/app/reports/files.d.ts +13 -0
  27. package/dist/app/reports/files.js +1 -0
  28. package/dist/app/reports/prepare.d.ts +20 -0
  29. package/dist/app/reports/prepare.js +2 -0
  30. package/dist/app/reports/publish.d.ts +21 -0
  31. package/dist/app/reports/publish.js +1 -0
  32. package/dist/app/run/engine-evidence-persistence.js +1 -1
  33. package/dist/app/run/engine-execution-cleanup.js +1 -1
  34. package/dist/app/run/engine-execution-failures.js +1 -1
  35. package/dist/app/run/engine-execution.d.ts +10 -8
  36. package/dist/app/run/engine-execution.js +1 -1
  37. package/dist/app/run/engine-orchestration.d.ts +15 -34
  38. package/dist/app/run/engine-orchestration.js +1 -1
  39. package/dist/app/run/environment.d.ts +33 -0
  40. package/dist/app/run/environment.js +1 -0
  41. package/dist/app/run/failure-details.js +1 -1
  42. package/dist/app/run/live-run.d.ts +3 -1
  43. package/dist/app/run/live-run.js +1 -1
  44. package/dist/app/run/orchestrator.d.ts +8 -1
  45. package/dist/app/run/orchestrator.js +1 -1
  46. package/dist/app/run/planning.d.ts +1 -2
  47. package/dist/app/run/planning.js +1 -1
  48. package/dist/app/run/results.d.ts +1 -1
  49. package/dist/app/run/results.js +1 -1
  50. package/dist/app/run/run-finalization.d.ts +6 -2
  51. package/dist/app/run/run-finalization.js +1 -1
  52. package/dist/app/run/runtime-fixtures.js +1 -1
  53. package/dist/app/run/runtime-task-types.d.ts +1 -1
  54. package/dist/app/run/scheduler/index.js +1 -1
  55. package/dist/app/run/scheduler/locks.d.ts +6 -0
  56. package/dist/app/run/scheduler/locks.js +1 -0
  57. package/dist/app/run/selection.d.ts +0 -1
  58. package/dist/app/run/selection.js +1 -1
  59. package/dist/app/run/setup/runtime-node-setup-protocol.js +1 -1
  60. package/dist/app/run/setup/runtime-node-setup-worker.js +1 -1
  61. package/dist/app/run/task-leases.d.ts +15 -0
  62. package/dist/app/run/task-leases.js +1 -0
  63. package/dist/app/run/worker-loop.d.ts +13 -4
  64. package/dist/app/run/worker-loop.js +1 -1
  65. package/dist/app/tracker/index.d.ts +6 -1
  66. package/dist/app/tracker/index.js +1 -1
  67. package/dist/app/typecheck/index.js +4 -4
  68. package/dist/cli/command-catalog.d.ts +19 -0
  69. package/dist/cli/command-catalog.js +1 -1
  70. package/dist/cli/command-registry.js +1 -1
  71. package/dist/cli/commands/report/prepare.d.ts +12 -0
  72. package/dist/cli/commands/report/prepare.js +5 -0
  73. package/dist/cli/commands/report/publish.d.ts +13 -0
  74. package/dist/cli/commands/report/publish.js +2 -0
  75. package/dist/cli/json-errors.js +1 -1
  76. package/dist/cli/operations/run/operation.d.ts +3 -1
  77. package/dist/cli/operations/run/operation.js +2 -2
  78. package/dist/cli/renderers/doctor/text.js +1 -1
  79. package/dist/cli/renderers/run/formatting.js +1 -1
  80. package/dist/config/authoring/index.d.ts +4 -13
  81. package/dist/config/authoring/index.js +1 -1
  82. package/dist/config/authoring/preset-env.js +1 -1
  83. package/dist/config/loader/config-loader.js +1 -1
  84. package/dist/config/loader/database.js +1 -1
  85. package/dist/config/loader/decode-execution.d.ts +0 -2
  86. package/dist/config/loader/decode-execution.js +1 -1
  87. package/dist/config/loader/discover-tests.js +1 -1
  88. package/dist/config/loader/discovery.d.ts +0 -1
  89. package/dist/config/loader/discovery.js +2 -2
  90. package/dist/config/loader/env.js +1 -1
  91. package/dist/config/loader/execution-model.d.ts +0 -1
  92. package/dist/config/loader/execution-model.js +1 -1
  93. package/dist/config/loader/file-metadata.d.ts +1 -1
  94. package/dist/config/loader/file-metadata.js +1 -1
  95. package/dist/config/loader/groups.d.ts +33 -0
  96. package/dist/config/loader/groups.js +1 -0
  97. package/dist/config/loader/load.d.ts +0 -2
  98. package/dist/config/loader/load.js +1 -1
  99. package/dist/config/loader/runtime-isolation.js +1 -1
  100. package/dist/config/loader/runtime.d.ts +0 -9
  101. package/dist/config/loader/runtime.js +1 -1
  102. package/dist/config/loader/skip-config.js +1 -1
  103. package/dist/config/loader/validation.d.ts +1 -2
  104. package/dist/config/loader/validation.js +1 -1
  105. package/dist/config/loader/values.d.ts +0 -1
  106. package/dist/config/loader/values.js +1 -1
  107. package/dist/database/activity-gate.js +1 -1
  108. package/dist/database/admin.js +2 -2
  109. package/dist/database/lifecycle-cleanup.d.ts +1 -0
  110. package/dist/database/lifecycle-cleanup.js +1 -1
  111. package/dist/database/local-postgres.js +1 -1
  112. package/dist/database/naming.js +1 -1
  113. package/dist/database/schema-operations.d.ts +1 -1
  114. package/dist/database/schema-operations.js +1 -1
  115. package/dist/database/schema-source.js +3 -3
  116. package/dist/database/source-url.d.ts +0 -1
  117. package/dist/database/source-url.js +2 -2
  118. package/dist/database/state.js +1 -1
  119. package/dist/database/steps.js +3 -3
  120. package/dist/domain/file-timeout.d.ts +0 -3
  121. package/dist/domain/file-timeout.js +1 -1
  122. package/dist/domain/operation-display.js +1 -1
  123. package/dist/domain/test-outcomes.d.ts +0 -1
  124. package/dist/domain/test-outcomes.js +1 -1
  125. package/dist/engines/adapters/evidence-identity.js +1 -1
  126. package/dist/engines/adapters/execution.d.ts +0 -1
  127. package/dist/engines/adapters/execution.js +1 -1
  128. package/dist/engines/api/capabilities.d.ts +0 -10
  129. package/dist/engines/api/capabilities.js +1 -1
  130. package/dist/engines/api/execution-scope.js +1 -1
  131. package/dist/engines/api/k6-runtime-input.js +1 -1
  132. package/dist/engines/bundle-cache.js +1 -1
  133. package/dist/engines/contracts/fixture-manifest.js +1 -1
  134. package/dist/engines/definitions.d.ts +1 -0
  135. package/dist/engines/definitions.js +1 -1
  136. package/dist/engines/installations/playwright-resolution-child.js +1 -1
  137. package/dist/engines/installations/playwright.js +1 -1
  138. package/dist/engines/k6/adapter/evidence.js +4 -4
  139. package/dist/engines/k6/adapter/executor.js +1 -1
  140. package/dist/engines/k6/adapter/native-summary.js +1 -1
  141. package/dist/engines/k6/runtime/checks.js +1 -1
  142. package/dist/engines/k6/runtime/dal-fixtures.js +1 -1
  143. package/dist/engines/k6/runtime/dal-suite.js +1 -1
  144. package/dist/engines/k6/runtime/http-suite-runtime.js +1 -1
  145. package/dist/engines/k6/runtime/http.js +1 -1
  146. package/dist/engines/k6/runtime/shared/db-normalization.js +1 -1
  147. package/dist/engines/k6/runtime/shared/fixture-engine.js +2 -2
  148. package/dist/engines/k6/runtime/shared/http-parsing.js +3 -3
  149. package/dist/engines/managed-module-resolution-policy-input.d.ts +0 -1
  150. package/dist/engines/managed-module-resolution-policy-input.js +1 -1
  151. package/dist/engines/managed-module-resolution-policy.js +1 -1
  152. package/dist/engines/node-vitest/adapter/adapter.js +1 -1
  153. package/dist/engines/node-vitest/adapter/evidence.js +2 -2
  154. package/dist/engines/node-vitest/adapter/workspace.js +22 -21
  155. package/dist/engines/node-vitest/runtime/contract/index.js +1 -1
  156. package/dist/engines/node-vitest/runtime/data/http.js +1 -1
  157. package/dist/engines/node-vitest/runtime/data/index.d.ts +2 -2
  158. package/dist/engines/node-vitest/runtime/data/index.js +1 -1
  159. package/dist/engines/node-vitest/runtime/data/report.js +5 -5
  160. package/dist/engines/node-vitest/runtime/data/scenario.d.ts +0 -1
  161. package/dist/engines/node-vitest/runtime/data/scenario.js +2 -2
  162. package/dist/engines/node-vitest/runtime/data/types.d.ts +1 -29
  163. package/dist/engines/node-vitest/runtime/runtime-input.js +1 -1
  164. package/dist/engines/playwright/adapter/adapter.js +1 -1
  165. package/dist/engines/playwright/adapter/config.d.ts +1 -1
  166. package/dist/engines/playwright/adapter/config.js +4 -4
  167. package/dist/engines/playwright/adapter/evidence-validation.d.ts +0 -3
  168. package/dist/engines/playwright/adapter/evidence-validation.js +1 -1
  169. package/dist/engines/playwright/adapter/evidence.js +1 -1
  170. package/dist/engines/playwright/runtime/config-file.js +1 -1
  171. package/dist/engines/playwright/runtime/config.js +1 -1
  172. package/dist/engines/playwright/runtime/runtime-input.d.ts +2 -2
  173. package/dist/engines/playwright/runtime/runtime-input.js +16 -12
  174. package/dist/environment/lifecycle-artifacts.js +1 -1
  175. package/dist/environment/lifecycle-controller.d.ts +1 -11
  176. package/dist/environment/lifecycle-controller.js +1 -1
  177. package/dist/environment/lifecycle.d.ts +14 -11
  178. package/dist/environment/lifecycle.js +1 -1
  179. package/dist/environment/planning-types.d.ts +0 -2
  180. package/dist/environment/ports.d.ts +10 -0
  181. package/dist/environment/ports.js +1 -0
  182. package/dist/environment/ready-environment.d.ts +2 -5
  183. package/dist/environment/ready-environment.js +1 -1
  184. package/dist/environment/run-environment.d.ts +1 -0
  185. package/dist/environment/run-environment.js +1 -1
  186. package/dist/environment/runtime-contexts.d.ts +11 -7
  187. package/dist/environment/runtime-contexts.js +1 -1
  188. package/dist/environment/runtime-manager.d.ts +1 -9
  189. package/dist/environment/runtime-manager.js +1 -1
  190. package/dist/environment/runtime-planning.js +1 -1
  191. package/dist/environment/runtime-preparation.js +1 -1
  192. package/dist/environment/runtime-service.d.ts +0 -6
  193. package/dist/environment/services.d.ts +11 -3
  194. package/dist/environment/services.js +3 -1
  195. package/dist/environment/state.d.ts +1 -4
  196. package/dist/environment/state.js +1 -1
  197. package/dist/environment/template.d.ts +6 -22
  198. package/dist/environment/template.js +1 -1
  199. package/dist/evidence/artifact-config.js +1 -1
  200. package/dist/evidence/artifact-validation.d.ts +1 -2
  201. package/dist/evidence/artifact-validation.js +1 -1
  202. package/dist/evidence/artifacts.js +2 -2
  203. package/dist/evidence/bundles.js +1 -1
  204. package/dist/evidence/environment-record.d.ts +22 -0
  205. package/dist/evidence/environment-record.js +1 -0
  206. package/dist/evidence/index.d.ts +1 -1
  207. package/dist/evidence/logs.js +2 -2
  208. package/dist/evidence/operation-timings.js +1 -1
  209. package/dist/evidence/read-fields.d.ts +0 -1
  210. package/dist/evidence/read-fields.js +1 -1
  211. package/dist/evidence/read-run.js +1 -1
  212. package/dist/evidence/reading.js +1 -1
  213. package/dist/evidence/reporting-types.d.ts +19 -0
  214. package/dist/evidence/reporting.d.ts +2 -2
  215. package/dist/evidence/reporting.js +1 -1
  216. package/dist/evidence/storage.d.ts +0 -2
  217. package/dist/evidence/storage.js +2 -2
  218. package/dist/evidence/timing.js +1 -1
  219. package/dist/kernel/error-serialization.d.ts +2 -0
  220. package/dist/kernel/error-serialization.js +1 -1
  221. package/dist/kernel/unknown-values.d.ts +5 -0
  222. package/dist/kernel/unknown-values.js +1 -1
  223. package/dist/process/environment.js +1 -1
  224. package/dist/process/execa-bundle.json +3 -3
  225. package/dist/process/files/json-file.d.ts +0 -1
  226. package/dist/process/files/json-file.js +1 -1
  227. package/dist/process/files/paths.d.ts +2 -0
  228. package/dist/process/files/paths.js +1 -1
  229. package/dist/process/host/metadata.js +1 -1
  230. package/dist/process/host/platform.js +1 -1
  231. package/dist/process/host-process.js +1 -1
  232. package/dist/process/lifecycle-fence.d.ts +1 -2
  233. package/dist/process/lifecycle-fence.js +1 -1
  234. package/dist/process/locks.js +2 -2
  235. package/dist/process/process-group.js +1 -1
  236. package/dist/process/process-identity.js +1 -1
  237. package/dist/process/process.js +30 -30
  238. package/dist/process/readiness.d.ts +7 -1
  239. package/dist/process/readiness.js +1 -1
  240. package/dist/process/recovery.js +1 -1
  241. package/dist/process/runs/manifest.js +1 -1
  242. package/dist/process/runs/ownership.js +1 -1
  243. package/dist/process/runs/product-state.js +1 -1
  244. package/dist/process/runs/stop.js +1 -1
  245. package/dist/process/stoppable-settlement.d.ts +26 -0
  246. package/dist/process/stoppable-settlement.js +1 -0
  247. package/dist/process/url-redaction.d.ts +1 -0
  248. package/dist/process/url-redaction.js +1 -0
  249. package/dist/process/windows-native.d.ts +6 -0
  250. package/dist/process/windows-native.js +1 -1
  251. package/dist/repo/ci-environment.js +1 -1
  252. package/dist/repo/fingerprint-policy.d.ts +0 -2
  253. package/dist/repo/fingerprint-policy.js +1 -1
  254. package/dist/repo/physical-tree.js +1 -1
  255. package/dist/repo/publish-directory.d.ts +20 -0
  256. package/dist/repo/publish-directory.js +3 -0
  257. package/dist/repo/state.js +4 -4
  258. package/dist/steps/build-config.d.ts +0 -1
  259. package/dist/steps/build-config.js +1 -1
  260. package/dist/steps/configured-steps.js +1 -1
  261. package/dist/steps/environment.d.ts +0 -2
  262. package/dist/steps/environment.js +1 -1
  263. package/dist/steps/index.d.ts +1 -1
  264. package/dist/steps/run.js +2 -2
  265. package/dist/toolchains/archive.js +1 -1
  266. package/dist/toolchains/index.js +3 -3
  267. package/dist/toolchains/node-provisioning.js +2 -2
  268. package/dist/toolchains/vitest/index.js +1 -1
  269. package/docs/bug-records.md +54 -52
  270. package/docs/engine-architecture.md +39 -11
  271. package/package.json +1 -1
  272. package/testkit-config-schema.md +43 -5
  273. package/dist/app/run/playwright-configuration.d.ts +0 -2
  274. package/dist/app/run/playwright-configuration.js +0 -1
  275. package/dist/environment/readiness.d.ts +0 -12
  276. package/dist/environment/readiness.js +0 -1
package/dist/steps/run.js CHANGED
@@ -1,3 +1,3 @@
1
- import{formatCapturedFailure as g,writeCapturedLine as S}from"../evidence/step-output.js";import w from"crypto";import m from"fs";import{createRequire as M}from"module";import u from"path";import{fileURLToPath as D,pathToFileURL as k}from"url";import"../config/runtime/build-assets.js";import{buildWithEsbuildRetry as I}from"../toolchains/esbuild.js";import{isBareModuleSpecifier as j,parseModuleSpecifier as f,resolveConfiguredCwd as C,resolveConfiguredPath as N}from"./configured-steps.js";import{CONFIGURED_STEP_TESTKIT_MODULE_DIST_PATHS as U,isTestkitPackageSpecifier as y,requireConfiguredStepTestkitModuleSpecifier as L}from"./configured-step-testkit-modules.js";export{collectConfiguredInputs,parseModuleSpecifier,resolveConfiguredCwd,resolveConfiguredPath}from"./configured-steps.js";import"../toolchains/index.js";import{resolvePosixCommandShell as R}from"../process/index.js";import{captureOutput as h,managedHostProcessInfrastructureError as $,startManagedHostProcess as b}from"../process/index.js";const T=String,E=u.resolve(u.dirname(D(import.meta.url)),"..",".."),F=Object.fromEntries(Object.entries(U).map(([e,t])=>[e,u.join(E,"dist",...t)+".js"])),A=u.join(E,"dist","steps","module-runner.js");export async function runConfiguredStep(e,t,r,o,n){const i=C(e.productDir,t.cwd);if(t.kind==="command"){const s=configuredCommandInvocation(t.cmd,r),d=b({recoveryIdentity:"none",args:s.args,cwd:i,displayLabel:`Configured step ${e.name}`,env:r,executable:s.command,windowsVerbatimArguments:s.windowsVerbatimArguments,outputMode:"caller-drained",signal:n.signal,timeoutMs:n.timeoutMs});await _(d,n);return}if(!B(t))return G(t);const{exportName:c}=f(t.specifier),a={args:t.args??{},databaseUrl:r.DATABASE_URL||null,productDir:e.productDir,cwd:i,serviceName:e.name,env:{...r},ownedContainer:n.ownedContainer||null,runtimeId:e.runtimeId||null,stateDir:e.stateDir,prepareDir:e.testkit.prepareDir||null},p=await K(e.productDir,t),l=u.join(e.productDir,".testkit","_template-steps",`${p.cacheKey}.${String(process.pid)}.${w.randomUUID()}.context.json`);m.writeFileSync(l,JSON.stringify(a));try{const s=b({recoveryIdentity:"none",args:[A,p.outputFile,c,l],cwd:i,displayLabel:`Configured module step ${e.name}`,env:r,executable:o?.nodeExecutable||process.execPath,outputMode:"caller-drained",signal:n.signal,timeoutMs:n.timeoutMs});await _(s,n)}finally{m.rmSync(l,{force:!0})}}export function configuredCommandInvocation(e,t,r=process.platform){return r==="win32"?{command:t.ComSpec?.trim()||"cmd.exe",args:["/d","/s","/c",`"${e}"`],windowsVerbatimArguments:!0}:{command:R(t),args:["-lc",e]}}export function applySetupNodeOptions(e={}){const t=T(e.TESTKIT_SETUP_NODE_OPTIONS||"").trim();return t?{...e,NODE_OPTIONS:[e.NODE_OPTIONS,t].filter(Boolean).join(" ")}:e}async function K(e,t){const{modulePath:r}=f(t.specifier),o=v(e,t),n=u.join(e,".testkit","_template-steps");m.mkdirSync(n,{recursive:!0});const i=q(o);if(y(r))return{outputFile:o,cacheKey:i};const c=u.join(n,`${u.basename(r).replace(/\W+/g,"-")}-${i.slice(0,12)}.mjs`);return await I({absWorkingDir:e,bundle:!0,entryPoints:[o],format:"esm",legalComments:"none",outfile:c,platform:"node",sourcemap:"inline",target:"es2020",plugins:[H()]}),{outputFile:c,cacheKey:i}}function v(e,t){const{modulePath:r}=f(t.specifier);if(y(r))return P(r);const o=N(e,t.cwd,r);if(m.existsSync(o)||!j(r))return o;const n=C(e,t.cwd);return M(u.join(n,"__testkit__.cjs")).resolve(r)}function q(e){const t=m.readFileSync(e,"utf8");return w.createHash("sha256").update(e).update("\0").update(t).digest("hex")}function H(){return{name:"testkit-configured-step-modules",setup(e){e.onResolve({filter:/^@gfdelarue\/testkit(?:\/.*)?$/},t=>({external:!0,path:k(P(t.path)).href}))}}}function P(e){const t=L(e);return F[t]}async function _(e,{livePrefix:t="",liveWriter:r=null,logRecord:o=null}={}){const n=[],i=[],c=[h(O(e.stdout),{livePrefix:t,liveWriter:r,onLine:d=>{n.push(d),S(o,"stdout",d)}}),h(O(e.stderr),{livePrefix:t,liveWriter:r,onLine:d=>{i.push(d),S(o,"stderr",d)}})],a=await e.completion;await Promise.all(c);const p=n.join(`
1
+ import{formatCapturedFailure as g,writeCapturedLine as S}from"../evidence/step-output.js";import w from"crypto";import m from"fs";import{createRequire as M}from"module";import u from"path";import{fileURLToPath as D,pathToFileURL as k}from"url";import"../config/runtime/build-assets.js";import{buildWithEsbuildRetry as I}from"../toolchains/esbuild.js";import{isBareModuleSpecifier as N,parseModuleSpecifier as f,resolveConfiguredCwd as C,resolveConfiguredPath as j}from"./configured-steps.js";import{CONFIGURED_STEP_TESTKIT_MODULE_DIST_PATHS as U,isTestkitPackageSpecifier as h,requireConfiguredStepTestkitModuleSpecifier as L}from"./configured-step-testkit-modules.js";import{readDynamicProperty as y}from"../kernel/unknown-values.js";export{collectConfiguredInputs,parseModuleSpecifier,resolveConfiguredCwd,resolveConfiguredPath}from"./configured-steps.js";import"../toolchains/index.js";import{resolvePosixCommandShell as R}from"../process/index.js";import{captureOutput as T,managedHostProcessInfrastructureError as $,startManagedHostProcess as b}from"../process/index.js";const E=String,P=u.resolve(u.dirname(D(import.meta.url)),"..",".."),F=Object.fromEntries(Object.entries(U).map(([e,t])=>[e,u.join(P,"dist",...t)+".js"])),v=u.join(P,"dist","steps","module-runner.js");export async function runConfiguredStep(e,t,r,n,o){const i=C(e.productDir,t.cwd);if(t.kind==="command"){const s=configuredCommandInvocation(t.cmd,r),d=b({recoveryIdentity:"none",args:s.args,cwd:i,displayLabel:`Configured step ${e.name}`,env:r,executable:s.command,windowsVerbatimArguments:s.windowsVerbatimArguments,outputMode:"caller-drained",signal:o.signal,timeoutMs:o.timeoutMs});await O(d,o);return}if(!B(t))return G(t);const{exportName:c}=f(t.specifier),a={args:t.args??{},databaseUrl:r.DATABASE_URL||null,productDir:e.productDir,cwd:i,serviceName:e.name,env:{...r},ownedContainer:o.ownedContainer||null,runtimeId:e.runtimeId||null,stateDir:e.stateDir,prepareDir:e.testkit.prepareDir||null},p=await A(e.productDir,t),l=u.join(e.productDir,".testkit","_template-steps",`${p.cacheKey}.${String(process.pid)}.${w.randomUUID()}.context.json`);m.writeFileSync(l,JSON.stringify(a));try{const s=b({recoveryIdentity:"none",args:[v,p.outputFile,c,l],cwd:i,displayLabel:`Configured module step ${e.name}`,env:r,executable:n?.nodeExecutable||process.execPath,outputMode:"caller-drained",signal:o.signal,timeoutMs:o.timeoutMs});await O(s,o)}finally{m.rmSync(l,{force:!0})}}export function configuredCommandInvocation(e,t,r=process.platform){return r==="win32"?{command:t.ComSpec?.trim()||"cmd.exe",args:["/d","/s","/c",`"${e}"`],windowsVerbatimArguments:!0}:{command:R(t),args:["-lc",e]}}export function applySetupNodeOptions(e={}){const t=E(e.TESTKIT_SETUP_NODE_OPTIONS||"").trim();return t?{...e,NODE_OPTIONS:[e.NODE_OPTIONS,t].filter(Boolean).join(" ")}:e}async function A(e,t){const{modulePath:r}=f(t.specifier),n=K(e,t),o=u.join(e,".testkit","_template-steps");m.mkdirSync(o,{recursive:!0});const i=q(n);if(h(r))return{outputFile:n,cacheKey:i};const c=u.join(o,`${u.basename(r).replace(/\W+/g,"-")}-${i.slice(0,12)}.mjs`);return await I({absWorkingDir:e,bundle:!0,entryPoints:[n],format:"esm",legalComments:"none",outfile:c,platform:"node",sourcemap:"inline",target:"es2020",plugins:[H()]}),{outputFile:c,cacheKey:i}}function K(e,t){const{modulePath:r}=f(t.specifier);if(h(r))return _(r);const n=j(e,t.cwd,r);if(m.existsSync(n)||!N(r))return n;const o=C(e,t.cwd);return M(u.join(o,"__testkit__.cjs")).resolve(r)}function q(e){const t=m.readFileSync(e,"utf8");return w.createHash("sha256").update(e).update("\0").update(t).digest("hex")}function H(){return{name:"testkit-configured-step-modules",setup(e){e.onResolve({filter:/^@gfdelarue\/testkit(?:\/.*)?$/},t=>({external:!0,path:k(_(t.path)).href}))}}}function _(e){const t=L(e);return F[t]}async function O(e,{livePrefix:t="",liveWriter:r=null,logRecord:n=null}={}){const o=[],i=[],c=[T(x(e.stdout),{livePrefix:t,liveWriter:r,onLine:d=>{o.push(d),S(n,"stdout",d)}}),T(x(e.stderr),{livePrefix:t,liveWriter:r,onLine:d=>{i.push(d),S(n,"stderr",d)}})],a=await e.completion;await Promise.all(c);const p=o.join(`
2
2
  `),l=i.join(`
3
- `),s=$("Configured step",a);if(s)throw new Error(g(s.message,p,l,"Configured step process supervision failed"),{cause:s});if(a.exitCode!==0)throw new Error(g(a.exitSignal?`Configured step terminated by ${a.exitSignal}`:null,p,l,`Step failed with exit code ${String(a.exitCode)}`))}function O(e){return!e||!("read"in e)?null:e}function B(e){return x(e,"kind")==="module"}function G(e){throw new Error(`Unsupported template step kind "${T(x(e,"kind"))}"`)}function x(e,t){if(!((typeof e!="object"||e===null)&&typeof e!="function"))return Reflect.get(e,t)}
3
+ `),s=$("Configured step",a);if(s)throw new Error(g(s.message,p,l,"Configured step process supervision failed"),{cause:s});if(a.exitCode!==0)throw new Error(g(a.exitSignal?`Configured step terminated by ${a.exitSignal}`:null,p,l,`Step failed with exit code ${String(a.exitCode)}`))}function x(e){return!e||!("read"in e)?null:e}function B(e){return y(e,"kind")==="module"}function G(e){throw new Error(`Unsupported template step kind "${E(y(e,"kind"))}"`)}
@@ -1 +1 @@
1
- import u from"node:fs";import{createRequire as F}from"node:module";import y from"node:path";import{pipeline as I}from"node:stream/promises";import{isPathWithin as g}from"../process/files/paths.js";export const ARCHIVE_FORMATS=["tar","tar.gz","zip"],MAX_ARCHIVE_ENTRIES=1e5;export class UnsafeArchiveError extends Error{code="TESTKIT_ARCHIVE_UNSAFE";constructor(t,i){super(t,i),this.name="UnsafeArchiveError"}}const N=F(import.meta.url);let D,M;function C(){return D??=N("tar")}function P(){return M??=N("yauzl")}const U=new Set(["File","OldFile","ContiguousFile"]),Z=new Set(["Directory","GNUDumpDir"]),S=4096,W=255,j=128,X=128*1024*1024,Y=/[<>:"|?*]/u,q=/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu,T=new TextDecoder("utf-8",{fatal:!0});export class TarArchiveInspector{formats=["tar","tar.gz"];async inspect(t,i,n={}){x(this.formats,i);const r=C(),o=E(n),a=[],s=u.createReadStream(t),c=new r.Parser({onReadEntry(l){if(n.signal?.aborted){const f=p(n.signal);s.destroy(f),c.abort(f);return}try{o.countEntry();const f=G(l);a.push(f),o.addPayload(f)}catch(f){const h=R(f,"Tar archive processing failed");s.destroy(h),c.abort(h);return}l.resume()},strict:!0});return await H(s,c,n.signal),a}}export class ZipArchiveExtractor{formats=["zip"];async inspect(t,i,n={}){x(this.formats,i);const r=E(n),o=[];return await O(t,n.signal,async(a,s)=>{r.countEntry();const c=await $(s,a,n.signal);o.push(c),r.addPayload(c)}),o}async extract(t,i,n,r={}){x(this.formats,n);const o=E(r,"extraction"),a=y.resolve(i),s=await this.inspect(t,n,r);validateArchiveEntryTopology(s),await re(a);const c=[];await O(t,r.signal,async(f,h)=>{const d=await $(h,f,r.signal);o.countEntry(),o.addPayload(d);const w=assertSafeArchiveEntry(d),m=y.resolve(a,w);if(!g(a,m))throw new UnsafeArchiveError(`Archive path escapes its destination: ${JSON.stringify(w)}`);if(d.kind==="directory"){await A(a,m,r.signal),await u.promises.chmod(m,d.mode);return}if(await A(a,y.dirname(m),r.signal),d.kind==="symlink"){if(d.linkTarget===void 0)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(w)} has no target`);c.push({destinationPath:m,entry:{...d,kind:"symlink",linkTarget:d.linkTarget},normalized:w});return}const _=await z(h,f);await I(_,u.createWriteStream(m,{flags:"wx",mode:d.mode}),{signal:r.signal}),await u.promises.chmod(m,d.mode)});for(const f of c)throwIfAborted(r.signal),await A(a,y.dirname(f.destinationPath),r.signal),await u.promises.symlink(f.entry.linkTarget,f.destinationPath,ne(f.normalized,s));const l=await u.promises.realpath(a);for(const f of c)await assertInstalledSymlinkTarget(f.destinationPath,f.normalized,l);throwIfAborted(r.signal)}}function E(e,t="inspection"){const i=Math.min(e.maxEntries??MAX_ARCHIVE_ENTRIES,MAX_ARCHIVE_ENTRIES),n=e.maxUnpackedSizeBytes??Number.MAX_SAFE_INTEGER,r=t==="extraction"?" during extraction":"";let o=0,a=0;return{countEntry(){if(t==="inspection"?o>=i:o+1>i)throw new UnsafeArchiveError(`Archive exceeds the ${i.toString()} entry limit${r}`);o+=1},addPayload(s){if(!(s.kind!=="file"&&s.kind!=="symlink")&&(a+=s.sizeBytes,a>n))throw new UnsafeArchiveError(`Archive exceeds its configured unpacked byte limit${r}`)}}}async function H(e,t,i){await new Promise((n,r)=>{let o=!1;const a=()=>{const l=p(i);e.destroy(l),t.abort(l)},s=l=>{o||(o=!0,i?.removeEventListener("abort",a),r(l))},c=()=>{o||(o=!0,i?.removeEventListener("abort",a),n())};e.once("error",s),t.once("error",s),t.once("end",c),i?.addEventListener("abort",a,{once:!0}),e.pipe(t)}),throwIfAborted(i)}export function validateArchiveEntryTopology(e){const t=new Map,i=new Map;for(const r of e){const o=assertSafeArchiveEntry(r),a=portableCollisionKey(o),s=i.get(a);if(s!==void 0)throw new UnsafeArchiveError(`Archive contains colliding entries ${JSON.stringify(s)} and ${JSON.stringify(o)}`);i.set(a,o),t.set(o,r)}const n=v(t);for(const[r,o]of t){for(const a of J(r)){const s=t.get(a);if(s?.kind==="symlink")throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(r)} would write through symbolic-link ancestor ${JSON.stringify(a)}`);if(s?.kind==="file")throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(r)} has regular-file ancestor ${JSON.stringify(a)}`)}o.kind==="symlink"&&B(r,t,n)}}export function assertSafeArchiveEntry(e){const t=normalizeArchivePath(e.path);if(e.kind==="forbidden")throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has forbidden type ${JSON.stringify(e.rawType)}`);if(requireSafeMode(e.mode,t),requireSafeSize(e.sizeBytes,t),e.kind==="directory"&&e.sizeBytes!==0)throw new UnsafeArchiveError(`Archive directory ${JSON.stringify(t)} has non-zero size`);if(e.kind==="symlink"){if(e.linkTarget===void 0)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(t)} has no target`);const i=normalizeSymlinkTarget(e.linkTarget,t),n=Buffer.byteLength(i,"utf8");if(e.sizeBytes!==n)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(t)} has inconsistent target size`)}else if(e.linkTarget!==void 0)throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has unexpected link target data`);return t}export function normalizeArchivePath(e){k(e,"archive path");const t=e.replace(/\/+$/u,"");if(!t||t.includes("\\")||t.startsWith("/")||Buffer.byteLength(t,"utf8")>S||K(t))throw new UnsafeArchiveError(`Archive path is not a portable relative path: ${JSON.stringify(e)}`);const i=t.split("/");if(i.length>j)throw new UnsafeArchiveError(`Archive path has too many segments: ${JSON.stringify(e)}`);for(const n of i)ie(n,e);return i.join("/")}function K(e){for(let t=0;t<e.length;t+=1){const i=e.charCodeAt(t);if(i<=31||i===127)return!0}return!1}function G(e){const t=te(e.type),i=t==="symlink"?e.linkpath:void 0;return{kind:t,...i===void 0?{}:{linkTarget:i},mode:e.mode,path:e.path,rawType:e.type,sizeBytes:t==="symlink"&&i!==void 0?Buffer.byteLength(i,"utf8"):e.size}}function V(e){const t=e.externalFileAttributes>>>16&65535,i=t&61440,n=e.versionMadeBy>>>8,r=e.fileName.endsWith("/")||i===16384||n===0&&(e.externalFileAttributes&16)!==0,o=i===40960,a=i!==0&&i!==16384&&i!==32768&&i!==40960,s=e.isEncrypted()||a?"forbidden":o?"symlink":r?"directory":"file",c=t&511;return{kind:s,mode:c||(r?493:420),path:e.fileName,rawType:e.isEncrypted()?"encrypted":o?"SymbolicLink":a?`mode-${i.toString(8)}`:r?"Directory":"File",sizeBytes:e.uncompressedSize}}async function $(e,t,i){const n=V(t);if(n.kind!=="symlink")return n;if(n.sizeBytes>S)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(n.path)} exceeds the portable target size limit`);const r=await z(e,t),o=await oe(r,S,i);if(o.byteLength!==n.sizeBytes)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(n.path)} has inconsistent target size`);return{...n,linkTarget:decodeUtf8(o,`archive symbolic link ${n.path}`),sizeBytes:o.byteLength}}async function O(e,t,i){const{zipFile:n,buffered:r}=await Q(e,t);let o=Promise.resolve();try{await new Promise((a,s)=>{let c=!1;const l=()=>{c||(c=!0,t?.removeEventListener("abort",h),a())},f=d=>{c||(c=!0,t?.removeEventListener("abort",h),s(R(d,"ZIP archive processing failed")))},h=()=>{f(p(t))};if(n.once("error",f),n.once("end",l),n.on("entry",d=>{o=Promise.resolve().then(()=>i(d,n)).then(()=>{c||n.readEntry()}).catch(f)}),t?.addEventListener("abort",h,{once:!0}),t?.aborted){h();return}n.readEntry()})}finally{await o,r?n.close():await new Promise((a,s)=>{const c=()=>{n.removeListener("error",l),a()},l=f=>{n.removeListener("close",c),s(f)};n.once("close",c),n.once("error",l),n.close()})}}async function Q(e,t){const i=await ee(e,t);throwIfAborted(t);const{fromBuffer:n,open:r}=P();return new Promise((o,a)=>{const s={autoClose:!1,decodeStrings:!0,lazyEntries:!0,strictFileNames:!0,validateEntrySizes:!0},c=(l,f)=>{if(l!==null){a(l);return}o({zipFile:f,buffered:i!==void 0})};i===void 0?r(e,s,c):n(i,s,c)})}async function ee(e,t){throwIfAborted(t);const i=await u.promises.open(e,"r");try{const n=await i.stat();if(throwIfAborted(t),n.size>X)return;const r=Buffer.allocUnsafe(n.size+1);let o=0;for(;o<r.length;){throwIfAborted(t);const a=Math.min(1024*1024,r.length-o),{bytesRead:s}=await i.read(r,o,a,null);if(throwIfAborted(t),s===0)return r.subarray(0,o);o+=s}return}finally{await i.close()}}function z(e,t){return new Promise((i,n)=>{e.openReadStream(t,(r,o)=>{if(r!==null){n(r);return}i(o)})})}function te(e){return U.has(e)?"file":Z.has(e)?"directory":e==="SymbolicLink"?"symlink":"forbidden"}function ie(e,t){if(!e||e==="."||e===".."||e.trim()!==e||e.endsWith(".")||e.normalize("NFC")!==e||Buffer.byteLength(e,"utf8")>W||Y.test(e)||q.test(e))throw new UnsafeArchiveError(`Archive path contains a non-portable segment: ${JSON.stringify(t)}`)}export function portableCollisionKey(e){return e.normalize("NFC").toLowerCase()}export function normalizeSymlinkTarget(e,t){k(e,`symbolic link ${t}`);const i=normalizeArchivePath(e);if(i!==e)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(t)} has a non-canonical relative target: ${JSON.stringify(e)}`);return b(t,i),i}function J(e){const t=e.split("/"),i=[];for(let n=1;n<t.length;n+=1)i.push(t.slice(0,n).join("/"));return i}function ne(e,t){const i=new Map(t.map(n=>[normalizeArchivePath(n.path),n]));return B(e,i,v(i))==="directory"?"dir":"file"}function B(e,t,i=v(t)){const n=t.get(e);if(n?.kind!=="symlink"||n.linkTarget===void 0)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has no target`);let r=b(e,normalizeSymlinkTarget(n.linkTarget,e));const o=new Set;for(let a=0;a<=t.size;a+=1){const s=r.split("/");let c=!1;for(let f=0;f<s.length;f+=1){const h=s.slice(0,f+1).join("/"),d=t.get(h);if(d?.kind==="symlink"){if(d.linkTarget===void 0||o.has(h))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has a cyclic target`);o.add(h);const w=b(h,normalizeSymlinkTarget(d.linkTarget,h)),m=s.slice(f+1).join("/");r=m?normalizeArchivePath(`${w}/${m}`):w,c=!0;break}if(d?.kind==="file"&&f<s.length-1)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} traverses regular file ${JSON.stringify(h)}`);if(d===void 0&&!i.has(h))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has dangling target ${JSON.stringify(n.linkTarget)}`)}if(c)continue;const l=t.get(r);if(l?.kind==="file")return"file";if(l?.kind==="directory"||i.has(r))return"directory";throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has dangling target ${JSON.stringify(n.linkTarget)}`)}throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has a cyclic target`)}function v(e){const t=new Set;for(const[i,n]of e){for(const r of J(i))t.add(r);n.kind==="directory"&&t.add(i)}return t}function b(e,t){if(y.posix.isAbsolute(t))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has an absolute target`);const i=y.posix.join(y.posix.dirname(e),t);if(i===".."||i.startsWith("../")||y.posix.isAbsolute(i))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} escapes the archive root`);return normalizeArchivePath(i)}export async function assertInstalledSymlinkTarget(e,t,i){let n;try{n=await u.promises.realpath(e)}catch(o){throw new UnsafeArchiveError(`Installed archive symbolic link ${JSON.stringify(t)} is dangling or cyclic`,{cause:o})}if(!g(i,n))throw new UnsafeArchiveError(`Installed archive symbolic link ${JSON.stringify(t)} resolves outside its root`);const r=await u.promises.lstat(n);if(!r.isFile()&&!r.isDirectory())throw new UnsafeArchiveError(`Installed archive symbolic link ${JSON.stringify(t)} resolves to a special file`)}async function re(e){const t=await u.promises.lstat(e);if(!t.isDirectory()||t.isSymbolicLink())throw new UnsafeArchiveError("Archive extraction root must be a real directory");if((await u.promises.readdir(e)).length!==0)throw new UnsafeArchiveError("Archive extraction root must be empty")}async function A(e,t,i){if(!g(e,t))throw new UnsafeArchiveError(`Archive extraction path escapes its destination: ${JSON.stringify(t)}`);const n=y.relative(e,t);let r=e;for(const[o,a]of n.split(y.sep).filter(Boolean).entries()){o>0&&o%16===0&&await new Promise(c=>setImmediate(c)),throwIfAborted(i),r=y.join(r,a);let s;try{s=u.lstatSync(r)}catch(c){if(!L(c,"ENOENT"))throw c;try{u.mkdirSync(r,{mode:493})}catch(l){if(!L(l,"EEXIST"))throw l}s=u.lstatSync(r)}if(!s.isDirectory()||s.isSymbolicLink())throw new UnsafeArchiveError(`Archive refuses to write through non-directory path ${JSON.stringify(r)}`)}}async function oe(e,t,i){const n=[];let r=0;const o=()=>e.destroy(p(i));i?.addEventListener("abort",o,{once:!0});try{for await(const a of e){throwIfAborted(i);const s=Buffer.isBuffer(a)?a:Buffer.from(a);if(r+=s.byteLength,r>t){const c=new UnsafeArchiveError("Archive symbolic-link target exceeds its byte limit");throw e.destroy(c),c}n.push(s)}}finally{i?.removeEventListener("abort",o)}return Buffer.concat(n,r)}export function decodeUtf8(e,t){try{const i=T.decode(e);return k(i,t),i}catch(i){throw i instanceof UnsafeArchiveError?i:new UnsafeArchiveError(`Archive ${t} is not valid UTF-8`,{cause:i})}}function k(e,t){if(e.includes("\uFFFD")||T.decode(Buffer.from(e,"utf8"))!==e)throw new UnsafeArchiveError(`Archive ${t} is not valid canonical UTF-8`)}function L(e,t){return typeof e=="object"&&e!==null&&Reflect.get(e,"code")===t}export function requireSafeMode(e,t){if(!Number.isSafeInteger(e)||e<0||e>4095)throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has an invalid mode`);return e&511}export function requireSafeSize(e,t){if(!Number.isSafeInteger(e)||e<0)throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has an invalid size`);return e}function x(e,t){if(!e.includes(t))throw new Error(`Archive format ${t} requires another extractor`)}export function throwIfAborted(e){if(e?.aborted)throw p(e)}function p(e){return e?.reason instanceof Error?e.reason:new Error("Archive operation was aborted")}function R(e,t){return e instanceof Error?e:new Error(t)}
1
+ import u from"node:fs";import{createRequire as F}from"node:module";import y from"node:path";import{pipeline as I}from"node:stream/promises";import{isPathWithin as g}from"../process/files/paths.js";import{hasErrorCode as N}from"../kernel/error-serialization.js";export const ARCHIVE_FORMATS=["tar","tar.gz","zip"],MAX_ARCHIVE_ENTRIES=1e5;export class UnsafeArchiveError extends Error{code="TESTKIT_ARCHIVE_UNSAFE";constructor(t,i){super(t,i),this.name="UnsafeArchiveError"}}const T=F(import.meta.url);let D,M;function C(){return D??=T("tar")}function P(){return M??=T("yauzl")}const U=new Set(["File","OldFile","ContiguousFile"]),Z=new Set(["Directory","GNUDumpDir"]),S=4096,W=255,X=128,j=128*1024*1024,Y=/[<>:"|?*]/u,q=/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu,$=new TextDecoder("utf-8",{fatal:!0});export class TarArchiveInspector{formats=["tar","tar.gz"];async inspect(t,i,r={}){x(this.formats,i);const n=C(),o=E(r),a=[],s=u.createReadStream(t),c=new n.Parser({onReadEntry(l){if(r.signal?.aborted){const f=p(r.signal);s.destroy(f),c.abort(f);return}try{o.countEntry();const f=G(l);a.push(f),o.addPayload(f)}catch(f){const h=_(f,"Tar archive processing failed");s.destroy(h),c.abort(h);return}l.resume()},strict:!0});return await H(s,c,r.signal),a}}export class ZipArchiveExtractor{formats=["zip"];async inspect(t,i,r={}){x(this.formats,i);const n=E(r),o=[];return await z(t,r.signal,async(a,s)=>{n.countEntry();const c=await O(s,a,r.signal);o.push(c),n.addPayload(c)}),o}async extract(t,i,r,n={}){x(this.formats,r);const o=E(n,"extraction"),a=y.resolve(i),s=await this.inspect(t,r,n);validateArchiveEntryTopology(s),await ne(a);const c=[];await z(t,n.signal,async(f,h)=>{const d=await O(h,f,n.signal);o.countEntry(),o.addPayload(d);const w=assertSafeArchiveEntry(d),m=y.resolve(a,w);if(!g(a,m))throw new UnsafeArchiveError(`Archive path escapes its destination: ${JSON.stringify(w)}`);if(d.kind==="directory"){await A(a,m,n.signal),await u.promises.chmod(m,d.mode);return}if(await A(a,y.dirname(m),n.signal),d.kind==="symlink"){if(d.linkTarget===void 0)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(w)} has no target`);c.push({destinationPath:m,entry:{...d,kind:"symlink",linkTarget:d.linkTarget},normalized:w});return}const R=await J(h,f);await I(R,u.createWriteStream(m,{flags:"wx",mode:d.mode}),{signal:n.signal}),await u.promises.chmod(m,d.mode)});for(const f of c)throwIfAborted(n.signal),await A(a,y.dirname(f.destinationPath),n.signal),await u.promises.symlink(f.entry.linkTarget,f.destinationPath,re(f.normalized,s));const l=await u.promises.realpath(a);for(const f of c)await assertInstalledSymlinkTarget(f.destinationPath,f.normalized,l);throwIfAborted(n.signal)}}function E(e,t="inspection"){const i=Math.min(e.maxEntries??MAX_ARCHIVE_ENTRIES,MAX_ARCHIVE_ENTRIES),r=e.maxUnpackedSizeBytes??Number.MAX_SAFE_INTEGER,n=t==="extraction"?" during extraction":"";let o=0,a=0;return{countEntry(){if(t==="inspection"?o>=i:o+1>i)throw new UnsafeArchiveError(`Archive exceeds the ${i.toString()} entry limit${n}`);o+=1},addPayload(s){if(!(s.kind!=="file"&&s.kind!=="symlink")&&(a+=s.sizeBytes,a>r))throw new UnsafeArchiveError(`Archive exceeds its configured unpacked byte limit${n}`)}}}async function H(e,t,i){await new Promise((r,n)=>{let o=!1;const a=()=>{const l=p(i);e.destroy(l),t.abort(l)},s=l=>{o||(o=!0,i?.removeEventListener("abort",a),n(l))},c=()=>{o||(o=!0,i?.removeEventListener("abort",a),r())};e.once("error",s),t.once("error",s),t.once("end",c),i?.addEventListener("abort",a,{once:!0}),e.pipe(t)}),throwIfAborted(i)}export function validateArchiveEntryTopology(e){const t=new Map,i=new Map;for(const n of e){const o=assertSafeArchiveEntry(n),a=portableCollisionKey(o),s=i.get(a);if(s!==void 0)throw new UnsafeArchiveError(`Archive contains colliding entries ${JSON.stringify(s)} and ${JSON.stringify(o)}`);i.set(a,o),t.set(o,n)}const r=v(t);for(const[n,o]of t){for(const a of B(n)){const s=t.get(a);if(s?.kind==="symlink")throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(n)} would write through symbolic-link ancestor ${JSON.stringify(a)}`);if(s?.kind==="file")throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(n)} has regular-file ancestor ${JSON.stringify(a)}`)}o.kind==="symlink"&&L(n,t,r)}}export function assertSafeArchiveEntry(e){const t=normalizeArchivePath(e.path);if(e.kind==="forbidden")throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has forbidden type ${JSON.stringify(e.rawType)}`);if(requireSafeMode(e.mode,t),requireSafeSize(e.sizeBytes,t),e.kind==="directory"&&e.sizeBytes!==0)throw new UnsafeArchiveError(`Archive directory ${JSON.stringify(t)} has non-zero size`);if(e.kind==="symlink"){if(e.linkTarget===void 0)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(t)} has no target`);const i=normalizeSymlinkTarget(e.linkTarget,t),r=Buffer.byteLength(i,"utf8");if(e.sizeBytes!==r)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(t)} has inconsistent target size`)}else if(e.linkTarget!==void 0)throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has unexpected link target data`);return t}export function normalizeArchivePath(e){k(e,"archive path");const t=e.replace(/\/+$/u,"");if(!t||t.includes("\\")||t.startsWith("/")||Buffer.byteLength(t,"utf8")>S||K(t))throw new UnsafeArchiveError(`Archive path is not a portable relative path: ${JSON.stringify(e)}`);const i=t.split("/");if(i.length>X)throw new UnsafeArchiveError(`Archive path has too many segments: ${JSON.stringify(e)}`);for(const r of i)ie(r,e);return i.join("/")}function K(e){for(let t=0;t<e.length;t+=1){const i=e.charCodeAt(t);if(i<=31||i===127)return!0}return!1}function G(e){const t=te(e.type),i=t==="symlink"?e.linkpath:void 0;return{kind:t,...i===void 0?{}:{linkTarget:i},mode:e.mode,path:e.path,rawType:e.type,sizeBytes:t==="symlink"&&i!==void 0?Buffer.byteLength(i,"utf8"):e.size}}function V(e){const t=e.externalFileAttributes>>>16&65535,i=t&61440,r=e.versionMadeBy>>>8,n=e.fileName.endsWith("/")||i===16384||r===0&&(e.externalFileAttributes&16)!==0,o=i===40960,a=i!==0&&i!==16384&&i!==32768&&i!==40960,s=e.isEncrypted()||a?"forbidden":o?"symlink":n?"directory":"file",c=t&511;return{kind:s,mode:c||(n?493:420),path:e.fileName,rawType:e.isEncrypted()?"encrypted":o?"SymbolicLink":a?`mode-${i.toString(8)}`:n?"Directory":"File",sizeBytes:e.uncompressedSize}}async function O(e,t,i){const r=V(t);if(r.kind!=="symlink")return r;if(r.sizeBytes>S)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(r.path)} exceeds the portable target size limit`);const n=await J(e,t),o=await oe(n,S,i);if(o.byteLength!==r.sizeBytes)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(r.path)} has inconsistent target size`);return{...r,linkTarget:decodeUtf8(o,`archive symbolic link ${r.path}`),sizeBytes:o.byteLength}}async function z(e,t,i){const{zipFile:r,buffered:n}=await Q(e,t);let o=Promise.resolve();try{await new Promise((a,s)=>{let c=!1;const l=()=>{c||(c=!0,t?.removeEventListener("abort",h),a())},f=d=>{c||(c=!0,t?.removeEventListener("abort",h),s(_(d,"ZIP archive processing failed")))},h=()=>{f(p(t))};if(r.once("error",f),r.once("end",l),r.on("entry",d=>{o=Promise.resolve().then(()=>i(d,r)).then(()=>{c||r.readEntry()}).catch(f)}),t?.addEventListener("abort",h,{once:!0}),t?.aborted){h();return}r.readEntry()})}finally{await o,n?r.close():await new Promise((a,s)=>{const c=()=>{r.removeListener("error",l),a()},l=f=>{r.removeListener("close",c),s(f)};r.once("close",c),r.once("error",l),r.close()})}}async function Q(e,t){const i=await ee(e,t);throwIfAborted(t);const{fromBuffer:r,open:n}=P();return new Promise((o,a)=>{const s={autoClose:!1,decodeStrings:!0,lazyEntries:!0,strictFileNames:!0,validateEntrySizes:!0},c=(l,f)=>{if(l!==null){a(l);return}o({zipFile:f,buffered:i!==void 0})};i===void 0?n(e,s,c):r(i,s,c)})}async function ee(e,t){throwIfAborted(t);const i=await u.promises.open(e,"r");try{const r=await i.stat();if(throwIfAborted(t),r.size>j)return;const n=Buffer.allocUnsafe(r.size+1);let o=0;for(;o<n.length;){throwIfAborted(t);const a=Math.min(1024*1024,n.length-o),{bytesRead:s}=await i.read(n,o,a,null);if(throwIfAborted(t),s===0)return n.subarray(0,o);o+=s}return}finally{await i.close()}}function J(e,t){return new Promise((i,r)=>{e.openReadStream(t,(n,o)=>{if(n!==null){r(n);return}i(o)})})}function te(e){return U.has(e)?"file":Z.has(e)?"directory":e==="SymbolicLink"?"symlink":"forbidden"}function ie(e,t){if(!e||e==="."||e===".."||e.trim()!==e||e.endsWith(".")||e.normalize("NFC")!==e||Buffer.byteLength(e,"utf8")>W||Y.test(e)||q.test(e))throw new UnsafeArchiveError(`Archive path contains a non-portable segment: ${JSON.stringify(t)}`)}export function portableCollisionKey(e){return e.normalize("NFC").toLowerCase()}export function normalizeSymlinkTarget(e,t){k(e,`symbolic link ${t}`);const i=normalizeArchivePath(e);if(i!==e)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(t)} has a non-canonical relative target: ${JSON.stringify(e)}`);return b(t,i),i}function B(e){const t=e.split("/"),i=[];for(let r=1;r<t.length;r+=1)i.push(t.slice(0,r).join("/"));return i}function re(e,t){const i=new Map(t.map(r=>[normalizeArchivePath(r.path),r]));return L(e,i,v(i))==="directory"?"dir":"file"}function L(e,t,i=v(t)){const r=t.get(e);if(r?.kind!=="symlink"||r.linkTarget===void 0)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has no target`);let n=b(e,normalizeSymlinkTarget(r.linkTarget,e));const o=new Set;for(let a=0;a<=t.size;a+=1){const s=n.split("/");let c=!1;for(let f=0;f<s.length;f+=1){const h=s.slice(0,f+1).join("/"),d=t.get(h);if(d?.kind==="symlink"){if(d.linkTarget===void 0||o.has(h))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has a cyclic target`);o.add(h);const w=b(h,normalizeSymlinkTarget(d.linkTarget,h)),m=s.slice(f+1).join("/");n=m?normalizeArchivePath(`${w}/${m}`):w,c=!0;break}if(d?.kind==="file"&&f<s.length-1)throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} traverses regular file ${JSON.stringify(h)}`);if(d===void 0&&!i.has(h))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has dangling target ${JSON.stringify(r.linkTarget)}`)}if(c)continue;const l=t.get(n);if(l?.kind==="file")return"file";if(l?.kind==="directory"||i.has(n))return"directory";throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has dangling target ${JSON.stringify(r.linkTarget)}`)}throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has a cyclic target`)}function v(e){const t=new Set;for(const[i,r]of e){for(const n of B(i))t.add(n);r.kind==="directory"&&t.add(i)}return t}function b(e,t){if(y.posix.isAbsolute(t))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} has an absolute target`);const i=y.posix.join(y.posix.dirname(e),t);if(i===".."||i.startsWith("../")||y.posix.isAbsolute(i))throw new UnsafeArchiveError(`Archive symbolic link ${JSON.stringify(e)} escapes the archive root`);return normalizeArchivePath(i)}export async function assertInstalledSymlinkTarget(e,t,i){let r;try{r=await u.promises.realpath(e)}catch(o){throw new UnsafeArchiveError(`Installed archive symbolic link ${JSON.stringify(t)} is dangling or cyclic`,{cause:o})}if(!g(i,r))throw new UnsafeArchiveError(`Installed archive symbolic link ${JSON.stringify(t)} resolves outside its root`);const n=await u.promises.lstat(r);if(!n.isFile()&&!n.isDirectory())throw new UnsafeArchiveError(`Installed archive symbolic link ${JSON.stringify(t)} resolves to a special file`)}async function ne(e){const t=await u.promises.lstat(e);if(!t.isDirectory()||t.isSymbolicLink())throw new UnsafeArchiveError("Archive extraction root must be a real directory");if((await u.promises.readdir(e)).length!==0)throw new UnsafeArchiveError("Archive extraction root must be empty")}async function A(e,t,i){if(!g(e,t))throw new UnsafeArchiveError(`Archive extraction path escapes its destination: ${JSON.stringify(t)}`);const r=y.relative(e,t);let n=e;for(const[o,a]of r.split(y.sep).filter(Boolean).entries()){o>0&&o%16===0&&await new Promise(c=>setImmediate(c)),throwIfAborted(i),n=y.join(n,a);let s;try{s=u.lstatSync(n)}catch(c){if(!N(c,"ENOENT"))throw c;try{u.mkdirSync(n,{mode:493})}catch(l){if(!N(l,"EEXIST"))throw l}s=u.lstatSync(n)}if(!s.isDirectory()||s.isSymbolicLink())throw new UnsafeArchiveError(`Archive refuses to write through non-directory path ${JSON.stringify(n)}`)}}async function oe(e,t,i){const r=[];let n=0;const o=()=>e.destroy(p(i));i?.addEventListener("abort",o,{once:!0});try{for await(const a of e){throwIfAborted(i);const s=Buffer.isBuffer(a)?a:Buffer.from(a);if(n+=s.byteLength,n>t){const c=new UnsafeArchiveError("Archive symbolic-link target exceeds its byte limit");throw e.destroy(c),c}r.push(s)}}finally{i?.removeEventListener("abort",o)}return Buffer.concat(r,n)}export function decodeUtf8(e,t){try{const i=$.decode(e);return k(i,t),i}catch(i){throw i instanceof UnsafeArchiveError?i:new UnsafeArchiveError(`Archive ${t} is not valid UTF-8`,{cause:i})}}function k(e,t){if(e.includes("\uFFFD")||$.decode(Buffer.from(e,"utf8"))!==e)throw new UnsafeArchiveError(`Archive ${t} is not valid canonical UTF-8`)}export function requireSafeMode(e,t){if(!Number.isSafeInteger(e)||e<0||e>4095)throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has an invalid mode`);return e&511}export function requireSafeSize(e,t){if(!Number.isSafeInteger(e)||e<0)throw new UnsafeArchiveError(`Archive entry ${JSON.stringify(t)} has an invalid size`);return e}function x(e,t){if(!e.includes(t))throw new Error(`Archive format ${t} requires another extractor`)}export function throwIfAborted(e){if(e?.aborted)throw p(e)}function p(e){return e?.reason instanceof Error?e.reason:new Error("Archive operation was aborted")}function _(e,t){return e instanceof Error?e:new Error(t)}
@@ -1,4 +1,4 @@
1
- import _ from"crypto";import f from"fs";import u from"path";import{prepareCommandForSpawn as I,resolveCommandForSpawn as A}from"../process/host/platform.js";import{normalizeContainedRelativePath as V,resolveContainedPath as D}from"../process/files/contained-path.js";import{deriveSemverMinimum as k,isExactSemver as L,normalizeSemver as p,satisfiesSemver as g}from"./semver.js";import{managedHostProcessInfrastructureError as M,runManagedHostProcess as j}from"../process/index.js";import{managedNodeRelease as H}from"./managed-node-releases.js";import{createSharedToolchainOperation as R,waitForSharedToolchainOperation as F}from"./shared-operation.js";import{TOOLCHAIN_METADATA_TIMEOUT_MS as $}from"./timeouts.js";const P=".",z="auto",W="require-host",q=600*1e3,U=['"use strict";','const { spawn } = require("node:child_process");',"const [command, ...args] = process.argv.slice(1);",'if (typeof command !== "string" || command.length === 0) {',' process.stderr.write("Missing Windows toolchain command\\n");'," process.exitCode = 126;","} else {"," let spawnFailed = false;"," const child = spawn(command, args, {"," shell: false,",' stdio: "inherit",'," windowsHide: true,"," windowsVerbatimArguments: true,"," });",' child.once("error", (error) => {'," spawnFailed = true;"," process.stderr.write(`${error.stack ?? String(error)}\\n`);"," process.exitCode = 126;"," });",' child.once("close", (code, signal) => {'," if (spawnFailed) return;"," if (signal !== null) {"," process.stderr.write(`Windows toolchain command exited on signal ${signal}\\n`);"," process.exitCode = 1;"," return;"," }"," process.exitCode = code ?? 1;"," });","}"].join(`
2
- `),T=new WeakMap,O=new WeakSet;export function nodeToolchain(n={}){return{...n}}export function normalizeToolchainRegistry(n){if(n==null)return{};if(!N(n))throw new Error("testkit.config.ts toolchains must be an object");const e={};for(const[o,r]of Object.entries(n))e[o]=x(r,`testkit.config.ts toolchains.${o}`);return e}export function normalizeRuntimeToolchain(n,e,o={}){if(n==null)return null;if(typeof n=="string"){const t=n.trim();if(!t)throw new Error(`${e} must reference a non-empty toolchain name`);const i=o[t];if(!i)throw new Error(`${e} references unknown toolchain "${t}"`);return{...i,refName:t,sourceLabel:`${e} -> ${t}`}}return{...x(n,e),refName:null,sourceLabel:e}}export async function resolveConfiguredToolchain(n,e={}){const o=n?.testkit?.runtime?.toolchain;if(!n||!o)return null;let r=T.get(n);if(r===void 0){r=R(i=>B(n,o,{...e,signal:i})),T.set(n,r);const t=r;r.promise.catch(()=>{T.get(n)===t&&T.delete(n)})}return F(r,e.signal)}export function announceResolvedToolchain(n,e,o=null){return!e||O.has(n)||(O.add(n),o?.toolchainResolved&&o.toolchainResolved(n,e)),Promise.resolve()}export function applyToolchainEnv(n,e,o=process.env){if(!e)return{...n};const r={...n,PATH:ee(e.binDir,n.PATH||o.PATH||""),TESTKIT_TOOLCHAIN_KIND:"node",TESTKIT_TOOLCHAIN_NODE_VERSION:e.nodeVersion,TESTKIT_TOOLCHAIN_NODE_SOURCE:e.nodeSource,TESTKIT_TOOLCHAIN_INSTALL_MODE:e.install};return e.npmVersion&&(r.TESTKIT_TOOLCHAIN_NPM_VERSION=e.npmVersion,r.TESTKIT_TOOLCHAIN_NPM_SOURCE=e.npmSource),r}function x(n,e){if(!N(n))throw new Error(`${e} must be an object`);const o=V(d(n.cwd)||P,`${e}.cwd`),r=d(n.detect)||z,t=d(n.install)||W;if(r!=="auto"&&r!=="off")throw new Error(`${e}.detect must be "auto" or "off"`);if(t!=="require-host"&&t!=="download")throw new Error(`${e}.install must be "require-host" or "download"`);return{cwd:o,detect:r,install:t,node:d(n.node),npm:d(n.npm)}}async function B(n,e,o){const r=n.productDir,t=D(r,e.cwd,`${e.sourceLabel}.cwd`),i=J(t),s=e.node||(e.detect==="auto"?i.nodeSpec:null),a=e.node?"toolchain.node":e.detect==="auto"?i.nodeSource??"none":"none",l=e.npm||(e.detect==="auto"?i.npmSpec:null),c=e.npm?"toolchain.npm":e.detect==="auto"?i.npmSource??"none":"none";if(!s&&!l)throw new Error(`Service "${n.name}" enables runtime.toolchain but no Node/npm version could be resolved in ${e.cwd}`);return e.install==="require-host"?K({config:n,cwd:t,nodeSpec:s,nodeSource:a,npmSpec:l,npmSource:c,toolchain:e,options:o}):G({config:n,cwd:t,nodeSpec:s,nodeSource:a,npmSpec:l,npmSource:c,toolchain:e,options:o})}function J(n){const e=u.join(n,"package.json"),o=Y(e),r=Z(u.join(n,".tool-versions")),t=b(u.join(n,".nvmrc")),i=b(u.join(n,".node-version")),s=d(w(o,"volta","node")),a=d(w(o,"engines","node")),l=d(r.nodejs),c=s||t||i||l||a;let m=null;s?m="package.json#volta.node":t?m=".nvmrc":i?m=".node-version":l?m=".tool-versions#nodejs":a&&(m="package.json#engines.node");const y=d(w(o,"volta","npm")),E=d(w(o,"engines","npm")),v=X(w(o,"packageManager")),C=y||(v?.name==="npm"?v.version:null)||E;let h=null;return y?h="package.json#volta.npm":v?.name==="npm"?h="package.json#packageManager":E&&(h="package.json#engines.npm"),{nodeSpec:c,nodeSource:m,npmSpec:C,npmSource:h}}async function K({config:n,cwd:e,nodeSpec:o,nodeSource:r,npmSpec:t,npmSource:i,toolchain:s,options:a}){const l=p(a.hostNodeVersion||process.versions.node);if(o&&!g(l,o))throw new Error(`Service "${n.name}" requires Node ${o} (${r}), but host Node is ${l}`);let c=null;if(t&&(c=p(a.hostNpmVersion||await Q({command:"npm",args:["--version"],cwd:e,env:process.env,commandExecutor:a.commandExecutor,signal:a.signal})),!g(c,t)))throw new Error(`Service "${n.name}" requires npm ${t} (${i}), but host npm is ${c}`);return{install:s.install,cwd:s.cwd,nodeVersion:l,nodeSource:r,nodeSpec:o,npmVersion:c,npmSource:i,npmSpec:t,nodeExecutable:process.execPath,binDir:u.dirname(process.execPath),toolchainDir:u.dirname(process.execPath),fingerprint:S({install:s.install,nodeVersion:l,npmVersion:c,nodeSource:r,npmSource:i}),summary:`${s.install} node=${l}${c?` npm=${c}`:""} (${r})`}}async function G({config:n,nodeSpec:e,nodeSource:o,npmSpec:r,npmSource:t,toolchain:i,options:s}){if(!e)throw new Error(`Service "${n.name}" download toolchain mode requires a resolvable Node version`);const a=L(e)?p(e):k(e);if(!a)throw new Error(`Service "${n.name}" cannot deterministically provision Node from "${e}". Provide an exact version in runtime.toolchain.node, .nvmrc, .node-version, .tool-versions, or package.json#volta.node.`);const l=await provisionNodeToolchain({productDir:n.productDir,version:a,fetchImpl:s.fetchImpl||fetchToolchain,commandExecutor:s.commandExecutor||executeToolchainCommand,signal:s.signal});if(!g(a,e))throw new Error(`Service "${n.name}" resolved Node ${a}, which does not satisfy ${e}`);const c=l.npmVersion?p(l.npmVersion):null;if(r&&(!c||!g(c,r)))throw new Error(`Service "${n.name}" requires npm ${r} (${t}), but Node ${a} bundles npm ${c||"unknown"}`);return{install:i.install,cwd:i.cwd,nodeVersion:a,nodeSource:o,nodeSpec:e,npmVersion:c,npmSource:t,npmSpec:r,nodeExecutable:l.nodeExecutable,binDir:l.binDir,toolchainDir:l.toolchainDir,fingerprint:S({install:i.install,nodeVersion:a,npmVersion:c,nodeSource:o,npmSource:t}),summary:`download node=${a}${c?` npm=${c}`:""} (${o})`}}export async function provisionNodeToolchain({productDir:n,version:e,fetchImpl:o=fetchToolchain,commandExecutor:r=executeToolchainCommand,signal:t}){const i=p(e);if(e!==i)throw new Error(`Downloaded Node toolchain version must use canonical x.y.z form; received ${JSON.stringify(e)}`);const s=H(i),{provisionNodeToolchainWithRelease:a}=await import("./node-provisioning.js");return a({commandExecutor:r,fetchImpl:o,productDir:n,release:s,signal:t})}function S(n){return _.createHash("sha256").update(JSON.stringify({kind:"node",...n})).digest("hex")}async function Q({command:n,args:e,cwd:o,env:r,commandExecutor:t=executeToolchainCommand,signal:i}){const s=await t(n,e,{cwd:o,env:r,signal:i,stdio:"pipe",timeoutMs:$,windowsHide:!0});return s.stdout==null?"":typeof s.stdout=="string"?s.stdout.trim():Buffer.from(s.stdout).toString().trim()}function X(n){const e=d(n);if(!e)return null;const o=e.lastIndexOf("@");return o<=0||o===e.length-1?null:{name:e.slice(0,o),version:e.slice(o+1)}}function Y(n){if(!f.existsSync(n))return null;try{return JSON.parse(f.readFileSync(n,"utf8"))}catch{return null}}function b(n){if(!f.existsSync(n))return null;const o=f.readFileSync(n,"utf8").split(/\r?\n/).find(r=>r.trim().length>0);return o?o.trim():null}function Z(n){if(!f.existsSync(n))return{};const e={};for(const o of f.readFileSync(n,"utf8").split(/\r?\n/)){const r=o.trim();if(!r||r.startsWith("#"))continue;const[t,i]=r.split(/\s+/,2);t&&i&&(e[t]=i.trim())}return e}function ee(n,e){const o=d(n);if(!o)return e;const r=(e||"").split(u.delimiter).filter(Boolean).filter(t=>t!==o);return[o,...r].join(u.delimiter)}function d(n){if(typeof n!="string")return null;const e=n.trim();return e.length>0?e:null}export async function fetchToolchain(n,e={}){const o=AbortSignal.timeout(q),r=await fetch(n,{signal:e.signal===void 0?o:AbortSignal.any([e.signal,o])});return{arrayBuffer:()=>r.arrayBuffer(),body:r.body===null?null:ne(r.body),ok:r.ok,status:r.status}}async function*ne(n){const e=n.getReader();let o=!1;try{for(;;){const r=await e.read();if(r.done){o=!0;return}yield r.value}}finally{o||await e.cancel(),e.releaseLock()}}export function prepareToolchainCommandForManagedHost(n,e,o={}){const r={env:o.env||process.env,platform:o.platform},t=A(n,r),i=I(t,e,r);return i.windowsVerbatimArguments?(oe(t,e),{executable:o.hostNodeExecutable||process.execPath,args:["--input-type=commonjs","--eval",U,"--",i.command,...i.args]}):{executable:i.command,args:i.args}}function oe(n,e){if(/[%!"\0\r\n]/u.test(n))throw new Error(`Windows batch toolchain commands cannot safely preserve command path ${JSON.stringify(n)}`);const o=e.find(r=>/[%!"\0\r\n]/u.test(r)||r.endsWith("\\"));if(o!==void 0)throw new Error(`Windows batch toolchain commands cannot safely preserve argument ${JSON.stringify(o)}`)}export async function executeToolchainCommand(n,e,o={}){const r=re(o.timeoutMs),t=u.resolve(o.cwd||process.cwd()),i=prepareToolchainCommandForManagedHost(n,e,{env:o.env||process.env}),s=await j({args:i.args,cwd:t,displayLabel:`Toolchain command ${n}`,env:o.env||process.env,executable:i.executable,signal:o.signal,timeoutMs:r}),a=M(`Toolchain command ${n}`,s);if(a)throw a;if(s.exitCode!==0)throw new Error(`Toolchain command ${n} exited with code ${String(s.exitCode)}${te(s.stdout,s.stderr)}`);return{stdout:s.stdout}}function re(n){const e=n??$;if(!Number.isSafeInteger(e)||e<=0)throw new Error("Toolchain command timeout must be a positive safe integer in milliseconds");return e}function te(n,e){const o=[n,e].map(r=>r.trim()).filter(Boolean).join(`
1
+ import b from"crypto";import f from"fs";import u from"path";import{prepareCommandForSpawn as I,resolveCommandForSpawn as A}from"../process/host/platform.js";import{normalizeContainedRelativePath as V,resolveContainedPath as k}from"../process/files/contained-path.js";import{deriveSemverMinimum as D,isExactSemver as L,normalizeSemver as p,satisfiesSemver as g}from"./semver.js";import{managedHostProcessInfrastructureError as M,runManagedHostProcess as H}from"../process/index.js";import{managedNodeRelease as j}from"./managed-node-releases.js";import{createSharedToolchainOperation as R,waitForSharedToolchainOperation as F}from"./shared-operation.js";import{TOOLCHAIN_METADATA_TIMEOUT_MS as y}from"./timeouts.js";import{isUnknownRecord as N}from"../kernel/unknown-values.js";const P=".",z="auto",W="require-host",q=600*1e3,U=['"use strict";','const { spawn } = require("node:child_process");',"const [command, ...args] = process.argv.slice(1);",'if (typeof command !== "string" || command.length === 0) {',' process.stderr.write("Missing Windows toolchain command\\n");'," process.exitCode = 126;","} else {"," let spawnFailed = false;"," const child = spawn(command, args, {"," shell: false,",' stdio: "inherit",'," windowsHide: true,"," windowsVerbatimArguments: true,"," });",' child.once("error", (error) => {'," spawnFailed = true;"," process.stderr.write(`${error.stack ?? String(error)}\\n`);"," process.exitCode = 126;"," });",' child.once("close", (code, signal) => {'," if (spawnFailed) return;"," if (signal !== null) {"," process.stderr.write(`Windows toolchain command exited on signal ${signal}\\n`);"," process.exitCode = 1;"," return;"," }"," process.exitCode = code ?? 1;"," });","}"].join(`
2
+ `),T=new WeakMap,O=new WeakSet;export function nodeToolchain(n={}){return{...n}}export function normalizeToolchainRegistry(n){if(n==null)return{};if(!N(n))throw new Error("testkit.config.ts toolchains must be an object");const e={};for(const[o,r]of Object.entries(n))e[o]=x(r,`testkit.config.ts toolchains.${o}`);return e}export function normalizeRuntimeToolchain(n,e,o={}){if(n==null)return null;if(typeof n=="string"){const t=n.trim();if(!t)throw new Error(`${e} must reference a non-empty toolchain name`);const i=o[t];if(!i)throw new Error(`${e} references unknown toolchain "${t}"`);return{...i,refName:t,sourceLabel:`${e} -> ${t}`}}return{...x(n,e),refName:null,sourceLabel:e}}export async function resolveConfiguredToolchain(n,e={}){const o=n?.testkit?.runtime?.toolchain;if(!n||!o)return null;let r=T.get(n);if(r===void 0){r=R(i=>B(n,o,{...e,signal:i})),T.set(n,r);const t=r;r.promise.catch(()=>{T.get(n)===t&&T.delete(n)})}return F(r,e.signal)}export function announceResolvedToolchain(n,e,o=null){return!e||O.has(n)||(O.add(n),o?.toolchainResolved&&o.toolchainResolved(n,e)),Promise.resolve()}export function applyToolchainEnv(n,e,o=process.env){if(!e)return{...n};const r={...n,PATH:ee(e.binDir,n.PATH||o.PATH||""),TESTKIT_TOOLCHAIN_KIND:"node",TESTKIT_TOOLCHAIN_NODE_VERSION:e.nodeVersion,TESTKIT_TOOLCHAIN_NODE_SOURCE:e.nodeSource,TESTKIT_TOOLCHAIN_INSTALL_MODE:e.install};return e.npmVersion&&(r.TESTKIT_TOOLCHAIN_NPM_VERSION=e.npmVersion,r.TESTKIT_TOOLCHAIN_NPM_SOURCE=e.npmSource),r}function x(n,e){if(!N(n))throw new Error(`${e} must be an object`);const o=V(d(n.cwd)||P,`${e}.cwd`),r=d(n.detect)||z,t=d(n.install)||W;if(r!=="auto"&&r!=="off")throw new Error(`${e}.detect must be "auto" or "off"`);if(t!=="require-host"&&t!=="download")throw new Error(`${e}.install must be "require-host" or "download"`);return{cwd:o,detect:r,install:t,node:d(n.node),npm:d(n.npm)}}async function B(n,e,o){const r=n.productDir,t=k(r,e.cwd,`${e.sourceLabel}.cwd`),i=J(t),s=e.node||(e.detect==="auto"?i.nodeSpec:null),a=e.node?"toolchain.node":e.detect==="auto"?i.nodeSource??"none":"none",l=e.npm||(e.detect==="auto"?i.npmSpec:null),c=e.npm?"toolchain.npm":e.detect==="auto"?i.npmSource??"none":"none";if(!s&&!l)throw new Error(`Service "${n.name}" enables runtime.toolchain but no Node/npm version could be resolved in ${e.cwd}`);return e.install==="require-host"?K({config:n,cwd:t,nodeSpec:s,nodeSource:a,npmSpec:l,npmSource:c,toolchain:e,options:o}):G({config:n,cwd:t,nodeSpec:s,nodeSource:a,npmSpec:l,npmSource:c,toolchain:e,options:o})}function J(n){const e=u.join(n,"package.json"),o=Y(e),r=Z(u.join(n,".tool-versions")),t=C(u.join(n,".nvmrc")),i=C(u.join(n,".node-version")),s=d(w(o,"volta","node")),a=d(w(o,"engines","node")),l=d(r.nodejs),c=s||t||i||l||a;let m=null;s?m="package.json#volta.node":t?m=".nvmrc":i?m=".node-version":l?m=".tool-versions#nodejs":a&&(m="package.json#engines.node");const E=d(w(o,"volta","npm")),$=d(w(o,"engines","npm")),v=X(w(o,"packageManager")),_=E||(v?.name==="npm"?v.version:null)||$;let h=null;return E?h="package.json#volta.npm":v?.name==="npm"?h="package.json#packageManager":$&&(h="package.json#engines.npm"),{nodeSpec:c,nodeSource:m,npmSpec:_,npmSource:h}}async function K({config:n,cwd:e,nodeSpec:o,nodeSource:r,npmSpec:t,npmSource:i,toolchain:s,options:a}){const l=p(a.hostNodeVersion||process.versions.node);if(o&&!g(l,o))throw new Error(`Service "${n.name}" requires Node ${o} (${r}), but host Node is ${l}`);let c=null;if(t&&(c=p(a.hostNpmVersion||await Q({command:"npm",args:["--version"],cwd:e,env:process.env,commandExecutor:a.commandExecutor,signal:a.signal})),!g(c,t)))throw new Error(`Service "${n.name}" requires npm ${t} (${i}), but host npm is ${c}`);return{install:s.install,cwd:s.cwd,nodeVersion:l,nodeSource:r,nodeSpec:o,npmVersion:c,npmSource:i,npmSpec:t,nodeExecutable:process.execPath,binDir:u.dirname(process.execPath),toolchainDir:u.dirname(process.execPath),fingerprint:S({install:s.install,nodeVersion:l,npmVersion:c,nodeSource:r,npmSource:i}),summary:`${s.install} node=${l}${c?` npm=${c}`:""} (${r})`}}async function G({config:n,nodeSpec:e,nodeSource:o,npmSpec:r,npmSource:t,toolchain:i,options:s}){if(!e)throw new Error(`Service "${n.name}" download toolchain mode requires a resolvable Node version`);const a=L(e)?p(e):D(e);if(!a)throw new Error(`Service "${n.name}" cannot deterministically provision Node from "${e}". Provide an exact version in runtime.toolchain.node, .nvmrc, .node-version, .tool-versions, or package.json#volta.node.`);const l=await provisionNodeToolchain({productDir:n.productDir,version:a,fetchImpl:s.fetchImpl||fetchToolchain,commandExecutor:s.commandExecutor||executeToolchainCommand,signal:s.signal});if(!g(a,e))throw new Error(`Service "${n.name}" resolved Node ${a}, which does not satisfy ${e}`);const c=l.npmVersion?p(l.npmVersion):null;if(r&&(!c||!g(c,r)))throw new Error(`Service "${n.name}" requires npm ${r} (${t}), but Node ${a} bundles npm ${c||"unknown"}`);return{install:i.install,cwd:i.cwd,nodeVersion:a,nodeSource:o,nodeSpec:e,npmVersion:c,npmSource:t,npmSpec:r,nodeExecutable:l.nodeExecutable,binDir:l.binDir,toolchainDir:l.toolchainDir,fingerprint:S({install:i.install,nodeVersion:a,npmVersion:c,nodeSource:o,npmSource:t}),summary:`download node=${a}${c?` npm=${c}`:""} (${o})`}}export async function provisionNodeToolchain({productDir:n,version:e,fetchImpl:o=fetchToolchain,commandExecutor:r=executeToolchainCommand,signal:t}){const i=p(e);if(e!==i)throw new Error(`Downloaded Node toolchain version must use canonical x.y.z form; received ${JSON.stringify(e)}`);const s=j(i),{provisionNodeToolchainWithRelease:a}=await import("./node-provisioning.js");return a({commandExecutor:r,fetchImpl:o,productDir:n,release:s,signal:t})}function S(n){return b.createHash("sha256").update(JSON.stringify({kind:"node",...n})).digest("hex")}async function Q({command:n,args:e,cwd:o,env:r,commandExecutor:t=executeToolchainCommand,signal:i}){const s=await t(n,e,{cwd:o,env:r,signal:i,stdio:"pipe",timeoutMs:y,windowsHide:!0});return s.stdout==null?"":typeof s.stdout=="string"?s.stdout.trim():Buffer.from(s.stdout).toString().trim()}function X(n){const e=d(n);if(!e)return null;const o=e.lastIndexOf("@");return o<=0||o===e.length-1?null:{name:e.slice(0,o),version:e.slice(o+1)}}function Y(n){if(!f.existsSync(n))return null;try{return JSON.parse(f.readFileSync(n,"utf8"))}catch{return null}}function C(n){if(!f.existsSync(n))return null;const o=f.readFileSync(n,"utf8").split(/\r?\n/).find(r=>r.trim().length>0);return o?o.trim():null}function Z(n){if(!f.existsSync(n))return{};const e={};for(const o of f.readFileSync(n,"utf8").split(/\r?\n/)){const r=o.trim();if(!r||r.startsWith("#"))continue;const[t,i]=r.split(/\s+/,2);t&&i&&(e[t]=i.trim())}return e}function ee(n,e){const o=d(n);if(!o)return e;const r=(e||"").split(u.delimiter).filter(Boolean).filter(t=>t!==o);return[o,...r].join(u.delimiter)}function d(n){if(typeof n!="string")return null;const e=n.trim();return e.length>0?e:null}export async function fetchToolchain(n,e={}){const o=AbortSignal.timeout(q),r=await fetch(n,{signal:e.signal===void 0?o:AbortSignal.any([e.signal,o])});return{arrayBuffer:()=>r.arrayBuffer(),body:r.body===null?null:ne(r.body),ok:r.ok,status:r.status}}async function*ne(n){const e=n.getReader();let o=!1;try{for(;;){const r=await e.read();if(r.done){o=!0;return}yield r.value}}finally{o||await e.cancel(),e.releaseLock()}}export function prepareToolchainCommandForManagedHost(n,e,o={}){const r={env:o.env||process.env,platform:o.platform},t=A(n,r),i=I(t,e,r);return i.windowsVerbatimArguments?(oe(t,e),{executable:o.hostNodeExecutable||process.execPath,args:["--input-type=commonjs","--eval",U,"--",i.command,...i.args]}):{executable:i.command,args:i.args}}function oe(n,e){if(/[%!"\0\r\n]/u.test(n))throw new Error(`Windows batch toolchain commands cannot safely preserve command path ${JSON.stringify(n)}`);const o=e.find(r=>/[%!"\0\r\n]/u.test(r)||r.endsWith("\\"));if(o!==void 0)throw new Error(`Windows batch toolchain commands cannot safely preserve argument ${JSON.stringify(o)}`)}export async function executeToolchainCommand(n,e,o={}){const r=re(o.timeoutMs),t=u.resolve(o.cwd||process.cwd()),i=prepareToolchainCommandForManagedHost(n,e,{env:o.env||process.env}),s=await H({args:i.args,cwd:t,displayLabel:`Toolchain command ${n}`,env:o.env||process.env,executable:i.executable,signal:o.signal,timeoutMs:r}),a=M(`Toolchain command ${n}`,s);if(a)throw a;if(s.exitCode!==0)throw new Error(`Toolchain command ${n} exited with code ${String(s.exitCode)}${te(s.stdout,s.stderr)}`);return{stdout:s.stdout}}function re(n){const e=n??y;if(!Number.isSafeInteger(e)||e<=0)throw new Error("Toolchain command timeout must be a positive safe integer in milliseconds");return e}function te(n,e){const o=[n,e].map(r=>r.trim()).filter(Boolean).join(`
3
3
  `);return o?`
4
- ${o.slice(-4e3)}`:""}function w(n,...e){let o=n;for(const r of e){if(!N(o))return;o=o[r]}return o}function N(n){return n!==null&&typeof n=="object"&&!Array.isArray(n)}
4
+ ${o.slice(-4e3)}`:""}function w(n,...e){let o=n;for(const r of e){if(!N(o))return;o=o[r]}return o}
@@ -1,2 +1,2 @@
1
- import k from"node:crypto";import u from"node:fs";import d from"node:path";import{TarArchiveInspector as z,ZipArchiveExtractor as M}from"./archive.js";import{withProcessLock as B}from"../process/locks.js";import{createContainedTemporaryDirectory as L,existingRealInstallDirectory as $,NODE_TOOLCHAIN_CONTENT_DIRECTORY as P,NODE_TOOLCHAIN_MARKER_FILENAME as R,nodeToolchainCacheKey as j,prepareNodeToolchainCache as H,readRealManifestFile as p,removeContainedInstallDirectory as F,requireContainedExecutable as v,requireRealContentDirectory as U}from"./node-cache.js";import{InstalledNodeTreeMismatchError as Y,validateNodeArchiveEntries as q,verifyInstalledNodeTree as g}from"./node-integrity.js";import{createSharedToolchainOperation as J,throwIfSignalAborted as f,waitForPromise as m,waitForSharedToolchainOperation as K}from"./shared-operation.js";import{TOOLCHAIN_METADATA_TIMEOUT_MS as W}from"./timeouts.js";const w=2,E=300*1e3,G=Object.freeze([100,250,500,1e3,2e3,3e3,3e3]),y=new Map;export async function provisionNodeToolchainWithRelease({archiveTimeoutMs:e=E,commandExecutor:r,fetchImpl:t,productDir:n,release:o,signal:i,zipExtractor:a}){f(i);const s=H(n,o),l=j(s);let c=y.get(l);if(c===void 0){c=J(V=>Z({archiveTimeoutMs:e,commandExecutor:r,fetchImpl:t,paths:s,productDir:n,release:o,signal:V,zipExtractor:a})),y.set(l,c);const h=c;c.promise.then(()=>{A(l,h)},()=>{A(l,h)})}return K(c,i)}export async function extractTrustedNodeArchive({archivePath:e,commandExecutor:r,destination:t,release:n,signal:o,timeoutMs:i=E,zipExtractor:a=new M}){if(ce(i),u.existsSync(t))throw new Error(`Node archive destination already exists: ${t}`);u.mkdirSync(t,{mode:448});const s=AbortSignal.timeout(i),l=o===void 0?s:AbortSignal.any([o,s]),c=n.archive.format==="zip"?a:new z;try{const h=await c.inspect(e,n.archive.format,{maxEntries:n.archive.entryCount,maxUnpackedSizeBytes:n.archive.unpackedSizeBytes,signal:l});q(h,n),n.archive.format==="zip"?await a.extract(e,t,n.archive.format,{maxEntries:n.archive.entryCount,maxUnpackedSizeBytes:n.archive.unpackedSizeBytes,signal:l}):(await r("tar",["-xzf",e,"-C",t],{cwd:d.dirname(t),env:process.env,signal:l,timeoutMs:i}),f(l))}catch(h){throw u.rmSync(t,{recursive:!0,force:!0}),h}}async function Z(e){let r=null;const t=await b(e.paths,e.release,e.commandExecutor,e.signal,n=>{r=n});return t!==null?t:B(e.paths.lockPath,async()=>{if(f(e.signal),r===null||!T(e.paths,r)){const n=await b(e.paths,e.release,e.commandExecutor,e.signal);if(n!==null)return n}return F(e.paths),Q(e)},{signal:e.signal,timeoutMs:null})}async function Q(e){const r=L(e.paths.rootDirectory,`.tmp-${e.release.version}`),t=e.release.archive.format==="zip"?".zip":".tar.gz",n=d.join(r,`node${t}`),o=d.join(r,"extracted"),i=d.join(r,"publish"),a=d.join(i,P);try{const s=await m(e.fetchImpl(e.release.archive.url,{signal:e.signal}),e.signal);if(!s.ok)throw new Error(`Failed to download ${e.release.archive.url}: HTTP ${String(s.status)}`);await ee(s,n,e.release,e.signal),await extractTrustedNodeArchive({archivePath:n,commandExecutor:e.commandExecutor,destination:o,release:e.release,signal:e.signal,timeoutMs:e.archiveTimeoutMs,zipExtractor:e.zipExtractor});const l=d.join(o,e.release.archive.rootDirectory);ae(o,l),await g(l,e.release.installed,e.signal),u.mkdirSync(i,{mode:448}),u.renameSync(l,a),u.rmSync(o,{recursive:!0,force:!0}),u.rmSync(n,{force:!0});const c=x(a,e.release),h=await D(a,c,e.release,e.commandExecutor,e.signal);return ne(d.join(i,R),te(e.release,h.npmVersion)),await renameNodeToolchainPublishDirectory(i,e.paths.installDirectory,e.signal),{binDir:S(e.paths.contentDirectory,e.release),nodeExecutable:d.join(e.paths.contentDirectory,c.nodeRelativePath),npmVersion:h.npmVersion,toolchainDir:e.paths.installDirectory}}finally{u.rmSync(r,{recursive:!0,force:!0})}}export async function renameNodeToolchainPublishDirectory(e,r,t){for(let n=0;;n+=1){f(t);try{await u.promises.rename(e,r);return}catch(o){const i=nodeToolchainPublishRetryDelay(o,process.platform,n);if(i===null||X(r))throw o;await m(new Promise(a=>setTimeout(a,i)),t)}}}function X(e){try{return u.lstatSync(e).isDirectory()&&u.readdirSync(e).length>0}catch{return!1}}export function nodeToolchainPublishRetryDelay(e,r,t){if(r!=="win32"||!(e instanceof Error))return null;const n=e.code;return n!=="EACCES"&&n!=="EBUSY"&&n!=="EPERM"?null:G[t]??null}async function b(e,r,t,n,o){if(!$(e))return null;const i=p(e);if(i===null)return null;const a=ie(i);if(a===null||!oe(a,r))return null;U(e);const s=o?N(e,i):null;try{await g(e.contentDirectory,r.installed,n)}catch(h){return f(n),h instanceof Y&&s!==null&&T(e,s)&&o?.(s),null}const l=x(e.contentDirectory,r),c=await D(e.contentDirectory,l,r,t,n);return c.npmVersion!==a.npmVersion?null:{binDir:S(e.contentDirectory,r),nodeExecutable:l.nodeExecutable,npmVersion:c.npmVersion,toolchainDir:e.installDirectory}}function N(e,r){try{const t=u.lstatSync(e.installDirectory,{bigint:!0});return!t.isDirectory()||t.isSymbolicLink()||t.ino===0n||t.birthtimeNs===0n?null:{birthtimeNs:t.birthtimeNs,dev:t.dev,ino:t.ino,manifestSource:r}}catch{return null}}function T(e,r){try{const t=N(e,r.manifestSource);return t!==null&&t.birthtimeNs===r.birthtimeNs&&t.dev===r.dev&&t.ino===r.ino&&p(e)===r.manifestSource}catch{return!1}}async function ee(e,r,t,n){const o=[];let i=0;if(e.body!==void 0&&e.body!==null){const l=e.body[Symbol.asyncIterator]();for(;;){const c=await m(l.next(),n);if(c.done)break;if(!(c.value instanceof Uint8Array))throw new Error("Node toolchain download yielded a non-binary chunk");const h=Buffer.from(c.value.buffer,c.value.byteOffset,c.value.byteLength);if(i+=h.byteLength,i>t.archive.sizeBytes)throw await l.return?.(),new Error(`Node toolchain archive exceeds its trusted ${t.archive.sizeBytes.toString()} byte size`);o.push(h)}}else{const l=await m(e.arrayBuffer(),n),c=Buffer.from(l);i=c.byteLength,i<=t.archive.sizeBytes&&o.push(c)}if(i!==t.archive.sizeBytes)throw new Error(`Node toolchain archive size mismatch: expected ${t.archive.sizeBytes.toString()}, received ${i.toString()}`);const a=Buffer.concat(o,i),s=k.createHash("sha256").update(a).digest("hex");if(s!==t.archive.sha256)throw new Error(`Node toolchain archive digest mismatch: expected ${t.archive.sha256}, received ${s}`);f(n),u.writeFileSync(r,a,{flag:"wx",mode:384})}function x(e,r){const t=r.target.platform==="win32"?"node.exe":"bin/node",n=r.target.platform==="win32"?"npm.cmd":"bin/npm";return{nodeExecutable:v(e,t,{allowSymbolicLink:!1}),nodeRelativePath:t,npmCli:r.target.platform==="win32"?v(e,"node_modules/npm/bin/npm-cli.js",{allowSymbolicLink:!1}):null,npmExecutable:v(e,n,{allowSymbolicLink:r.target.platform!=="win32"})}}async function D(e,r,t,n,o){const i=S(e,t),a=await O(r.nodeExecutable,e,i,n,o);if(a!==t.version)throw new Error(`Downloaded Node toolchain version mismatch: expected ${t.version}, received ${a}`);return{npmVersion:await O(r.npmCli===null?r.npmExecutable:r.nodeExecutable,e,i,n,o,r.npmCli===null?[]:[r.npmCli])}}async function O(e,r,t,n,o,i=[]){const a=nodeToolchainVersionInvocation(r,t,process.env,i),s=await n(e,a.args,{...a.options,signal:o});return re(typeof s.stdout=="string"?s.stdout:Buffer.from(s.stdout??"").toString())}export function nodeToolchainVersionInvocation(e,r,t,n=[]){const o=(t.PATH??"").split(d.delimiter).filter(i=>i.length>0&&i!==r);return{args:[...n,"--version"],options:{cwd:e,env:{...t,PATH:[r,...o].join(d.delimiter)},stdio:"pipe",timeoutMs:W,windowsHide:!0}}}function re(e){const r=e.trim().replace(/^v/u,"");if(!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(r))throw new Error(`Toolchain command returned an invalid version: ${JSON.stringify(e)}`);return r}function S(e,r){return r.target.platform==="win32"?e:d.join(e,"bin")}function te(e,r){return{arch:e.target.arch,archiveSha256:e.archive.sha256,installedSha256:e.installed.sha256,npmVersion:r,platform:e.target.platform,schemaVersion:w,version:e.version}}function ne(e,r){u.writeFileSync(e,`${JSON.stringify(r,null,2)}
2
- `,{encoding:"utf8",flag:"wx",mode:384})}function ie(e){let r;try{r=JSON.parse(e)}catch{return null}if(!se(r))return null;const t=["arch","archiveSha256","installedSha256","npmVersion","platform","schemaVersion","version"],n=Object.keys(r).filter(o=>o!=="kind").sort();return n.length!==t.length||n.some((o,i)=>o!==t[i])||r.schemaVersion!==w||!I(r.arch)||!C(r.archiveSha256)||!C(r.installedSha256)||!_(r.npmVersion)||!I(r.platform)||!_(r.version)?null:{arch:r.arch,archiveSha256:r.archiveSha256,installedSha256:r.installedSha256,npmVersion:r.npmVersion,platform:r.platform,schemaVersion:w,version:r.version}}function oe(e,r){return e.version===r.version&&e.platform===r.target.platform&&e.arch===r.target.arch&&e.archiveSha256===r.archive.sha256&&e.installedSha256===r.installed.sha256}function ae(e,r){const t=u.readdirSync(e);if(t.length!==1||t[0]!==d.basename(r)||!u.lstatSync(r).isDirectory()||u.lstatSync(r).isSymbolicLink())throw new Error(`Expected Node archive to contain only ${d.basename(r)}`)}function A(e,r){y.get(e)===r&&y.delete(e)}function ce(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error("Node archive timeout must be a positive safe integer")}function se(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)}function I(e){return typeof e=="string"&&e.length>0}function C(e){return typeof e=="string"&&/^[a-f0-9]{64}$/u.test(e)}function _(e){return typeof e=="string"&&/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(e)}
1
+ import V from"node:crypto";import u from"node:fs";import d from"node:path";import{TarArchiveInspector as z,ZipArchiveExtractor as M}from"./archive.js";import{withProcessLock as B}from"../process/locks.js";import{createContainedTemporaryDirectory as L,existingRealInstallDirectory as $,NODE_TOOLCHAIN_CONTENT_DIRECTORY as P,NODE_TOOLCHAIN_MARKER_FILENAME as R,nodeToolchainCacheKey as j,prepareNodeToolchainCache as H,readRealManifestFile as p,removeContainedInstallDirectory as F,requireContainedExecutable as v,requireRealContentDirectory as U}from"./node-cache.js";import{InstalledNodeTreeMismatchError as Y,validateNodeArchiveEntries as q,verifyInstalledNodeTree as g}from"./node-integrity.js";import{createSharedToolchainOperation as J,throwIfSignalAborted as m,waitForPromise as f,waitForSharedToolchainOperation as K}from"./shared-operation.js";import{TOOLCHAIN_METADATA_TIMEOUT_MS as W}from"./timeouts.js";import{isUnknownRecord as G}from"../kernel/unknown-values.js";const w=2,E=300*1e3,Z=Object.freeze([100,250,500,1e3,2e3,3e3,3e3]),y=new Map;export async function provisionNodeToolchainWithRelease({archiveTimeoutMs:e=E,commandExecutor:r,fetchImpl:t,productDir:n,release:o,signal:i,zipExtractor:a}){m(i);const s=H(n,o),l=j(s);let c=y.get(l);if(c===void 0){c=J(k=>Q({archiveTimeoutMs:e,commandExecutor:r,fetchImpl:t,paths:s,productDir:n,release:o,signal:k,zipExtractor:a})),y.set(l,c);const h=c;c.promise.then(()=>{I(l,h)},()=>{I(l,h)})}return K(c,i)}export async function extractTrustedNodeArchive({archivePath:e,commandExecutor:r,destination:t,release:n,signal:o,timeoutMs:i=E,zipExtractor:a=new M}){if(se(i),u.existsSync(t))throw new Error(`Node archive destination already exists: ${t}`);u.mkdirSync(t,{mode:448});const s=AbortSignal.timeout(i),l=o===void 0?s:AbortSignal.any([o,s]),c=n.archive.format==="zip"?a:new z;try{const h=await c.inspect(e,n.archive.format,{maxEntries:n.archive.entryCount,maxUnpackedSizeBytes:n.archive.unpackedSizeBytes,signal:l});q(h,n),n.archive.format==="zip"?await a.extract(e,t,n.archive.format,{maxEntries:n.archive.entryCount,maxUnpackedSizeBytes:n.archive.unpackedSizeBytes,signal:l}):(await r("tar",["-xzf",e,"-C",t],{cwd:d.dirname(t),env:process.env,signal:l,timeoutMs:i}),m(l))}catch(h){throw u.rmSync(t,{recursive:!0,force:!0}),h}}async function Q(e){let r=null;const t=await b(e.paths,e.release,e.commandExecutor,e.signal,n=>{r=n});return t!==null?t:B(e.paths.lockPath,async()=>{if(m(e.signal),r===null||!T(e.paths,r)){const n=await b(e.paths,e.release,e.commandExecutor,e.signal);if(n!==null)return n}return F(e.paths),X(e)},{signal:e.signal,timeoutMs:null})}async function X(e){const r=L(e.paths.rootDirectory,`.tmp-${e.release.version}`),t=e.release.archive.format==="zip"?".zip":".tar.gz",n=d.join(r,`node${t}`),o=d.join(r,"extracted"),i=d.join(r,"publish"),a=d.join(i,P);try{const s=await f(e.fetchImpl(e.release.archive.url,{signal:e.signal}),e.signal);if(!s.ok)throw new Error(`Failed to download ${e.release.archive.url}: HTTP ${String(s.status)}`);await re(s,n,e.release,e.signal),await extractTrustedNodeArchive({archivePath:n,commandExecutor:e.commandExecutor,destination:o,release:e.release,signal:e.signal,timeoutMs:e.archiveTimeoutMs,zipExtractor:e.zipExtractor});const l=d.join(o,e.release.archive.rootDirectory);ce(o,l),await g(l,e.release.installed,e.signal),u.mkdirSync(i,{mode:448}),u.renameSync(l,a),u.rmSync(o,{recursive:!0,force:!0}),u.rmSync(n,{force:!0});const c=x(a,e.release),h=await D(a,c,e.release,e.commandExecutor,e.signal);return ie(d.join(i,R),ne(e.release,h.npmVersion)),await renameNodeToolchainPublishDirectory(i,e.paths.installDirectory,e.signal),{binDir:S(e.paths.contentDirectory,e.release),nodeExecutable:d.join(e.paths.contentDirectory,c.nodeRelativePath),npmVersion:h.npmVersion,toolchainDir:e.paths.installDirectory}}finally{u.rmSync(r,{recursive:!0,force:!0})}}export async function renameNodeToolchainPublishDirectory(e,r,t){for(let n=0;;n+=1){m(t);try{await u.promises.rename(e,r);return}catch(o){const i=nodeToolchainPublishRetryDelay(o,process.platform,n);if(i===null||ee(r))throw o;await f(new Promise(a=>setTimeout(a,i)),t)}}}function ee(e){try{return u.lstatSync(e).isDirectory()&&u.readdirSync(e).length>0}catch{return!1}}export function nodeToolchainPublishRetryDelay(e,r,t){if(r!=="win32"||!(e instanceof Error))return null;const n=e.code;return n!=="EACCES"&&n!=="EBUSY"&&n!=="EPERM"?null:Z[t]??null}async function b(e,r,t,n,o){if(!$(e))return null;const i=p(e);if(i===null)return null;const a=oe(i);if(a===null||!ae(a,r))return null;U(e);const s=o?N(e,i):null;try{await g(e.contentDirectory,r.installed,n)}catch(h){return m(n),h instanceof Y&&s!==null&&T(e,s)&&o?.(s),null}const l=x(e.contentDirectory,r),c=await D(e.contentDirectory,l,r,t,n);return c.npmVersion!==a.npmVersion?null:{binDir:S(e.contentDirectory,r),nodeExecutable:l.nodeExecutable,npmVersion:c.npmVersion,toolchainDir:e.installDirectory}}function N(e,r){try{const t=u.lstatSync(e.installDirectory,{bigint:!0});return!t.isDirectory()||t.isSymbolicLink()||t.ino===0n||t.birthtimeNs===0n?null:{birthtimeNs:t.birthtimeNs,dev:t.dev,ino:t.ino,manifestSource:r}}catch{return null}}function T(e,r){try{const t=N(e,r.manifestSource);return t!==null&&t.birthtimeNs===r.birthtimeNs&&t.dev===r.dev&&t.ino===r.ino&&p(e)===r.manifestSource}catch{return!1}}async function re(e,r,t,n){const o=[];let i=0;if(e.body!==void 0&&e.body!==null){const l=e.body[Symbol.asyncIterator]();for(;;){const c=await f(l.next(),n);if(c.done)break;if(!(c.value instanceof Uint8Array))throw new Error("Node toolchain download yielded a non-binary chunk");const h=Buffer.from(c.value.buffer,c.value.byteOffset,c.value.byteLength);if(i+=h.byteLength,i>t.archive.sizeBytes)throw await l.return?.(),new Error(`Node toolchain archive exceeds its trusted ${t.archive.sizeBytes.toString()} byte size`);o.push(h)}}else{const l=await f(e.arrayBuffer(),n),c=Buffer.from(l);i=c.byteLength,i<=t.archive.sizeBytes&&o.push(c)}if(i!==t.archive.sizeBytes)throw new Error(`Node toolchain archive size mismatch: expected ${t.archive.sizeBytes.toString()}, received ${i.toString()}`);const a=Buffer.concat(o,i),s=V.createHash("sha256").update(a).digest("hex");if(s!==t.archive.sha256)throw new Error(`Node toolchain archive digest mismatch: expected ${t.archive.sha256}, received ${s}`);m(n),u.writeFileSync(r,a,{flag:"wx",mode:384})}function x(e,r){const t=r.target.platform==="win32"?"node.exe":"bin/node",n=r.target.platform==="win32"?"npm.cmd":"bin/npm";return{nodeExecutable:v(e,t,{allowSymbolicLink:!1}),nodeRelativePath:t,npmCli:r.target.platform==="win32"?v(e,"node_modules/npm/bin/npm-cli.js",{allowSymbolicLink:!1}):null,npmExecutable:v(e,n,{allowSymbolicLink:r.target.platform!=="win32"})}}async function D(e,r,t,n,o){const i=S(e,t),a=await O(r.nodeExecutable,e,i,n,o);if(a!==t.version)throw new Error(`Downloaded Node toolchain version mismatch: expected ${t.version}, received ${a}`);return{npmVersion:await O(r.npmCli===null?r.npmExecutable:r.nodeExecutable,e,i,n,o,r.npmCli===null?[]:[r.npmCli])}}async function O(e,r,t,n,o,i=[]){const a=nodeToolchainVersionInvocation(r,t,process.env,i),s=await n(e,a.args,{...a.options,signal:o});return te(typeof s.stdout=="string"?s.stdout:Buffer.from(s.stdout??"").toString())}export function nodeToolchainVersionInvocation(e,r,t,n=[]){const o=(t.PATH??"").split(d.delimiter).filter(i=>i.length>0&&i!==r);return{args:[...n,"--version"],options:{cwd:e,env:{...t,PATH:[r,...o].join(d.delimiter)},stdio:"pipe",timeoutMs:W,windowsHide:!0}}}function te(e){const r=e.trim().replace(/^v/u,"");if(!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(r))throw new Error(`Toolchain command returned an invalid version: ${JSON.stringify(e)}`);return r}function S(e,r){return r.target.platform==="win32"?e:d.join(e,"bin")}function ne(e,r){return{arch:e.target.arch,archiveSha256:e.archive.sha256,installedSha256:e.installed.sha256,npmVersion:r,platform:e.target.platform,schemaVersion:w,version:e.version}}function ie(e,r){u.writeFileSync(e,`${JSON.stringify(r,null,2)}
2
+ `,{encoding:"utf8",flag:"wx",mode:384})}function oe(e){let r;try{r=JSON.parse(e)}catch{return null}if(!G(r))return null;const t=["arch","archiveSha256","installedSha256","npmVersion","platform","schemaVersion","version"],n=Object.keys(r).filter(o=>o!=="kind").sort();return n.length!==t.length||n.some((o,i)=>o!==t[i])||r.schemaVersion!==w||!A(r.arch)||!C(r.archiveSha256)||!C(r.installedSha256)||!_(r.npmVersion)||!A(r.platform)||!_(r.version)?null:{arch:r.arch,archiveSha256:r.archiveSha256,installedSha256:r.installedSha256,npmVersion:r.npmVersion,platform:r.platform,schemaVersion:w,version:r.version}}function ae(e,r){return e.version===r.version&&e.platform===r.target.platform&&e.arch===r.target.arch&&e.archiveSha256===r.archive.sha256&&e.installedSha256===r.installed.sha256}function ce(e,r){const t=u.readdirSync(e);if(t.length!==1||t[0]!==d.basename(r)||!u.lstatSync(r).isDirectory()||u.lstatSync(r).isSymbolicLink())throw new Error(`Expected Node archive to contain only ${d.basename(r)}`)}function I(e,r){y.get(e)===r&&y.delete(e)}function se(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error("Node archive timeout must be a positive safe integer")}function A(e){return typeof e=="string"&&e.length>0}function C(e){return typeof e=="string"&&/^[a-f0-9]{64}$/u.test(e)}function _(e){return typeof e=="string"&&/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(e)}
@@ -1 +1 @@
1
- const s=["**/*.int.testkit.ts","**/*.e2e.testkit.ts","**/*.contract.testkit.ts","**/*.data.testkit.ts","**/*.dal.testkit.ts","**/*.load.testkit.ts","**/*.ui.testkit.ts"];export function defineConfig(t={},r={}){const e=Reflect.get(t,"test"),n={...c(e)?e:{}},i=new Set([...o(n.exclude,"config.test.exclude"),...r.exclude??[],...s]);return{...t,test:{...n,exclude:[...i]}}}export function testkitExcludes(){return[...s]}function o(t,r){if(t===void 0)return[];if(!Array.isArray(t)||!t.every(e=>typeof e=="string"))throw new TypeError(`${r} must be an array of strings`);return t}function c(t){return t!==null&&typeof t=="object"&&!Array.isArray(t)}
1
+ import{isUnknownRecord as o}from"../../kernel/unknown-values.js";const n=["**/*.int.testkit.ts","**/*.e2e.testkit.ts","**/*.contract.testkit.ts","**/*.data.testkit.ts","**/*.dal.testkit.ts","**/*.load.testkit.ts","**/*.ui.testkit.ts"];export function defineConfig(t={},r={}){const e=Reflect.get(t,"test"),s={...o(e)?e:{}},i=new Set([...c(s.exclude,"config.test.exclude"),...r.exclude??[],...n]);return{...t,test:{...s,exclude:[...i]}}}export function testkitExcludes(){return[...n]}function c(t,r){if(t===void 0)return[];if(!Array.isArray(t)||!t.every(e=>typeof e=="string"))throw new TypeError(`${r} must be an array of strings`);return t}
@@ -45,10 +45,10 @@ Two overlapping refund requests for one order both pay out.
45
45
  ## Reproduce
46
46
 
47
47
  ```sh
48
- npx testkit run --file src/checkout/__testkit__/refund.contract.testkit.ts
48
+ npx testkit run --file src/checkout/__testkit__/refund.int.testkit.ts
49
49
  ```
50
50
 
51
- Test: `src/checkout/__testkit__/refund.contract.testkit.ts`, "a refund pays out once".
51
+ Test: `src/checkout/__testkit__/refund.int.testkit.ts`, "a refund pays out once".
52
52
  Found at `4f0c2d1e9b7a4c3d8e6f5a4b3c2d1e0f9a8b7c6d`.
53
53
  Fault: `src/checkout/refunds.ts`, lines 41 to 58.
54
54
 
@@ -66,9 +66,9 @@ Fault: `src/checkout/refunds.ts`, lines 41 to 58.
66
66
  "escape": "no-test",
67
67
  "caughtBy": "campaign",
68
68
  "campaign": "refunds-2026-10",
69
- "testType": "contract",
69
+ "testType": "int",
70
70
  "test": {
71
- "file": "src/checkout/__testkit__/refund.contract.testkit.ts",
71
+ "file": "src/checkout/__testkit__/refund.int.testkit.ts",
72
72
  "title": "a refund pays out once"
73
73
  },
74
74
  "foundAt": "4f0c2d1e9b7a4c3d8e6f5a4b3c2d1e0f9a8b7c6d",
@@ -130,29 +130,31 @@ A body with no start marker has no facts. A body with a start marker and no read
130
130
 
131
131
  All paths in the block use `/` and are relative to the repository root.
132
132
 
133
- | Field | Type | Written | Meaning |
134
- | ------------------- | -------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------- |
135
- | `v` | `1` | Always | The format version. |
136
- | `cause` | Cause id | Always | The kind of mistake in the code. |
137
- | `impact` | Impact id | Always | What is at stake for the business. |
138
- | `confidence.cause` | `high` or `low` | Always | The finder's confidence in `cause`. |
139
- | `confidence.impact` | `high` or `low` | Always | The finder's confidence in `impact`. |
140
- | `escape` | Escape id | Always | Why the existing tests did not catch the bug. |
141
- | `caughtBy` | `campaign` or `manual` | Always | `campaign` when a campaign was active at record time. |
142
- | `campaign` | Campaign id | With `caughtBy: "campaign"` | The campaign that caught the bug. |
143
- | `testType` | `ui`, `e2e`, `int`, `contract`, `data`, `dal`, or `load` | Always | The type of the regression test, from its file suffix. |
144
- | `test.file` | Path | Always | The regression test file. |
145
- | `test.title` | String | When the issue covers one test | The failure's title. See [Known failures](#known-failures). |
146
- | `foundAt` | Full commit id | Always | The commit the test failed at. |
147
- | `fault.path` | Path | Always | The source file that holds the mistake. |
148
- | `fault.lines` | `[start, end]` | Always | The first and last faulty line at `foundAt`, counted from 1. |
149
- | `area.id` | Area id | When the area map claims `fault.path` | The area's key in `areas`. |
150
- | `area.name` | String | With `area.id` | The area's name at filing time. |
151
- | `area.share` | Number from 0 to 1 | With `area.id` | The area's share of the repository's source bytes at `foundAt`. |
152
- | `introducedBy` | Full commit id | When the blame resolves | The newest commit among the fault lines at `foundAt`. |
153
- | `introducedMethod` | `blame` | With `introducedBy` | How Testkit found `introducedBy`. |
154
-
155
- Testkit writes every field it can resolve. A reader accepts a version 1 block that lacks `escape`, `campaign`, `test.title`, `fault`, `fault.lines`, `area`, `introducedBy`, or `introducedMethod`, because issues that predate the CLI were given facts by hand. A reader ignores fields it does not know.
133
+ | Field | Type | Written | Meaning |
134
+ | ------------------- | -------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
135
+ | `v` | `1` | Always | The format version. |
136
+ | `cause` | Cause id | Always | The kind of mistake in the code. |
137
+ | `impact` | Impact id | Always | What is at stake for the business. |
138
+ | `confidence.cause` | `high` or `low` | Always | The finder's confidence in `cause`. |
139
+ | `confidence.impact` | `high` or `low` | Always | The finder's confidence in `impact`. |
140
+ | `escape` | Escape id | Always | Why the existing tests did not catch the bug. |
141
+ | `caughtBy` | `campaign` or `manual` | Always | `campaign` when a campaign was active at record time. |
142
+ | `campaign` | Campaign id | With `caughtBy: "campaign"` | The campaign that caught the bug. |
143
+ | `testType` | `ui`, `e2e`, `int`, `contract`, `data`, `dal`, or `load` | When the test file's name gives a type | The type of the regression test, from its file suffix. |
144
+ | `test.file` | Path | Always | The regression test file. |
145
+ | `test.title` | String | When the issue covers one test | The failure's title. See [Known failures](#known-failures). |
146
+ | `foundAt` | Full commit id | Always | The commit the test failed at. |
147
+ | `fault.path` | Path | Always | The source file that holds the mistake. |
148
+ | `fault.lines` | `[start, end]` | Always | The first and last faulty line at `foundAt`, counted from 1. |
149
+ | `area.id` | Area id | When the area map claims `fault.path` | The area's key in `areas`. |
150
+ | `area.name` | String | With `area.id` | The area's name at filing time. |
151
+ | `area.share` | Number from 0 to 1 | With `area.id` | The area's share of the repository's source bytes at `foundAt`. |
152
+ | `introducedBy` | Full commit id | When the blame resolves | The newest commit among the fault lines at `foundAt`. |
153
+ | `introducedMethod` | `blame` | With `introducedBy` | How Testkit found `introducedBy`. |
154
+
155
+ Testkit writes every field it can resolve. A reader accepts a version 1 block that lacks `escape`, `campaign`, `testType`, `test.title`, `fault`, `fault.lines`, `area`, `introducedBy`, or `introducedMethod`, because issues that predate the CLI were given facts by hand and a test file's name may give no type. A reader ignores fields it does not know.
156
+
157
+ A reader accepts every `testType` in the list above, including a type that Testkit no longer writes. Issues keep the type they were filed with, so the list never loses a value. A new type is a format change.
156
158
 
157
159
  A reader rejects a block whose `v` it does not know, and a block where a field that is present has the wrong type or an id outside its list.
158
160
 
@@ -160,36 +162,36 @@ A reader rejects a block whose `v` it does not know, and a block where a field t
160
162
 
161
163
  The question is what kind of mistake is in the code. A bug gets exactly one cause. When a bug holds several mistakes, classify the earliest one in the causal chain. Use `other` when none fits.
162
164
 
163
- | Id | Definition | Boundary rule |
164
- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
- | `missing-validation` | The code never checks an input or a state precondition that the domain rules require: range, format, existence, uniqueness, cross-field consistency, or lifecycle state. Includes a validator that exists and accepts invalid input. | Earliest mistake wins. If application code never tested the condition, it is `missing-validation` even when a database constraint or the runtime later throws and the caller sees a server error. If the unchecked value is null, absent, empty, zero, or a default, use `null-or-default-mishandling`. |
166
- | `mishandled-error` | A failure was detected and signalled, and the handling is wrong: not caught, translated to the wrong error, swallowed, ignored by the UI, or followed by a wrong retry or recovery. | The failure must already be signalled by something that checked. If nothing in the application checked and a lower layer failed, it is `missing-validation`. |
167
- | `race-condition` | Correctness depends on how overlapping operations interleave: check-then-act without a lock or constraint, a lost update, or a late asynchronous completion that overwrites newer state. | Needs two overlapping operations to reproduce. If one sequential flow reproduces it, it is `stale-state` or `missing-validation`. |
168
- | `unbounded-or-inefficient-work` | Results are correct and the cost is unbounded or needlessly repeated: unpaginated reads, N+1 queries, a missing or redundant index, serial waits, or loading everything and then slicing. | Correct results at excessive cost. If the result is wrong, it is `incorrect-query-or-selection`. |
169
- | `incorrect-query-or-selection` | The code identifies the wrong set of records: a missing or wrong predicate, unstable ordering, a truncated page treated as complete, duplicate rows from a join, or a lookup key that omits a distinguishing part. | A missing predicate is `missing-access-control` only when it separates actors or tenants with different rights. The right records with wrong arithmetic on them is `incorrect-logic`. |
170
- | `missing-access-control` | A permission, tenant, ownership, or separation-of-duties check is absent or wrong. | Use it when the missing check is about who the actor is. A missing check about what the data or state is belongs in `missing-validation`. |
171
- | `null-or-default-mishandling` | Null, absent, empty, zero, or default values are conflated or not handled: null treated as omitted, a zero or an empty collection treated as missing, a null dereference, a wrong fallback default, or a caller that omits an argument and silently gets the default. | Wins over `missing-validation` and `incorrect-logic` whenever the triggering value is null, absent, empty, zero, or a default. |
172
- | `stale-state` | Held state, such as a cache, local UI state, a derived or denormalised value, or leftover fields, is not refreshed, reset, persisted, or tied to the right identity when the thing it mirrors changes. | If the omission leaves old values visible where current ones should be, it is `stale-state`. If the missing action leaves nothing stale, it is `omitted-step`. If it reproduces only with overlapping operations, it is `race-condition`. |
173
- | `interface-mismatch` | Data is lost or misread where it crosses between representations or components. One component's mapping drops, overwrites, hardcodes, or mis-assigns fields, or two components that must agree disagree on shape, type, field names, allowed values, semantics, or which copy of the data is authoritative. | Use it when the values were right before the crossing. Wrong computation on values that arrived intact is `incorrect-logic`. A held copy that goes out of date after its source changes is `stale-state`. An omitted argument that falls back to a default is `null-or-default-mishandling`. |
174
- | `omitted-step` | A required follow-on action is not performed on some path: an audit record, a notification, a dispatch, a finalisation, or an alternate route that bypasses the shared routine. | If the missing thing is a check, it is `missing-validation`. If its absence leaves old values on display, it is `stale-state`. |
175
- | `incorrect-logic` | The algorithm, arithmetic, date or time handling, rounding, boundary, comparison, boolean condition, parsing, or order of operations is wrong. | The default for wrong logic on valid, present values. If the trigger is a null, empty, zero, or default value, use `null-or-default-mishandling`. If the fault is which records were chosen, use `incorrect-query-or-selection`. |
176
- | `other` | No category above describes the mistake. | |
165
+ | Id | Definition | Boundary rule |
166
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
167
+ | `missing-validation` | The code never checks an input or a state precondition that the domain rules require: range, format, existence, uniqueness, cross-field consistency, or lifecycle state. Includes a validator that exists and accepts invalid input. | Earliest mistake wins. If application code never tested the condition, it is `missing-validation` even when a database constraint or the runtime later throws and the caller sees an unexpected error. If the unchecked value is null, absent, empty, zero, or a default, use `null-or-default-mishandling`. |
168
+ | `mishandled-error` | A failure was detected and signalled, and the handling is wrong: not caught, translated to the wrong error, swallowed, ignored by the UI, or followed by a wrong retry or recovery. | The failure must already be signalled by something that checked. If nothing in the application checked and a lower layer failed, it is `missing-validation`. |
169
+ | `race-condition` | Correctness depends on how overlapping operations interleave: check-then-act without a lock or constraint, a lost update, or a late asynchronous completion that overwrites newer state. | Needs two overlapping operations to reproduce. If one sequential flow reproduces it, it is `stale-state` or `missing-validation`. |
170
+ | `unbounded-or-inefficient-work` | Results are correct and the cost is unbounded or needlessly repeated: unpaginated reads, N+1 queries, a missing or redundant index, serial waits, or loading everything and then slicing. | Correct results at excessive cost. If the result is wrong, it is `incorrect-query-or-selection`. |
171
+ | `incorrect-query-or-selection` | The code identifies the wrong set of records: a missing or wrong predicate, unstable ordering, a truncated page treated as complete, duplicate rows from a join, or a lookup key that omits a distinguishing part. | A missing predicate is `missing-access-control` only when it separates actors or tenants with different rights. The right records with wrong arithmetic on them is `incorrect-logic`. |
172
+ | `missing-access-control` | A permission, tenant, ownership, or separation-of-duties check is absent or wrong. | Use it when the missing check is about who the actor is. A missing check about what the data or state is belongs in `missing-validation`. |
173
+ | `null-or-default-mishandling` | Null, absent, empty, zero, or default values are conflated or not handled: null treated as omitted, a zero or an empty collection treated as missing, a null dereference, a wrong fallback default, or a caller that omits an argument and silently gets the default. | Wins over `missing-validation` and `incorrect-logic` whenever the triggering value is null, absent, empty, zero, or a default. |
174
+ | `stale-state` | Held state, such as a cache, local UI state, a derived or denormalised value, or leftover fields, is not refreshed, reset, persisted, or tied to the right identity when the thing it mirrors changes. | If the omission leaves old values visible where current ones should be, it is `stale-state`. If the missing action leaves nothing stale, it is `omitted-step`. If it reproduces only with overlapping operations, it is `race-condition`. |
175
+ | `interface-mismatch` | Data is lost or misread where it crosses between representations or components. One component's mapping drops, overwrites, hardcodes, or mis-assigns fields, or two components that must agree disagree on shape, type, field names, allowed values, semantics, or which copy of the data is authoritative. | Use it when the values were right before the crossing. Wrong computation on values that arrived intact is `incorrect-logic`. A held copy that goes out of date after its source changes is `stale-state`. An omitted argument that falls back to a default is `null-or-default-mishandling`. |
176
+ | `omitted-step` | A required follow-on action is not performed on some path: an audit record, a notification, a dispatch, a finalisation, or an alternate route that bypasses the shared routine. | If the missing thing is a check, it is `missing-validation`. If its absence leaves old values on display, it is `stale-state`. |
177
+ | `incorrect-logic` | The algorithm, arithmetic, date or time handling, rounding, boundary, comparison, boolean condition, parsing, or order of operations is wrong. | The default for wrong logic on valid, present values. If the trigger is a null, empty, zero, or default value, use `null-or-default-mishandling`. If the fault is which records were chosen, use `incorrect-query-or-selection`. |
178
+ | `other` | No category above describes the mistake. | |
177
179
 
178
180
  ## Impact
179
181
 
180
182
  The question is what is at stake for the business. A bug gets exactly one impact. The list is in precedence order, and the first type that applies wins. Classify the impact the test demonstrated, not a downstream one you expect. Use `other` when none fits.
181
183
 
182
- | Id | Definition | Boundary rule |
183
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
184
- | `unauthorized-access` | Someone can see or change something they are not entitled to, including getting past a separation-of-duties rule. | The actor lacks the right. If an entitled actor skips a mandated control or leaves no evidence, it is `audit-or-compliance-gap`. |
185
- | `financial-error` | A monetary amount, rate, currency, limit, payment or settlement state, or a money-committing decision is wrong where it is stored, charged, paid, or decided. | A money figure that is only displayed wrongly, with the stored value and the decision intact, is `wrong-information-shown`. |
186
- | `audit-or-compliance-gap` | Required audit evidence or attribution is missing, duplicated, or false, or a mandated control did not run. | Use it when the harm is to evidence or to a regulatory or policy control. A skipped ordinary process step is `workflow-breakdown`. |
187
- | `data-loss-or-corruption` | Stored data, or work the user entered, is destroyed, overwritten, duplicated, or left invalid or inconsistent. | If the action silently did nothing and prior stored state is intact, it is `blocked-operation`. |
188
- | `workflow-breakdown` | A business process, such as a workflow, task, schedule, notification, or gate, is skipped, stalled, duplicated, or advanced wrongly. | Use it when the process goes wrong after the triggering request returns. A single user action that fails in front of the user is `blocked-operation`. |
189
- | `availability-or-performance` | The service is slow, unavailable, or exhausts resources in a way that affects more than the triggering request or grows with data volume. | One request that fails for one user is `blocked-operation`. |
190
- | `blocked-operation` | A legitimate action fails, returns an unusable or misleading error, or silently does not apply, with prior stored state intact. | The user was trying to do something and could not. If they were only reading and what they read was wrong, it is `wrong-information-shown`. |
191
- | `wrong-information-shown` | Displayed or returned information is wrong, out of date, incomplete, or duplicated, with nothing stored wrongly. | Last in precedence. If the wrong value is also persisted, it is `data-loss-or-corruption`. |
192
- | `other` | No type above describes what is at stake. | |
184
+ | Id | Definition | Boundary rule |
185
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
186
+ | `unauthorized-access` | Someone can see or change something they are not entitled to, including getting past a separation-of-duties rule. | The actor lacks the right. If an entitled actor skips a mandated control or leaves no evidence, it is `audit-or-compliance-gap`. |
187
+ | `financial-error` | A monetary amount, rate, currency, limit, payment or settlement state, or a money-committing decision is wrong where it is stored, charged, paid, or decided. | A money figure that is only displayed wrongly, with the stored value and the decision intact, is `wrong-information-shown`. |
188
+ | `audit-or-compliance-gap` | Required audit evidence or attribution is missing, duplicated, or false, or a mandated control did not run. | Use it when the harm is to evidence or to a regulatory or policy control. A skipped ordinary process step is `workflow-breakdown`. |
189
+ | `data-loss-or-corruption` | Stored data, or work the user entered, is destroyed, overwritten, duplicated, or left invalid or inconsistent. | If the action silently did nothing and prior stored state is intact, it is `blocked-operation`. |
190
+ | `workflow-breakdown` | A business process, such as a workflow, task, schedule, notification, or gate, is skipped, stalled, duplicated, or advanced wrongly. | Use it when the process goes wrong after the triggering call returns. A single user action that fails in front of the user is `blocked-operation`. |
191
+ | `availability-or-performance` | The product is slow, unavailable, or exhausts resources in a way that affects more than the triggering call or grows with data volume. | One call that fails for one caller is `blocked-operation`. |
192
+ | `blocked-operation` | A legitimate action fails, returns an unusable or misleading error, or silently does not apply, with prior stored state intact. | The user was trying to do something and could not. If they were only reading and what they read was wrong, it is `wrong-information-shown`. |
193
+ | `wrong-information-shown` | Displayed or returned information is wrong, out of date, incomplete, or duplicated, with nothing stored wrongly. | Last in precedence. If the wrong value is also persisted, it is `data-loss-or-corruption`. |
194
+ | `other` | No type above describes what is at stake. | |
193
195
 
194
196
  ## Escape reason
195
197
 
@@ -239,8 +239,8 @@ The workspace can also provide a caller-owned `cacheDirectory`. Node/Vitest and
239
239
  Playwright use a private child directory for Node's compilation cache so fresh
240
240
  CLI and worker processes can reuse compiled code across files. Evaluated modules,
241
241
  consumer globals, runtime inputs, and native runner state remain process-local.
242
- The local run environment shares the cache across leases of one application
243
- runtime and removes it after that runtime's executions and services stop.
242
+ Test execution shares the cache across every task of one run and removes it
243
+ when the run's tasks finish.
244
244
  Adapters leave invocation temporary directories empty after cleanup. Callers
245
245
  that omit `cacheDirectory` retain execution without a shared compilation cache.
246
246
  Explicit consumer Node cache controls or V8 coverage disable automatic caching,
@@ -248,12 +248,14 @@ and an unavailable cache falls back to normal execution.
248
248
  On Windows, automatic cache allocation and Node startup use the extended path
249
249
  form so deeply nested product directories can retain compilation caching.
250
250
 
251
- The local run environment places engine workspaces and compilation caches below
252
- the graph's `node_modules/testkit-engine` directory. Playwright skips that tree
253
- before collecting files, so concurrent discovery cannot traverse another task's
254
- temporary outputs as they change. Consumer test directories, ignore rules,
255
- dependency projects, and snapshot paths keep their original configuration.
256
- Releasing a lease removes its engine workspace; runtime cleanup removes its cache.
251
+ Test execution places engine workspaces and the compilation cache below
252
+ `.testkit/_execution/node_modules/testkit-engine`, and each task's lease
253
+ directory below `.testkit/_execution/leases`. Playwright skips the
254
+ `node_modules` tree before collecting files, so concurrent discovery cannot
255
+ traverse another task's temporary outputs as they change. Consumer test
256
+ directories, ignore rules, dependency projects, and snapshot paths keep their
257
+ original configuration. Releasing a lease removes its directory and engine
258
+ workspace; the end of the run removes the cache.
257
259
 
258
260
  Adapters prepare the task batch before executing it. Shared `executeEngineTasks`
259
261
  starts tasks in order and defers evidence and terminal notifications until all
@@ -430,9 +432,17 @@ or moves work into another service is outside the contract.
430
432
 
431
433
  Testkit checks a POSIX process group after every completion path and records a
432
434
  cleanup error when signalling fails or the group remains after forced
433
- termination. Windows does not expose an equivalent portable post-termination
434
- proof. A null `terminationError` therefore means Testkit observed no cleanup
435
- failure; it does not prove that every descendant exited. Workloads that require
435
+ termination. On Windows, a bounded command that Testkit cancels or times out is
436
+ stopped by the packaged native helper instead of `taskkill`. The helper
437
+ terminates the command and its descendants, parents first, and waits up to four
438
+ seconds for every one of them to exit. A loaded Windows host can take seconds to
439
+ finish a terminated process, and until then the process still holds its files.
440
+ A member that outlives the deadline, or that the helper cannot open or
441
+ terminate, becomes the `terminationError`, and the command then returns without
442
+ waiting for output that a surviving process holds open. Windows offers no
443
+ equivalent check after a command exits on its own or for service handles. A null
444
+ `terminationError` therefore means Testkit observed no cleanup failure; it does
445
+ not prove that every descendant exited. Workloads that require
436
446
  hard containment need a container, VM, cgroup, service manager, or Windows Job
437
447
  Object owned outside this adapter.
438
448
 
@@ -453,6 +463,24 @@ native publication avoids that sequence while preserving complete-file
453
463
  visibility and duplicate run-ID rejection. This file operation does not create
454
464
  a Job Object, so library consumers retain control of their process ownership.
455
465
 
466
+ Each runtime instance reserves an OS-assigned port for every local service when
467
+ it first prepares, by listening on `127.0.0.1:0` and on the same port at `::1`
468
+ when IPv6 is available, and keeping both sockets open.
469
+ Template resolution reads the reserved ports into `{port}`, `baseUrl`, and
470
+ `readyUrl`, and the resolved config carries the port as `local.assignedPort`.
471
+ Service startup resolves the toolchain and prepares the invocation, then closes
472
+ the reservation immediately before it spawns the service and passes the port as
473
+ `PORT`. A service that exits before it becomes ready gets a fresh reservation,
474
+ and the instance's configs are resolved again. The context keeps holding every
475
+ port a service already tried, so the OS cannot hand one back.
476
+ Already started services whose start command, resolved env, readiness URL, or
477
+ prepare steps changed are stopped and started again, and the fingerprinted
478
+ `runtime.prepare` steps of every service not yet started run again before the
479
+ next attempt. Each service gets three attempts in total. Runtime cleanup closes
480
+ any reservation a service never claimed. Schema refresh and verify resolve
481
+ their configs with ports reserved the same way and released at once, because
482
+ they start no services.
483
+
456
484
  Long-running application services keep the live Execa handle for readiness,
457
485
  output drain, explicit stop, and normal teardown. Once the child reports spawn,
458
486
  Testkit records its creation identity and the POSIX process-group ID needed for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gfdelarue/testkit",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Test orchestration platform for product acceptance and QA workflows",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -30,7 +30,6 @@ export default defineConfig({
30
30
  api: app.node({
31
31
  cwd: ".",
32
32
  entry: "src/index.ts",
33
- port: 3004,
34
33
  envFiles: [".env.testkit"],
35
34
  database: database.postgres({
36
35
  runtimeConnections: 20,
@@ -50,7 +49,6 @@ export default defineConfig({
50
49
  frontend: app.next({
51
50
  cwd: "frontend",
52
51
  mode: "start",
53
- port: 3000,
54
52
  dependsOn: ["api"],
55
53
  envFiles: ["frontend/.env.testkit"],
56
54
  env: {
@@ -336,7 +334,6 @@ Local runtime configuration.
336
334
 
337
335
  - `start`
338
336
  - `cwd`
339
- - `port`
340
337
  - `baseUrl`
341
338
  - `readyUrl`
342
339
  - `readyTimeoutMs`
@@ -369,8 +366,49 @@ Windows service a graceful cleanup interval. Process ownership checks still
369
366
  apply; this option does not guarantee cleanup of detached resources or Compose
370
367
  containers.
371
368
 
372
- Both `app.node({ port: 3000, shutdownTimeoutMs: 5000 })` and
373
- `app.next({ port: 3000, shutdownTimeoutMs: 5000 })` place the option under `local`.
369
+ Both `app.node({ shutdownTimeoutMs: 5000 })` and
370
+ `app.next({ shutdownTimeoutMs: 5000 })` place the option under `local`.
371
+
372
+ #### Ports
373
+
374
+ Testkit asks the OS for a free port for each service in each runtime instance:
375
+ it binds `127.0.0.1:0`, binds the same port on `::1` when the host has IPv6, and
376
+ holds both sockets until it has resolved the service's toolchain and is about to
377
+ spawn it. Another process cannot take the port while Testkit holds it. The
378
+ remaining gap runs from that release until the service binds the port, which
379
+ includes the service's own startup time. The service receives the port as `PORT` and
380
+ as `{port}`. `baseUrl` must use `{port}`, or `{port:<its own name>}`, as its
381
+ port, for example `http://127.0.0.1:{port}`. `app.node` and `app.next` default to that `baseUrl`.
382
+
383
+ `local.port`, the `port` option of `app.node` and `app.next`, and a literal port
384
+ in `baseUrl` or `readyUrl` are config errors. The error names the service and
385
+ the key. Delete the setting and use `{port}`. Testkit has no setting that pins
386
+ a service to a fixed port.
387
+
388
+ If a service exits before it becomes ready, Testkit assigns it a fresh port and
389
+ starts it again. Testkit holds each port that a service already tried, so a
390
+ retry never gets one back. A service that already started and whose `start`, env,
391
+ `readyUrl`, or `runtime.prepare` steps name the moved service is stopped and
392
+ started again with the new value. The fingerprinted `runtime.prepare` steps of
393
+ every service that has not started yet run again first. Each service gets three attempts in
394
+ total. After the third, the run fails as an infrastructure failure whose error
395
+ lists each attempt's port and exit status and the last attempt's output. A
396
+ startup that fails another way after an earlier attempt, such as a readiness
397
+ timeout, also lists every port tried.
398
+
399
+ A `runtime.prepare` step whose environment or command includes a service URL or
400
+ port, such as a Next build that embeds `NEXT_PUBLIC_API_URL: "{baseUrl:api}"`,
401
+ sees a new port on every run, so its fingerprint changes and it rebuilds. Pass
402
+ such values at start time when the build should be cached.
403
+
404
+ `run.json` records each service's starts under `environment.services` as they
405
+ happen, with its `runtimeLabel`, `serviceName`, `baseUrl`, `readyUrl`, `port`,
406
+ and each attempt's port and outcome. A run whose startup fails keeps the
407
+ attempts it made, and a service restarted for a moved peer shows both starts.
408
+ The recorded URLs drop credentials, query values, and fragments, so
409
+ `http://127.0.0.1:41234/health?apiKey=x` is stored as
410
+ `http://127.0.0.1:41234/health?apiKey=<redacted>`. `testkit status` lists the
411
+ ports of active runs.
374
412
 
375
413
  Template tokens are supported in strings:
376
414
 
@@ -1,2 +0,0 @@
1
- import { buildTemplateContext, type ExecutionEnvironmentConfig } from "../../steps/index.js";
2
- export declare function buildPlaywrightUiConfig(config: Readonly<ExecutionEnvironmentConfig>, baseUrl: string, lease: Readonly<Parameters<typeof buildTemplateContext>[1]>): unknown;