@gfdelarue/testkit 0.4.4 → 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.
- package/README.md +108 -21
- package/bin/testkit-windows-owner-arm64.exe +0 -0
- package/bin/testkit-windows-owner-x64.exe +0 -0
- package/dist/app/bugs/categories.js +1 -1
- package/dist/app/bugs/draft.js +3 -3
- package/dist/app/bugs/facts.d.ts +3 -2
- package/dist/app/bugs/facts.js +2 -2
- package/dist/app/bugs/guide.js +1 -1
- package/dist/app/bugs/run-reading.js +1 -1
- package/dist/app/db-schema/prepare.js +1 -1
- package/dist/app/destroy/index.js +1 -1
- package/dist/app/doctor/index.js +1 -1
- package/dist/app/environments/config-driven.d.ts +5 -0
- package/dist/app/environments/config-driven.js +1 -0
- package/dist/app/environments/routing.d.ts +6 -0
- package/dist/app/environments/routing.js +1 -0
- package/dist/app/environments/supplied.d.ts +3 -0
- package/dist/app/environments/supplied.js +1 -0
- package/dist/app/file-selection.js +1 -1
- package/dist/app/lint/native.js +1 -1
- package/dist/app/lint/options.d.ts +0 -1
- package/dist/app/lint/options.js +1 -1
- package/dist/app/lint/shared.d.ts +0 -2
- package/dist/app/lint/shared.js +1 -1
- package/dist/app/package-inspection.js +1 -1
- package/dist/app/reports/files.d.ts +13 -0
- package/dist/app/reports/files.js +1 -0
- package/dist/app/reports/prepare.d.ts +20 -0
- package/dist/app/reports/prepare.js +2 -0
- package/dist/app/reports/publish.d.ts +21 -0
- package/dist/app/reports/publish.js +1 -0
- package/dist/app/run/engine-evidence-persistence.js +1 -1
- package/dist/app/run/engine-execution-cleanup.js +1 -1
- package/dist/app/run/engine-execution-failures.js +1 -1
- package/dist/app/run/engine-execution.d.ts +10 -8
- package/dist/app/run/engine-execution.js +1 -1
- package/dist/app/run/engine-orchestration.d.ts +15 -34
- package/dist/app/run/engine-orchestration.js +1 -1
- package/dist/app/run/environment.d.ts +33 -0
- package/dist/app/run/environment.js +1 -0
- package/dist/app/run/failure-details.js +1 -1
- package/dist/app/run/live-run.d.ts +3 -1
- package/dist/app/run/live-run.js +1 -1
- package/dist/app/run/orchestrator.d.ts +8 -1
- package/dist/app/run/orchestrator.js +1 -1
- package/dist/app/run/planning.d.ts +1 -2
- package/dist/app/run/planning.js +1 -1
- package/dist/app/run/results.d.ts +1 -1
- package/dist/app/run/results.js +1 -1
- package/dist/app/run/run-finalization.d.ts +6 -2
- package/dist/app/run/run-finalization.js +1 -1
- package/dist/app/run/runtime-fixtures.js +1 -1
- package/dist/app/run/runtime-task-types.d.ts +1 -1
- package/dist/app/run/scheduler/index.js +1 -1
- package/dist/app/run/scheduler/locks.d.ts +6 -0
- package/dist/app/run/scheduler/locks.js +1 -0
- package/dist/app/run/selection.d.ts +0 -1
- package/dist/app/run/selection.js +1 -1
- package/dist/app/run/setup/runtime-node-setup-protocol.js +1 -1
- package/dist/app/run/setup/runtime-node-setup-worker.js +1 -1
- package/dist/app/run/task-leases.d.ts +15 -0
- package/dist/app/run/task-leases.js +1 -0
- package/dist/app/run/worker-loop.d.ts +13 -4
- package/dist/app/run/worker-loop.js +1 -1
- package/dist/app/tracker/index.d.ts +6 -1
- package/dist/app/tracker/index.js +1 -1
- package/dist/app/typecheck/index.js +4 -4
- package/dist/cli/command-catalog.d.ts +19 -0
- package/dist/cli/command-catalog.js +1 -1
- package/dist/cli/command-registry.js +1 -1
- package/dist/cli/commands/report/prepare.d.ts +12 -0
- package/dist/cli/commands/report/prepare.js +5 -0
- package/dist/cli/commands/report/publish.d.ts +13 -0
- package/dist/cli/commands/report/publish.js +2 -0
- package/dist/cli/json-errors.js +1 -1
- package/dist/cli/operations/run/operation.d.ts +3 -1
- package/dist/cli/operations/run/operation.js +2 -2
- package/dist/cli/renderers/doctor/text.js +1 -1
- package/dist/cli/renderers/run/formatting.js +1 -1
- package/dist/config/authoring/index.d.ts +4 -13
- package/dist/config/authoring/index.js +1 -1
- package/dist/config/authoring/preset-env.js +1 -1
- package/dist/config/loader/config-loader.js +1 -1
- package/dist/config/loader/database.js +1 -1
- package/dist/config/loader/decode-execution.d.ts +0 -2
- package/dist/config/loader/decode-execution.js +1 -1
- package/dist/config/loader/discover-tests.js +1 -1
- package/dist/config/loader/discovery.d.ts +0 -1
- package/dist/config/loader/discovery.js +2 -2
- package/dist/config/loader/env.js +1 -1
- package/dist/config/loader/execution-model.d.ts +0 -1
- package/dist/config/loader/execution-model.js +1 -1
- package/dist/config/loader/file-metadata.d.ts +1 -1
- package/dist/config/loader/file-metadata.js +1 -1
- package/dist/config/loader/groups.d.ts +33 -0
- package/dist/config/loader/groups.js +1 -0
- package/dist/config/loader/load.d.ts +0 -2
- package/dist/config/loader/load.js +1 -1
- package/dist/config/loader/runtime-isolation.js +1 -1
- package/dist/config/loader/runtime.d.ts +0 -9
- package/dist/config/loader/runtime.js +1 -1
- package/dist/config/loader/skip-config.js +1 -1
- package/dist/config/loader/validation.d.ts +1 -2
- package/dist/config/loader/validation.js +1 -1
- package/dist/config/loader/values.d.ts +0 -1
- package/dist/config/loader/values.js +1 -1
- package/dist/database/activity-gate.js +1 -1
- package/dist/database/admin.js +2 -2
- package/dist/database/lifecycle-cleanup.d.ts +1 -0
- package/dist/database/lifecycle-cleanup.js +1 -1
- package/dist/database/local-postgres.js +1 -1
- package/dist/database/naming.js +1 -1
- package/dist/database/schema-operations.d.ts +1 -1
- package/dist/database/schema-operations.js +1 -1
- package/dist/database/schema-source.js +3 -3
- package/dist/database/source-url.d.ts +0 -1
- package/dist/database/source-url.js +2 -2
- package/dist/database/state.js +1 -1
- package/dist/database/steps.js +3 -3
- package/dist/domain/file-timeout.d.ts +0 -3
- package/dist/domain/file-timeout.js +1 -1
- package/dist/domain/operation-display.js +1 -1
- package/dist/domain/test-outcomes.d.ts +0 -1
- package/dist/domain/test-outcomes.js +1 -1
- package/dist/engines/adapters/evidence-identity.js +1 -1
- package/dist/engines/adapters/execution.d.ts +0 -1
- package/dist/engines/adapters/execution.js +1 -1
- package/dist/engines/api/capabilities.d.ts +0 -10
- package/dist/engines/api/capabilities.js +1 -1
- package/dist/engines/api/execution-scope.js +1 -1
- package/dist/engines/api/k6-runtime-input.js +1 -1
- package/dist/engines/bundle-cache.js +1 -1
- package/dist/engines/contracts/fixture-manifest.js +1 -1
- package/dist/engines/definitions.d.ts +1 -0
- package/dist/engines/definitions.js +1 -1
- package/dist/engines/installations/playwright-resolution-child.js +1 -1
- package/dist/engines/installations/playwright.js +1 -1
- package/dist/engines/k6/adapter/evidence.js +4 -4
- package/dist/engines/k6/adapter/executor.js +1 -1
- package/dist/engines/k6/adapter/native-summary.js +1 -1
- package/dist/engines/k6/runtime/checks.js +1 -1
- package/dist/engines/k6/runtime/dal-fixtures.js +1 -1
- package/dist/engines/k6/runtime/dal-suite.js +1 -1
- package/dist/engines/k6/runtime/http-suite-runtime.js +1 -1
- package/dist/engines/k6/runtime/http.js +1 -1
- package/dist/engines/k6/runtime/shared/db-normalization.js +1 -1
- package/dist/engines/k6/runtime/shared/fixture-engine.js +2 -2
- package/dist/engines/k6/runtime/shared/http-parsing.js +3 -3
- package/dist/engines/managed-module-resolution-policy-input.d.ts +0 -1
- package/dist/engines/managed-module-resolution-policy-input.js +1 -1
- package/dist/engines/managed-module-resolution-policy.js +1 -1
- package/dist/engines/node-vitest/adapter/adapter.js +1 -1
- package/dist/engines/node-vitest/adapter/evidence.js +2 -2
- package/dist/engines/node-vitest/adapter/workspace.js +22 -21
- package/dist/engines/node-vitest/runtime/contract/index.js +1 -1
- package/dist/engines/node-vitest/runtime/data/http.js +1 -1
- package/dist/engines/node-vitest/runtime/data/index.d.ts +2 -2
- package/dist/engines/node-vitest/runtime/data/index.js +1 -1
- package/dist/engines/node-vitest/runtime/data/report.js +5 -5
- package/dist/engines/node-vitest/runtime/data/scenario.d.ts +0 -1
- package/dist/engines/node-vitest/runtime/data/scenario.js +2 -2
- package/dist/engines/node-vitest/runtime/data/types.d.ts +1 -29
- package/dist/engines/node-vitest/runtime/runtime-input.js +1 -1
- package/dist/engines/playwright/adapter/adapter.js +1 -1
- package/dist/engines/playwright/adapter/config.d.ts +1 -1
- package/dist/engines/playwright/adapter/config.js +4 -4
- package/dist/engines/playwright/adapter/evidence-validation.d.ts +0 -3
- package/dist/engines/playwright/adapter/evidence-validation.js +1 -1
- package/dist/engines/playwright/adapter/evidence.js +1 -1
- package/dist/engines/playwright/runtime/config-file.js +1 -1
- package/dist/engines/playwright/runtime/config.js +1 -1
- package/dist/engines/playwright/runtime/runtime-input.d.ts +2 -2
- package/dist/engines/playwright/runtime/runtime-input.js +16 -12
- package/dist/environment/lifecycle-artifacts.js +1 -1
- package/dist/environment/lifecycle-controller.d.ts +1 -11
- package/dist/environment/lifecycle-controller.js +1 -1
- package/dist/environment/lifecycle.d.ts +14 -11
- package/dist/environment/lifecycle.js +1 -1
- package/dist/environment/planning-types.d.ts +0 -2
- package/dist/environment/ports.d.ts +10 -0
- package/dist/environment/ports.js +1 -0
- package/dist/environment/ready-environment.d.ts +2 -5
- package/dist/environment/ready-environment.js +1 -1
- package/dist/environment/run-environment.d.ts +1 -0
- package/dist/environment/run-environment.js +1 -1
- package/dist/environment/runtime-contexts.d.ts +11 -7
- package/dist/environment/runtime-contexts.js +1 -1
- package/dist/environment/runtime-manager.d.ts +1 -9
- package/dist/environment/runtime-manager.js +1 -1
- package/dist/environment/runtime-planning.js +1 -1
- package/dist/environment/runtime-preparation.js +1 -1
- package/dist/environment/runtime-service.d.ts +0 -6
- package/dist/environment/services.d.ts +11 -3
- package/dist/environment/services.js +3 -1
- package/dist/environment/state.d.ts +1 -4
- package/dist/environment/state.js +1 -1
- package/dist/environment/template.d.ts +6 -22
- package/dist/environment/template.js +1 -1
- package/dist/evidence/artifact-config.js +1 -1
- package/dist/evidence/artifact-validation.d.ts +1 -2
- package/dist/evidence/artifact-validation.js +1 -1
- package/dist/evidence/artifacts.js +2 -2
- package/dist/evidence/bundles.js +1 -1
- package/dist/evidence/environment-record.d.ts +22 -0
- package/dist/evidence/environment-record.js +1 -0
- package/dist/evidence/index.d.ts +1 -1
- package/dist/evidence/logs.js +2 -2
- package/dist/evidence/operation-timings.js +1 -1
- package/dist/evidence/read-fields.d.ts +0 -1
- package/dist/evidence/read-fields.js +1 -1
- package/dist/evidence/read-run.js +1 -1
- package/dist/evidence/reading.js +1 -1
- package/dist/evidence/reporting-types.d.ts +19 -0
- package/dist/evidence/reporting.d.ts +2 -2
- package/dist/evidence/reporting.js +1 -1
- package/dist/evidence/storage.d.ts +0 -2
- package/dist/evidence/storage.js +2 -2
- package/dist/evidence/timing.js +1 -1
- package/dist/kernel/error-serialization.d.ts +2 -0
- package/dist/kernel/error-serialization.js +1 -1
- package/dist/kernel/unknown-values.d.ts +5 -0
- package/dist/kernel/unknown-values.js +1 -1
- package/dist/process/environment.js +1 -1
- package/dist/process/execa-bundle.json +3 -3
- package/dist/process/files/json-file.d.ts +0 -1
- package/dist/process/files/json-file.js +1 -1
- package/dist/process/files/paths.d.ts +2 -0
- package/dist/process/files/paths.js +1 -1
- package/dist/process/host/metadata.js +1 -1
- package/dist/process/host/platform.js +1 -1
- package/dist/process/host-process.js +1 -1
- package/dist/process/lifecycle-fence.d.ts +1 -2
- package/dist/process/lifecycle-fence.js +1 -1
- package/dist/process/locks.js +2 -2
- package/dist/process/process-group.js +1 -1
- package/dist/process/process-identity.js +1 -1
- package/dist/process/process.js +30 -30
- package/dist/process/readiness.d.ts +7 -1
- package/dist/process/readiness.js +1 -1
- package/dist/process/recovery.js +1 -1
- package/dist/process/runs/manifest.js +1 -1
- package/dist/process/runs/ownership.js +1 -1
- package/dist/process/runs/product-state.js +1 -1
- package/dist/process/runs/stop.js +1 -1
- package/dist/process/stoppable-settlement.d.ts +26 -0
- package/dist/process/stoppable-settlement.js +1 -0
- package/dist/process/url-redaction.d.ts +1 -0
- package/dist/process/url-redaction.js +1 -0
- package/dist/process/windows-native.d.ts +6 -0
- package/dist/process/windows-native.js +1 -1
- package/dist/repo/ci-environment.js +1 -1
- package/dist/repo/fingerprint-policy.d.ts +0 -2
- package/dist/repo/fingerprint-policy.js +1 -1
- package/dist/repo/physical-tree.js +1 -1
- package/dist/repo/publish-directory.d.ts +20 -0
- package/dist/repo/publish-directory.js +3 -0
- package/dist/repo/state.js +4 -4
- package/dist/steps/build-config.d.ts +0 -1
- package/dist/steps/build-config.js +1 -1
- package/dist/steps/configured-steps.js +1 -1
- package/dist/steps/environment.d.ts +0 -2
- package/dist/steps/environment.js +1 -1
- package/dist/steps/index.d.ts +1 -1
- package/dist/steps/run.js +2 -2
- package/dist/toolchains/archive.js +1 -1
- package/dist/toolchains/index.js +3 -3
- package/dist/toolchains/node-provisioning.js +2 -2
- package/dist/toolchains/vitest/index.js +1 -1
- package/docs/bug-records.md +54 -52
- package/docs/engine-architecture.md +39 -11
- package/package.json +1 -1
- package/testkit-config-schema.md +43 -5
- package/dist/app/run/playwright-configuration.d.ts +0 -2
- package/dist/app/run/playwright-configuration.js +0 -1
- package/dist/environment/readiness.d.ts +0 -12
- 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
|
|
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
|
|
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)}
|
package/dist/toolchains/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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=
|
|
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}
|
|
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
|
|
2
|
-
`,{encoding:"utf8",flag:"wx",mode:384})}function
|
|
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
|
|
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}
|
package/docs/bug-records.md
CHANGED
|
@@ -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.
|
|
48
|
+
npx testkit run --file src/checkout/__testkit__/refund.int.testkit.ts
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
Test: `src/checkout/__testkit__/refund.
|
|
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": "
|
|
69
|
+
"testType": "int",
|
|
70
70
|
"test": {
|
|
71
|
-
"file": "src/checkout/__testkit__/refund.
|
|
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
|
|
134
|
-
| ------------------- | -------------------------------------------------------- |
|
|
135
|
-
| `v` | `1` | Always
|
|
136
|
-
| `cause` | Cause id | Always
|
|
137
|
-
| `impact` | Impact id | Always
|
|
138
|
-
| `confidence.cause` | `high` or `low` | Always
|
|
139
|
-
| `confidence.impact` | `high` or `low` | Always
|
|
140
|
-
| `escape` | Escape id | Always
|
|
141
|
-
| `caughtBy` | `campaign` or `manual` | Always
|
|
142
|
-
| `campaign` | Campaign id | With `caughtBy: "campaign"`
|
|
143
|
-
| `testType` | `ui`, `e2e`, `int`, `contract`, `data`, `dal`, or `load` |
|
|
144
|
-
| `test.file` | Path | Always
|
|
145
|
-
| `test.title` | String | When the issue covers one test
|
|
146
|
-
| `foundAt` | Full commit id | Always
|
|
147
|
-
| `fault.path` | Path | Always
|
|
148
|
-
| `fault.lines` | `[start, end]` | Always
|
|
149
|
-
| `area.id` | Area id | When the area map claims `fault.path`
|
|
150
|
-
| `area.name` | String | With `area.id`
|
|
151
|
-
| `area.share` | Number from 0 to 1 | With `area.id`
|
|
152
|
-
| `introducedBy` | Full commit id | When the blame resolves
|
|
153
|
-
| `introducedMethod` | `blame` | With `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
|
|
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
|
|
189
|
-
| `availability-or-performance` | The
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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
package/testkit-config-schema.md
CHANGED
|
@@ -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({
|
|
373
|
-
`app.next({
|
|
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
|
|