@gfdelarue/testkit 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +297 -3
  2. package/dist/app/bugs/batch.js +1 -1
  3. package/dist/app/bugs/bisect-dependencies.js +1 -0
  4. package/dist/app/bugs/bisect-identity.js +1 -0
  5. package/dist/app/bugs/bisect-report.js +1 -0
  6. package/dist/app/bugs/bisect-search.js +1 -0
  7. package/dist/app/bugs/bisect.js +2 -0
  8. package/dist/app/bugs/draft.js +3 -3
  9. package/dist/app/bugs/facts.js +2 -2
  10. package/dist/app/bugs/file.js +1 -1
  11. package/dist/app/bugs/issue.js +4 -4
  12. package/dist/app/bugs/record.js +2 -2
  13. package/dist/app/campaigns/brief.js +5 -5
  14. package/dist/app/prove/changes.js +3 -0
  15. package/dist/app/prove/index.js +2 -0
  16. package/dist/app/prove/mutants.js +3 -0
  17. package/dist/app/prove/report.js +3 -0
  18. package/dist/app/prove/verdict.js +1 -0
  19. package/dist/app/run/engine-orchestration.js +1 -1
  20. package/dist/app/run/orchestrator.js +1 -1
  21. package/dist/app/run/request.js +1 -1
  22. package/dist/app/run/time-zone.js +1 -0
  23. package/dist/app/sandbox/index.js +1 -0
  24. package/dist/app/sandbox/run.js +1 -0
  25. package/dist/app/shake/conditions.js +1 -0
  26. package/dist/app/shake/index.js +4 -0
  27. package/dist/app/shake/report.js +6 -0
  28. package/dist/app/shake/verdicts.js +1 -0
  29. package/dist/cli/command-catalog.js +1 -1
  30. package/dist/cli/command-flags.js +1 -1
  31. package/dist/cli/command-registry.js +1 -1
  32. package/dist/cli/commands/bug/bisect.js +1 -0
  33. package/dist/cli/commands/bug/file.js +3 -3
  34. package/dist/cli/commands/bug/record.js +2 -2
  35. package/dist/cli/commands/prove.js +3 -0
  36. package/dist/cli/commands/run.js +2 -2
  37. package/dist/cli/commands/shake.js +1 -0
  38. package/dist/domain/file-timeout.js +1 -1
  39. package/dist/engines/contracts/fixture-manifest.js +1 -1
  40. package/dist/engines/node-vitest/adapter/adapter.js +1 -1
  41. package/dist/engines/node-vitest/adapter/driver.js +2 -2
  42. package/dist/engines/node-vitest/adapter/reporter.js +1 -1
  43. package/dist/engines/node-vitest/selection.js +1 -1
  44. package/dist/engines/playwright/adapter/config.js +20 -5
  45. package/dist/process/environment.js +1 -1
  46. package/dist/process/execa-bundle.json +1 -1
  47. package/dist/repo/history.js +5 -5
  48. package/dist/types/workspace.d.ts +2 -0
  49. package/docs/bug-records.md +32 -23
  50. package/docs/release-notes.md +55 -0
  51. package/package.json +17 -2
@@ -1 +1 @@
1
- import s from"node:fs";import{createRequire as v}from"node:module";import c from"node:path";import{isUnknownRecord as u}from"../../kernel/unknown-values.js";import{isPathWithin as S}from"../../process/files/paths.js";import{NODE_VITEST_ENGINE_ENVIRONMENT_KEYS as N}from"./runtime/runtime-input.js";const O=["auto","managed","repo"];export const NODE_VITEST_INVENTORY_MARKER="TESTKIT_VITEST_INVENTORY=";const h=new Set(["BASE_URL","DATABASE_URL","NODE_OPTIONS",...N].map(e=>e.toUpperCase()));export function resolveVitestSelection(e,t,n=process.env){const{"vitest-config":r,"vitest-project":o,"vitest-runner":a}=e;if(a==="managed"&&(r??o)!==void 0)throw new Error("--vitest-config and --vitest-project select the repository's Vitest, not --vitest-runner managed");return{config:r===void 0?null:g(t,r),host:{...Object.fromEntries(Object.entries(n).filter(i=>i[1]!==void 0&&!h.has(i[0].toUpperCase()))),...n.NODE_OPTIONS===void 0?{}:{NODE_OPTIONS:n.NODE_OPTIONS}},project:o??null,runner:a??((r??o)===void 0?"auto":"repo")}}export function decodeVitestSelection(e){if(!u(e)||!O.includes(e.runner)||![e.config,e.project].every(t=>t===null||typeof t=="string")||!u(e.host)||!Object.values(e.host).every(t=>typeof t=="string"))throw new Error("Node/Vitest selection must name a runner, config, project and host environment");return e}function g(e,t){let n;try{n=s.realpathSync(c.resolve(e,t))}catch(r){throw new Error(`--vitest-config ${t} does not exist in ${e}`,{cause:r})}if(!S(s.realpathSync(e),n))throw new Error(`--vitest-config ${t} resolves outside ${e}`);if(!s.statSync(n).isFile())throw new Error(`--vitest-config ${t} is not a file`);return n}export function resolveRepositoryVitest(e,t=!1){const n=c.join(e,"package.json"),r=v(n);let o;try{o=r.resolve("vitest/package.json")}catch(f){const d=!r.resolve.paths("vitest")?.some(m=>s.lstatSync(c.join(m,"vitest"),{throwIfNoEntry:!1}));if(d&&t&&!w(n))return null;const E=d?"is not installed":"is installed but its package metadata is broken";throw new Error(`Vitest ${E} in ${e}; install vitest 4.x, or pass --vitest-runner managed`,{cause:f})}const a=c.dirname(o),i=JSON.parse(s.readFileSync(o,"utf8")),p=`Vitest ${i.version} at ${a}`;if(!/^4\.\d+\.\d+/u.test(i.version))throw new Error(`${p} is not supported; install vitest 4.x, or pass --vitest-runner managed`);const l=i.exports?.["."]?.import?.default;try{if(typeof l!="string")throw new Error("its package exports name no ESM entry point");return{entrypoint:r.resolve("vitest/node"),index:c.join(a,l),name:"vitest",root:a,version:i.version}}catch(f){throw new Error(`${p} is broken; reinstall it`,{cause:f})}}function w(e){if(!s.existsSync(e))return!1;const t=JSON.parse(s.readFileSync(e,"utf8"));return["dependencies","devDependencies","optionalDependencies"].some(n=>Object.hasOwn(t[n]??{},"vitest"))}
1
+ import i from"node:fs";import{createRequire as h}from"node:module";import f from"node:path";import{isUnknownRecord as d}from"../../kernel/unknown-values.js";import{isPathWithin as S}from"../../process/files/paths.js";import{NODE_VITEST_ENGINE_ENVIRONMENT_KEYS as v}from"./runtime/runtime-input.js";const N=["auto","managed","repo"];export const NODE_VITEST_INVENTORY_MARKER="TESTKIT_VITEST_INVENTORY=";const g=new Set(["BASE_URL","DATABASE_URL","NODE_OPTIONS",...v].map(e=>e.toUpperCase()));export function resolveVitestSelection(e,t,n=process.env){const{"vitest-config":r,"vitest-project":s,"vitest-runner":a,"vitest-shuffle":o}=e;if(o!==void 0&&!/^\d{1,9}$/u.test(o))throw new Error(`--vitest-shuffle takes a seed of up to 9 digits, not ${JSON.stringify(o)}`);if(a==="managed"&&(r??s)!==void 0)throw new Error("--vitest-config and --vitest-project select the repository's Vitest, not --vitest-runner managed");return{config:r===void 0?null:O(t,r),host:{...Object.fromEntries(Object.entries(n).filter(c=>c[1]!==void 0&&!g.has(c[0].toUpperCase()))),...n.NODE_OPTIONS===void 0?{}:{NODE_OPTIONS:n.NODE_OPTIONS}},project:s??null,runner:a??((r??s)===void 0?"auto":"repo"),shuffle:o===void 0?null:Number(o)}}export function decodeVitestSelection(e){if(!d(e)||!N.includes(e.runner)||![e.config,e.project].every(t=>t===null||typeof t=="string")||!(e.shuffle===null||Number.isInteger(e.shuffle))||!d(e.host)||!Object.values(e.host).every(t=>typeof t=="string"))throw new Error("Node/Vitest selection must name a runner, config, project and host environment");return e}function O(e,t){let n;try{n=i.realpathSync(f.resolve(e,t))}catch(r){throw new Error(`--vitest-config ${t} does not exist in ${e}`,{cause:r})}if(!S(i.realpathSync(e),n))throw new Error(`--vitest-config ${t} resolves outside ${e}`);if(!i.statSync(n).isFile())throw new Error(`--vitest-config ${t} is not a file`);return n}export function resolveRepositoryVitest(e,t=!1){const n=f.join(e,"package.json"),r=h(n);let s;try{s=r.resolve("vitest/package.json")}catch(l){const u=!r.resolve.paths("vitest")?.some(E=>i.lstatSync(f.join(E,"vitest"),{throwIfNoEntry:!1}));if(u&&t&&!w(n))return null;const m=u?"is not installed":"is installed but its package metadata is broken";throw new Error(`Vitest ${m} in ${e}; install vitest 4.x, or pass --vitest-runner managed`,{cause:l})}const a=f.dirname(s),o=JSON.parse(i.readFileSync(s,"utf8")),c=`Vitest ${o.version} at ${a}`;if(!/^4\.\d+\.\d+/u.test(o.version))throw new Error(`${c} is not supported; install vitest 4.x, or pass --vitest-runner managed`);const p=o.exports?.["."]?.import?.default;try{if(typeof p!="string")throw new Error("its package exports name no ESM entry point");return{entrypoint:r.resolve("vitest/node"),index:f.join(a,p),name:"vitest",root:a,version:o.version}}catch(l){throw new Error(`${c} is broken; reinstall it`,{cause:l})}}function w(e){if(!i.existsSync(e))return!1;const t=JSON.parse(i.readFileSync(e,"utf8"));return["dependencies","devDependencies","optionalDependencies"].some(n=>Object.hasOwn(t[n]??{},"vitest"))}
@@ -1,8 +1,8 @@
1
- import{ownedRuntimeInputModuleUrl as b}from"../../runtime-input-file.js";import P from"node:crypto";import s from"node:fs/promises";import o from"node:path";import{fileURLToPath as j,pathToFileURL as f}from"node:url";import{createManagedNodeModuleResolution as v}from"../../managed-module-resolution.js";import{materializePlaywrightRuntimeInput as T,playwrightRuntimeInputBootstrapSource as k}from"../runtime/runtime-input.js";import{nativeReporterFile as x}from"../../adapters/native-report.js";import{requireConsumerFile as O}from"../../adapters/workspace.js";export class PlaywrightConsumerConfigurationError extends Error{constructor(r){const t=r instanceof Error?r:new Error(String(r));super(t.message,{cause:t}),this.name="PlaywrightConsumerConfigurationError"}}export async function writePlaywrightTaskWorkspace(e){let r;try{F(e.inputs.supportModules),r=await D(e.rootDirectory,e.inputs)}catch(w){throw new PlaywrightConsumerConfigurationError(w)}await s.mkdir(e.taskDirectory,{recursive:!0}),await s.mkdir(e.artifactDirectory,{recursive:!0});const t=P.randomUUID(),n=o.join(e.taskDirectory,`playwright-${t}.config.mjs`),a=o.join(e.taskDirectory,`playwright-${t}.input-bootstrap.mjs`),l=o.join(e.taskDirectory,`playwright-${t}.resolution-bootstrap.cjs`),d=o.join(e.taskDirectory,"testkit-playwright-report.json"),g=o.join(e.taskDirectory,"testkit-playwright-reporters-finished"),p=o.join(e.artifactDirectory,"playwright-output");await s.mkdir(p);const c=o.join(e.artifactDirectory,"playwright-attachments");await s.mkdir(c,{mode:448,recursive:!1});const u=await T({baseUrl:e.baseUrl,databases:e.databases,directory:e.taskDirectory,endpoints:e.endpoints,handles:e.handles,inputs:e.inputs,invocationId:e.invocationId,orchestration:e.orchestration,taskId:e.taskId}),y=x("playwright");return await h(l,playwrightResolutionBootstrapSource(e.managedModuleEntrypoints,{consumerRoots:[e.rootDirectory],trustedImporterFiles:[n,a],trustedInternalEntrypoints:[e.moduleFile,y,j(b)]})),await h(a,k({digest:u.digest,file:u.file,sizeBytes:u.sizeBytes,invocationId:e.invocationId,taskId:e.taskId})),await h(n,S({baseConfigFile:r,baseUrl:e.baseUrl,browserFile:e.browserFile,inputBootstrapFile:a,outputDirectory:p,reporterFile:y,report:{attachmentDirectory:c,identity:{engineId:"playwright",invocationId:e.invocationId,taskId:e.taskId,sourceFile:e.sourceFile},reportFile:d,reportersFinishedFile:g,workspaceDirectory:e.taskDirectory},rootDirectory:e.rootDirectory,sourceFile:e.sourceFile,testkitPackageDirectory:await s.realpath(o.dirname(e.managedModuleEntrypoints.rootPackageJson))})),{attachmentDirectory:c,configFile:n,inputBootstrapFile:a,outputDirectory:p,reportFile:d,reportersFinishedFile:g,resolutionBootstrapFile:l}}export function playwrightResolutionBootstrapSource(e,r){const t={authoring:i(e.authoring,"authoring"),cli:i(e.cli,"cli"),environment:i(e.environment,"environment"),managedTest:i(e.managedTest,"managedTest"),playwright:i(e.playwright,"playwright"),playwrightCore:i(e.playwrightCore,"playwrightCore"),playwrightTest:i(e.playwrightTest,"playwrightTest"),root:i(e.root,"root"),rootPackageJson:i(e.rootPackageJson,"rootPackageJson")},n={"@playwright/test":t.playwrightTest,playwright:t.playwright,"playwright-core":t.playwrightCore};return v({consumerRoots:r.consumerRoots,direct:{"@gfdelarue/testkit":t.root,"@gfdelarue/testkit/environment":t.environment,"@gfdelarue/testkit/package.json":t.rootPackageJson,"@gfdelarue/testkit/playwright":t.authoring,"@playwright/test":t.managedTest,"playwright/test":t.managedTest},directBridgeRoots:[o.dirname(t.root)],engineLabel:"Playwright",forbiddenConsumerPackagePrefixes:[],packageAnchors:n,reservedPackageNames:["@gfdelarue/testkit"],stateSymbol:"@gfdelarue/testkit/playwright-resolution-hooks/v1",trustedImporterFiles:r.trustedImporterFiles,trustedInternalEntrypoints:r.trustedInternalEntrypoints,verifiedParentlessPackageFiles:{playwright:["lib/loader/loaderProcessEntry.js","lib/worker/workerProcessEntry.js"]},verifiedProcessEntrypoints:[t.cli]}).bootstrapSource}function i(e,r){if(!o.isAbsolute(e))throw new Error(`Resolved Playwright installation ${r} entrypoint must be absolute`);return e}function S(e){return`
1
+ import{ownedRuntimeInputModuleUrl as b}from"../../runtime-input-file.js";import P from"node:crypto";import s from"node:fs/promises";import o from"node:path";import{fileURLToPath as j,pathToFileURL as y}from"node:url";import{createManagedNodeModuleResolution as v}from"../../managed-module-resolution.js";import{materializePlaywrightRuntimeInput as T,playwrightRuntimeInputBootstrapSource as O}from"../runtime/runtime-input.js";import{nativeReporterFile as k}from"../../adapters/native-report.js";import{requireConsumerFile as S}from"../../adapters/workspace.js";export class PlaywrightConsumerConfigurationError extends Error{constructor(r){const t=r instanceof Error?r:new Error(String(r));super(t.message,{cause:t}),this.name="PlaywrightConsumerConfigurationError"}}export async function writePlaywrightTaskWorkspace(e){let r;try{D(e.inputs.supportModules),r=await I(e.rootDirectory,e.inputs)}catch(w){throw new PlaywrightConsumerConfigurationError(w)}await s.mkdir(e.taskDirectory,{recursive:!0}),await s.mkdir(e.artifactDirectory,{recursive:!0});const t=P.randomUUID(),n=o.join(e.taskDirectory,`playwright-${t}.config.mjs`),a=o.join(e.taskDirectory,`playwright-${t}.input-bootstrap.mjs`),l=o.join(e.taskDirectory,`playwright-${t}.resolution-bootstrap.cjs`),d=o.join(e.taskDirectory,"testkit-playwright-report.json"),g=o.join(e.taskDirectory,"testkit-playwright-reporters-finished"),p=o.join(e.artifactDirectory,"playwright-output");await s.mkdir(p);const c=o.join(e.artifactDirectory,"playwright-attachments");await s.mkdir(c,{mode:448,recursive:!1});const u=await T({baseUrl:e.baseUrl,databases:e.databases,directory:e.taskDirectory,endpoints:e.endpoints,handles:e.handles,inputs:e.inputs,invocationId:e.invocationId,orchestration:e.orchestration,taskId:e.taskId}),f=k("playwright");return await h(l,playwrightResolutionBootstrapSource(e.managedModuleEntrypoints,{consumerRoots:[e.rootDirectory],trustedImporterFiles:[n,a],trustedInternalEntrypoints:[e.moduleFile,f,j(b)]})),await h(a,O({digest:u.digest,file:u.file,sizeBytes:u.sizeBytes,invocationId:e.invocationId,taskId:e.taskId})),await h(n,x({baseConfigFile:r,baseUrl:e.baseUrl,browserFile:e.browserFile,inputBootstrapFile:a,outputDirectory:p,reporterFile:f,report:{attachmentDirectory:c,identity:{engineId:"playwright",invocationId:e.invocationId,taskId:e.taskId,sourceFile:e.sourceFile},reportFile:d,reportersFinishedFile:g,workspaceDirectory:e.taskDirectory},retries:typeof e.inputs.configuration?.retries=="number"?e.inputs.configuration.retries:null,rootDirectory:e.rootDirectory,sourceFile:e.sourceFile,testkitPackageDirectory:await s.realpath(o.dirname(e.managedModuleEntrypoints.rootPackageJson))})),{attachmentDirectory:c,configFile:n,inputBootstrapFile:a,outputDirectory:p,reportFile:d,reportersFinishedFile:g,resolutionBootstrapFile:l}}export function playwrightResolutionBootstrapSource(e,r){const t={authoring:i(e.authoring,"authoring"),cli:i(e.cli,"cli"),environment:i(e.environment,"environment"),managedTest:i(e.managedTest,"managedTest"),playwright:i(e.playwright,"playwright"),playwrightCore:i(e.playwrightCore,"playwrightCore"),playwrightTest:i(e.playwrightTest,"playwrightTest"),root:i(e.root,"root"),rootPackageJson:i(e.rootPackageJson,"rootPackageJson")},n={"@playwright/test":t.playwrightTest,playwright:t.playwright,"playwright-core":t.playwrightCore};return v({consumerRoots:r.consumerRoots,direct:{"@gfdelarue/testkit":t.root,"@gfdelarue/testkit/environment":t.environment,"@gfdelarue/testkit/package.json":t.rootPackageJson,"@gfdelarue/testkit/playwright":t.authoring,"@playwright/test":t.managedTest,"playwright/test":t.managedTest},directBridgeRoots:[o.dirname(t.root)],engineLabel:"Playwright",forbiddenConsumerPackagePrefixes:[],packageAnchors:n,reservedPackageNames:["@gfdelarue/testkit"],stateSymbol:"@gfdelarue/testkit/playwright-resolution-hooks/v1",trustedImporterFiles:r.trustedImporterFiles,trustedInternalEntrypoints:r.trustedInternalEntrypoints,verifiedParentlessPackageFiles:{playwright:["lib/loader/loaderProcessEntry.js","lib/worker/workerProcessEntry.js"]},verifiedProcessEntrypoints:[t.cli]}).bootstrapSource}function i(e,r){if(!o.isAbsolute(e))throw new Error(`Resolved Playwright installation ${r} entrypoint must be absolute`);return e}function x(e){return`
2
2
  import path from "node:path";
3
3
  import { createRequire } from "node:module";
4
4
 
5
- const OPTIONS = ${JSON.stringify({baseConfigUrl:e.baseConfigFile===null?null:f(e.baseConfigFile).href,baseUrl:e.baseUrl,browserFile:e.browserFile,exactTaskMatchSource:playwrightExactFileFilter(e.sourceFile),originalConfigDirectory:e.baseConfigFile===null?e.rootDirectory:o.dirname(e.baseConfigFile),originalConfigFile:e.baseConfigFile??o.join(e.rootDirectory,"__testkit_playwright_config__.mjs"),inputBootstrapUrl:f(e.inputBootstrapFile).href,outputDirectory:e.outputDirectory,report:e.report,reporterFile:e.reporterFile,rootDirectory:e.rootDirectory,sourceFile:e.sourceFile,testkitPackageMatchSource:`^${m(e.testkitPackageDirectory)}[\\\\/]`})};
5
+ const OPTIONS = ${JSON.stringify({baseConfigUrl:e.baseConfigFile===null?null:y(e.baseConfigFile).href,baseUrl:e.baseUrl,browserFile:e.browserFile,exactTaskMatchSource:playwrightExactFileFilter(e.sourceFile),originalConfigDirectory:e.baseConfigFile===null?e.rootDirectory:o.dirname(e.baseConfigFile),originalConfigFile:e.baseConfigFile??o.join(e.rootDirectory,"__testkit_playwright_config__.mjs"),inputBootstrapUrl:y(e.inputBootstrapFile).href,outputDirectory:e.outputDirectory,report:e.report,reporterFile:e.reporterFile,retries:e.retries,rootDirectory:e.rootDirectory,sourceFile:e.sourceFile,testkitPackageMatchSource:`^${m(e.testkitPackageDirectory)}[\\\\/]`})};
6
6
  const inputBootstrap = await import(OPTIONS.inputBootstrapUrl);
7
7
  if (typeof inputBootstrap.installPlaywrightRuntimeInput !== "function")
8
8
  throw new Error("Managed Playwright runtime-input bootstrap is invalid");
@@ -55,7 +55,7 @@ function normalizeExpect(expect) {
55
55
  };
56
56
  }
57
57
 
58
- function normalizeUse(use, label, inheritedSandbox) {
58
+ function normalizeUse(use, label, inheritedSandbox, inheritedTimeZone) {
59
59
  const value = use && typeof use === "object" && !Array.isArray(use) ? use : {};
60
60
  const launchOptions = value.launchOptions && typeof value.launchOptions === "object" ? value.launchOptions : {};
61
61
  if (value.browserName !== undefined && value.browserName !== "chromium")
@@ -68,6 +68,9 @@ function normalizeUse(use, label, inheritedSandbox) {
68
68
  ...value,
69
69
  browserName: "chromium",
70
70
  ...(OPTIONS.baseUrl === null ? {} : { baseURL: OPTIONS.baseUrl }),
71
+ ...(value.timezoneId === undefined && inheritedTimeZone === undefined && knownTimeZone(process.env.TZ)
72
+ ? { timezoneId: process.env.TZ }
73
+ : {}),
71
74
  launchOptions: {
72
75
  ...launchOptions,
73
76
  chromiumSandbox: launchOptions.chromiumSandbox ?? inheritedSandbox ?? true,
@@ -76,6 +79,16 @@ function normalizeUse(use, label, inheritedSandbox) {
76
79
  };
77
80
  }
78
81
 
82
+ function knownTimeZone(zone) {
83
+ if (!zone) return false;
84
+ try {
85
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
79
92
  function normalizeSetups(value) {
80
93
  if (value === undefined) return [];
81
94
  return (Array.isArray(value) ? value : [value]).map(absolutePath);
@@ -137,8 +150,9 @@ function normalizeProject(project, index, supportNames) {
137
150
  snapshotPathTemplate: absoluteTemplate(project.snapshotPathTemplate),
138
151
  }),
139
152
  expect: normalizeExpect(project.expect),
153
+ ...(OPTIONS.retries === null ? {} : { retries: OPTIONS.retries }),
140
154
  ...(isSupportProject ? {} : { testMatch: exactTaskMatch }),
141
- use: normalizeUse(project.use, "Playwright config.projects[" + index + "].use", baseSandbox),
155
+ use: normalizeUse(project.use, "Playwright config.projects[" + index + "].use", baseSandbox, base.use?.timezoneId),
142
156
  outputDir: OPTIONS.outputDirectory,
143
157
  };
144
158
  }
@@ -154,6 +168,7 @@ const supportNames = projects === null ? new Set() : supportProjectNames(project
154
168
 
155
169
  export default {
156
170
  ...base,
171
+ ...(OPTIONS.retries === null ? {} : { retries: OPTIONS.retries }),
157
172
  testDir: base.testDir === undefined ? OPTIONS.rootDirectory : absolutePath(base.testDir),
158
173
  ...(projects === null ? { testMatch: exactTaskMatch } : {}),
159
174
  ...(base.snapshotDir === undefined ? {} : { snapshotDir: absolutePath(base.snapshotDir) }),
@@ -182,4 +197,4 @@ export default {
182
197
  fullyParallel: false,
183
198
  workers: 1,
184
199
  };
185
- `}export function playwrightExactFileFilter(e){return`^${m(e)}$`}function m(e){return e.split(/[\\/]+/u).map(r=>r.replace(/[.*+?^${}()|[\]\\]/gu,"\\$&")).join("[\\\\/]")}export function playwrightFileSuiteGrep(e){const r=e.split(/[\\/]+/u).filter(a=>a!=="").map(a=>a.replace(/[.*+?^${}()|[\]\\]/gu,"\\$&")),t=r.pop()??"";return`(?:^| )(?:\\.\\.[\\\\/])*${r.reduce((a,l)=>`(?:${a}${l}[\\\\/])?`,"")}${t}(?: |$)`}async function D(e,r){if(!Object.hasOwn(r.supportModules,"playwrightConfig"))return null;const t=r.supportModules.playwrightConfig;if(!o.isAbsolute(t))throw new Error("supportModules.playwrightConfig must be an absolute path");return O(e,t,"supportModules.playwrightConfig")}function F(e){const r=Object.keys(e).filter(t=>t!=="playwrightConfig");if(r.length>0)throw new Error(`Playwright engine does not support support module(s): ${r.join(", ")}`)}async function h(e,r){const t=await s.open(e,"wx",384);try{await t.writeFile(r,"utf8")}finally{await t.close()}}
200
+ `}export function playwrightExactFileFilter(e){return`^${m(e)}$`}function m(e){return e.split(/[\\/]+/u).map(r=>r.replace(/[.*+?^${}()|[\]\\]/gu,"\\$&")).join("[\\\\/]")}export function playwrightFileSuiteGrep(e){const r=e.split(/[\\/]+/u).filter(a=>a!=="").map(a=>a.replace(/[.*+?^${}()|[\]\\]/gu,"\\$&")),t=r.pop()??"";return`(?:^| )(?:\\.\\.[\\\\/])*${r.reduce((a,l)=>`(?:${a}${l}[\\\\/])?`,"")}${t}(?: |$)`}async function I(e,r){if(!Object.hasOwn(r.supportModules,"playwrightConfig"))return null;const t=r.supportModules.playwrightConfig;if(!o.isAbsolute(t))throw new Error("supportModules.playwrightConfig must be an absolute path");return S(e,t,"supportModules.playwrightConfig")}function D(e){const r=Object.keys(e).filter(t=>t!=="playwrightConfig");if(r.length>0)throw new Error(`Playwright engine does not support support module(s): ${r.join(", ")}`)}async function h(e,r){const t=await s.open(e,"wx",384);try{await t.writeFile(r,"utf8")}finally{await t.close()}}
@@ -1 +1 @@
1
- import c from"node:fs/promises";import P from"node:os";import i from"node:path";import{toError as w}from"../kernel/error-serialization.js";import{isUnknownRecord as N}from"../kernel/unknown-values.js";import{captureProcessCreationIdentity as T,observeProcessCreationIdentity as O}from"./process-identity.js";const y="testkit-engine-",v=".testkit-owner.json",E=new Set;export async function createInvocationProcessEnvironment(t,r=process.env,e=process.platform){return h(t,i.join(t,"home"),i.join(t,"tmp"),!0,r,e)}export async function createNativeInvocationProcessEnvironment(t,r={}){const e=r.platform??process.platform,s=r.ambientEnvironment??process.env,n=await g(r.nativeTemporaryRootDirectory??I(s,e)),u=await R(e);E.has(n)||(E.add(n),await b(n,e,u));let o=null;try{const a=await c.mkdtemp(i.join(n,y));return o=a,e!=="win32"&&await c.chmod(a,448),o=await j(a,n,e),await c.writeFile(i.join(o,v),JSON.stringify({pid:process.pid,pidNamespace:u,processIdentity:T(process.pid)}),{flag:"wx",mode:384}),await h(t,i.join(o,"home"),o,!1,s,e)}catch(a){if(o===null)throw a;try{await c.rm(o,{force:!0,recursive:!0})}catch(l){throw new AggregateError([w(a),w(l)],`Could not initialize or remove native invocation temporary directory ${o}`,{cause:l})}throw a}}async function h(t,r,e,s,n,u){if(!i.isAbsolute(t))throw new Error("Engine invocation directory must be absolute");if(!i.isAbsolute(e))throw new Error("Engine invocation temporary directory must be absolute");const o=i.join(t,"cache"),a=i.join(t,"config"),l=i.join(t,"data"),f=i.join(t,"local-app-data"),m=i.join(t,"app-data");await Promise.all([r,...s?[e]:[],o,a,l,f,m].map(A=>c.mkdir(A,{mode:448})));const p={APPDATA:m,HOME:r,LOCALAPPDATA:f,TEMP:e,TMP:e,TMPDIR:e,USERPROFILE:r,XDG_CACHE_HOME:o,XDG_CONFIG_HOME:a,XDG_DATA_HOME:l};return d(p,n,"LANG"),d(p,n,"LC_ALL"),u==="win32"&&(p.PATH="",d(p,n,"ComSpec"),d(p,n,"SystemRoot")),Object.freeze({environment:Object.freeze(p),homeDirectory:r,temporaryDirectory:e})}async function R(t){if(t!=="linux")return null;try{return await c.readlink("/proc/self/ns/pid")}catch{return null}}async function b(t,r,e){if(r==="linux"&&e===null)return;let s;try{s=await c.readdir(t)}catch{return}for(const n of s.filter(u=>u.startsWith(y))){const u=i.join(t,n);try{const o=await c.lstat(u);if(!o.isDirectory()||o.isSymbolicLink()||r!=="win32"&&(o.uid!==process.getuid?.()||(o.mode&63)!==0))continue;const a=JSON.parse(await c.readFile(i.join(u,v),"utf8"));if(!N(a)||a.pidNamespace!==e||O(a.pid,a.processIdentity)!=="different")continue;await c.rm(u,{force:!0,recursive:!0})}catch{continue}}}async function g(t){if(!i.isAbsolute(t))throw new Error("Native invocation temporary root must be absolute");const r=await c.realpath(t);if(!(await c.stat(r)).isDirectory())throw new Error("Native invocation temporary root must be a directory");return r}async function j(t,r,e){const s=await c.lstat(t);if(!s.isDirectory()||s.isSymbolicLink())throw new Error("Native invocation temporary directory must be a non-symbolic-link directory");if(e!=="win32"&&(s.mode&63)!==0)throw new Error("Native invocation temporary directory must not grant group or other access");const n=await c.realpath(t);if(i.dirname(n)!==r)throw new Error("Native invocation temporary directory escaped its operating-system root");return n}function I(t,r){if(r!=="win32")return"/tmp";const e=t.TEMP??t.TMP;return e===void 0||e.length===0?P.tmpdir():e}function d(t,r,e){const s=r[e];s!==void 0&&(t[e]=s)}
1
+ import c from"node:fs/promises";import P from"node:os";import n from"node:path";import{toError as w}from"../kernel/error-serialization.js";import{isUnknownRecord as T}from"../kernel/unknown-values.js";import{captureProcessCreationIdentity as N,observeProcessCreationIdentity as O}from"./process-identity.js";const y="testkit-engine-",v=".testkit-owner.json",E=new Set;export async function createInvocationProcessEnvironment(t,r=process.env,e=process.platform){return h(t,n.join(t,"home"),n.join(t,"tmp"),!0,r,e)}export async function createNativeInvocationProcessEnvironment(t,r={}){const e=r.platform??process.platform,s=r.ambientEnvironment??process.env,o=await j(r.nativeTemporaryRootDirectory??I(s,e)),u=await R(e);E.has(o)||(E.add(o),await g(o,e,u));let i=null;try{const a=await c.mkdtemp(n.join(o,y));return i=a,e!=="win32"&&await c.chmod(a,448),i=await b(a,o,e),await c.writeFile(n.join(i,v),JSON.stringify({pid:process.pid,pidNamespace:u,processIdentity:N(process.pid)}),{flag:"wx",mode:384}),await h(t,n.join(i,"home"),i,!1,s,e)}catch(a){if(i===null)throw a;try{await c.rm(i,{force:!0,recursive:!0})}catch(l){throw new AggregateError([w(a),w(l)],`Could not initialize or remove native invocation temporary directory ${i}`,{cause:l})}throw a}}async function h(t,r,e,s,o,u){if(!n.isAbsolute(t))throw new Error("Engine invocation directory must be absolute");if(!n.isAbsolute(e))throw new Error("Engine invocation temporary directory must be absolute");const i=n.join(t,"cache"),a=n.join(t,"config"),l=n.join(t,"data"),f=n.join(t,"local-app-data"),m=n.join(t,"app-data");await Promise.all([r,...s?[e]:[],i,a,l,f,m].map(A=>c.mkdir(A,{mode:448})));const p={APPDATA:m,HOME:r,LOCALAPPDATA:f,TEMP:e,TMP:e,TMPDIR:e,USERPROFILE:r,XDG_CACHE_HOME:i,XDG_CONFIG_HOME:a,XDG_DATA_HOME:l};return d(p,o,"LANG"),d(p,o,"LC_ALL"),d(p,o,"TZ"),u==="win32"&&(p.PATH="",d(p,o,"ComSpec"),d(p,o,"SystemRoot")),Object.freeze({environment:Object.freeze(p),homeDirectory:r,temporaryDirectory:e})}async function R(t){if(t!=="linux")return null;try{return await c.readlink("/proc/self/ns/pid")}catch{return null}}async function g(t,r,e){if(r==="linux"&&e===null)return;let s;try{s=await c.readdir(t)}catch{return}for(const o of s.filter(u=>u.startsWith(y))){const u=n.join(t,o);try{const i=await c.lstat(u);if(!i.isDirectory()||i.isSymbolicLink()||r!=="win32"&&(i.uid!==process.getuid?.()||(i.mode&63)!==0))continue;const a=JSON.parse(await c.readFile(n.join(u,v),"utf8"));if(!T(a)||a.pidNamespace!==e||O(a.pid,a.processIdentity)!=="different")continue;await c.rm(u,{force:!0,recursive:!0})}catch{continue}}}async function j(t){if(!n.isAbsolute(t))throw new Error("Native invocation temporary root must be absolute");const r=await c.realpath(t);if(!(await c.stat(r)).isDirectory())throw new Error("Native invocation temporary root must be a directory");return r}async function b(t,r,e){const s=await c.lstat(t);if(!s.isDirectory()||s.isSymbolicLink())throw new Error("Native invocation temporary directory must be a non-symbolic-link directory");if(e!=="win32"&&(s.mode&63)!==0)throw new Error("Native invocation temporary directory must not grant group or other access");const o=await c.realpath(t);if(n.dirname(o)!==r)throw new Error("Native invocation temporary directory escaped its operating-system root");return o}function I(t,r){if(r!=="win32")return"/tmp";const e=t.TEMP??t.TMP;return e===void 0||e.length===0?P.tmpdir():e}function d(t,r,e){const s=r[e];s!==void 0&&(t[e]=s)}
@@ -3,7 +3,7 @@
3
3
  "entrypoint": "dist/process/process.js",
4
4
  "sourceSha256": "e7301ca010796bb92d983c039db47f36e155c26ccfb42ff2d057d47addab3121",
5
5
  "bundleSha256": "db5da25a23e4577451c0c047360de70af8abbd63be686dd7b97d65486cbba3b8",
6
- "packageLockSha256": "41f889dea57e892f03549d17c252c8cd601b637210d1c9fbb5ff095a4f34f506",
6
+ "packageLockSha256": "ba434d693cbd53c0160909d2aa6c445a3e4649130c953a1f77244465d969a05c",
7
7
  "esbuildVersion": "0.28.1",
8
8
  "options": {
9
9
  "format": "esm",
@@ -1,7 +1,7 @@
1
- import{errorMessage as M}from"../kernel/error-serialization.js";import{requireManagedHostCommandSuccess as d}from"../process/index.js";import{REPOSITORY_COMMAND_GUARD_ARGS as $,runManagedGitCommand as u}from"./managed-git.js";const A=/^([0-9a-f]{40}|[0-9a-f]{64}) \d+ \d+(?: \d+)?$/u,x=/^committer-time (\d+)$/u,C=/^([0-9a-f]{40}|[0-9a-f]{64})\trefs\/heads\/(.+)$/u,p=50;export async function findIntroducingCommit(t,n,s,e){const r=`${String(e[0])},${String(e[1])}`,i=await u(t,["blame","-w","-M","--porcelain","-L",r,n,"--",s]);if(i.exitCode!==0)throw new Error(`Git cannot blame lines ${String(e[0])} to ${String(e[1])} of ${s} at ${n}: ${H(i.stderr)}`);const f=newestBlamedCommit(parseBlame(i.stdout));if(f===null)throw new Error(`Git blamed no lines of ${s} at ${n}`);return f.boundary&&await E(t)?{status:"outside-history"}:{commit:f.commit,status:"found"}}export function parseBlame(t){const n=new Map;let s=null;for(const e of t.split(`
2
- `)){if(e.startsWith(" "))continue;const r=A.exec(e);if(r!==null){s=n.get(r[1])??{boundary:!1,committedAt:0},n.set(r[1],s);continue}if(s===null)continue;const i=x.exec(e);i!==null?s.committedAt=Number(i[1]):e==="boundary"&&(s.boundary=!0)}return[...n].map(([e,r])=>({commit:e,...r}))}export function newestBlamedCommit(t){let n=null;for(const s of t)(n===null||s.committedAt>n.committedAt)&&(n=s);return n}export async function listRemoteRefsContaining(t,n){return d("git for-each-ref",await u(t,["for-each-ref","--format=%(refname)","--contains",n,"refs/remotes"])).stdout.split(`
3
- `).map(r=>r.trim()).filter(r=>r.length>0&&!r.endsWith("/HEAD"))}export async function findRemoteBranchHolding(t,n,s=process.env){const e=await listRemoteRefsContaining(t,n);if(e.length===0)return{status:"missing"};const r=d("git remote",await u(t,["remote"])).stdout.split(`
4
- `).map(a=>a.trim()).filter(a=>a.length>0).sort((a,l)=>l.length-a.length),i=new Map;for(const a of e){const l=a.slice(13),o=r.find(m=>l.startsWith(`${m}/`));o!==void 0&&i.set(o,(i.get(o)??new Set).add(l.slice(o.length+1)))}const f={env:{...s,GIT_TERMINAL_PROMPT:"0"},shallow:await E(t)},c=[];for(const[a,l]of i){const o=await T(t,a,l,n,f);if(o.status==="found")return o;o.status==="unknown"&&c.push(o)}return c.length===0?{status:"missing"}:{problems:c.flatMap(a=>a.problems),shallow:c.some(a=>a.shallow),status:"unknown"}}async function T(t,n,s,e,r){const i=await y(t,["ls-remote","--heads",n],r.env);if(typeof i=="string")return{problems:[`Git could not list the branches on ${n}: ${i}`],shallow:!1,status:"unknown"};const f=_(i.stdout);let c=!1;for(const a of[f.filter(([l])=>s.has(l)),f.filter(([l])=>!s.has(l))]){const l=[];for(const[,o]of a){const m=await b(t,o,e);if(m==="yes")return{status:"found"};m==="absent"?l.push(o):c=!0}for(let o=0;o<l.length;o+=p){const m=l.slice(o,o+p),h=await y(t,["fetch","--quiet","--no-tags","--no-write-fetch-head",n,...m],r.env);if(typeof h=="string")return{problems:[`Git could not fetch the branch tips from ${n}: ${h}`],shallow:r.shallow&&c,status:"unknown"};for(const g of m){const w=await b(t,g,e);if(w==="yes")return{status:"found"};c||=w==="no"}}}return r.shallow&&c?{problems:[],shallow:!0,status:"unknown"}:{status:"missing"}}async function y(t,n,s){try{const e=await u(t,n,{env:s});return e.exitCode===0?e:S(e.stderr)}catch(e){return M(e)}}function _(t){return t.split(`
5
- `).flatMap(n=>{const s=C.exec(n.trim());return s===null?[]:[[s[2],s[1]]]})}async function b(t,n,s){const e=await u(t,["merge-base","--is-ancestor",s,n]);return e.exitCode===0?"yes":e.exitCode===1?"no":"absent"}export async function listUncommittedTrackedFiles(t){const n=[...$,"diff","--name-only","-z","HEAD","--"];return d("git diff",await u(t,n)).stdout.split("\0").filter(e=>e.length>0)}export async function listFilesChangedSince(t,n){if((await u(t,["merge-base","--is-ancestor",n,"HEAD"])).exitCode!==0)return null;const e=["diff","--name-only","--diff-filter=d","-z",`${n}..HEAD`,"--"];return d("git diff",await u(t,e)).stdout.split("\0").filter(i=>i.length>0)}async function E(t){return d("git rev-parse",await u(t,["rev-parse","--is-shallow-repository"])).stdout.trim()==="true"}function H(t){return t.trim().split(`
1
+ import{errorMessage as $}from"../kernel/error-serialization.js";import{requireManagedHostCommandSuccess as d}from"../process/index.js";import{REPOSITORY_COMMAND_GUARD_ARGS as p,runManagedGitCommand as l}from"./managed-git.js";const x=/^([0-9a-f]{40}|[0-9a-f]{64}) \d+ \d+(?: \d+)?$/u,A=/^committer-time (\d+)$/u,C=/^([0-9a-f]{40}|[0-9a-f]{64})\trefs\/heads\/(.+)$/u,y=50;export async function findIntroducingCommit(t,e,s,n){const r=`${String(n[0])},${String(n[1])}`,i=await l(t,["blame","-w","-M","--porcelain","-L",r,e,"--",s]);if(i.exitCode!==0)throw new Error(`Git cannot blame lines ${String(n[0])} to ${String(n[1])} of ${s} at ${e}: ${H(i.stderr)}`);const f=newestBlamedCommit(parseBlame(i.stdout));if(f===null)throw new Error(`Git blamed no lines of ${s} at ${e}`);return f.boundary&&await M(t)?{status:"outside-history"}:{commit:f.commit,status:"found"}}export function parseBlame(t){const e=new Map;let s=null;for(const n of t.split(`
2
+ `)){if(n.startsWith(" "))continue;const r=x.exec(n);if(r!==null){s=e.get(r[1])??{boundary:!1,committedAt:0},e.set(r[1],s);continue}if(s===null)continue;const i=A.exec(n);i!==null?s.committedAt=Number(i[1]):n==="boundary"&&(s.boundary=!0)}return[...e].map(([n,r])=>({commit:n,...r}))}export function newestBlamedCommit(t){let e=null;for(const s of t)(e===null||s.committedAt>e.committedAt)&&(e=s);return e}export async function listRemoteRefsContaining(t,e){return d("git for-each-ref",await l(t,["for-each-ref","--format=%(refname)","--contains",e,"refs/remotes"])).stdout.split(`
3
+ `).map(r=>r.trim()).filter(r=>r.length>0&&!r.endsWith("/HEAD"))}export async function findRemoteBranchHolding(t,e,s=process.env){const n=await listRemoteRefsContaining(t,e);if(n.length===0)return{status:"missing"};const r=d("git remote",await l(t,["remote"])).stdout.split(`
4
+ `).map(a=>a.trim()).filter(a=>a.length>0).sort((a,c)=>c.length-a.length),i=new Map;for(const a of n){const c=a.slice(13),o=r.find(m=>c.startsWith(`${m}/`));o!==void 0&&i.set(o,(i.get(o)??new Set).add(c.slice(o.length+1)))}const f={env:{...s,GIT_TERMINAL_PROMPT:"0"},shallow:await M(t)},u=[];for(const[a,c]of i){const o=await T(t,a,c,e,f);if(o.status==="found")return o;o.status==="unknown"&&u.push(o)}return u.length===0?{status:"missing"}:{problems:u.flatMap(a=>a.problems),shallow:u.some(a=>a.shallow),status:"unknown"}}async function T(t,e,s,n,r){const i=await b(t,["ls-remote","--heads",e],r.env);if(typeof i=="string")return{problems:[`Git could not list the branches on ${e}: ${i}`],shallow:!1,status:"unknown"};const f=_(i.stdout);let u=!1;for(const a of[f.filter(([c])=>s.has(c)),f.filter(([c])=>!s.has(c))]){const c=[];for(const[,o]of a){const m=await E(t,o,n);if(m==="yes")return{status:"found"};m==="absent"?c.push(o):u=!0}for(let o=0;o<c.length;o+=y){const m=c.slice(o,o+y),h=await b(t,["fetch","--quiet","--no-tags","--no-write-fetch-head",e,...m],r.env);if(typeof h=="string")return{problems:[`Git could not fetch the branch tips from ${e}: ${h}`],shallow:r.shallow&&u,status:"unknown"};for(const g of m){const w=await E(t,g,n);if(w==="yes")return{status:"found"};u||=w==="no"}}}return r.shallow&&u?{problems:[],shallow:!0,status:"unknown"}:{status:"missing"}}async function b(t,e,s){try{const n=await l(t,e,{env:s});return n.exitCode===0?n:S(n.stderr)}catch(n){return $(n)}}function _(t){return t.split(`
5
+ `).flatMap(e=>{const s=C.exec(e.trim());return s===null?[]:[[s[2],s[1]]]})}async function E(t,e,s){const n=await l(t,["merge-base","--is-ancestor",s,e]);return n.exitCode===0?"yes":n.exitCode===1?"no":"absent"}export async function isAncestor(t,e,s){const n=[...p,"merge-base","--is-ancestor",e,s];return(await l(t,n)).exitCode===0}export async function listUncommittedTrackedFiles(t){const e=[...p,"diff","--name-only","-z","HEAD","--"];return d("git diff",await l(t,e)).stdout.split("\0").filter(n=>n.length>0)}export async function listFilesChangedSince(t,e){if((await l(t,["merge-base","--is-ancestor",e,"HEAD"])).exitCode!==0)return null;const n=["diff","--name-only","--diff-filter=d","-z",`${e}..HEAD`,"--"];return d("git diff",await l(t,n)).stdout.split("\0").filter(i=>i.length>0)}async function M(t){return d("git rev-parse",await l(t,["rev-parse","--is-shallow-repository"])).stdout.trim()==="true"}function H(t){return t.trim().split(`
6
6
  `).at(-1)??""}function S(t){return t.trim().split(`
7
7
  `)[0]??""}
@@ -386,6 +386,8 @@ export type VitestSelection = Readonly<{
386
386
  host: Readonly<Record<string, string>>;
387
387
  project: null | string;
388
388
  runner: "auto" | "managed" | "repo";
389
+ shuffle: null | number;
390
+ retry?: number;
389
391
  }>;
390
392
  export interface DiscoverySummary {
391
393
  activeFiles: number;
@@ -85,10 +85,14 @@ Fault: `src/checkout/refunds.ts`, lines 41 to 58.
85
85
 
86
86
  The reproduce command runs from the repository root. When `testkit.config.ts` is in a subdirectory, the command carries `--dir <directory>` and the `--file` path is relative to that directory. The `Test:` and `Fault:` lines always give paths from the repository root.
87
87
 
88
+ When the bug was recorded with `--vitest-shuffle <seed>`, `--timezone <zone>`, or `--retries <n>`, the command carries the same flag, after any Vitest selectors, so it runs the tests in the order, the time zone, or with the retries that make them fail. The facts block does not change.
89
+
88
90
  When `testkit bug file` ran the test against [supplied values](../README.md#run-against-a-supplied-environment), the command ends with the `--base-url`, `--endpoint`, `--env-from`, and `--environment-file` flags it was given, as given. They hold no raw secret: `--env-from` names host variables, the environment file stays outside the issue, and `testkit bug file` accepts a URL only without credentials, a query, or a fragment, and an endpoint name only of letters, digits, dot, underscore, and hyphen. It refuses `--database` and `--env`, whose values the command would otherwise carry, and a relative `--environment-file` unless it runs from the repository root. It checks these values, and that every `--env-from` host variable is set, before it contacts the tracker. The command is written for a POSIX shell: flags and simple mappings appear unquoted, and any argument that needs quoting, such as a URL with brackets or a path with a space, is quoted for that shell. The facts block does not change.
89
91
 
90
92
  A fault on one line reads `line 41`.
91
93
 
94
+ When `testkit bug bisect` found where the test starts to fail, the `Fault:` line is followed by ``The test passes at `<good>` and fails from its child `<bad>`.``, or, for a range, ``The test passes at `<good>` and fails at `<bad>`. The fault came in between them.``, with full commit ids. A line ``Testkit ran it at: `<commit>` fails, `<commit>` passes, ….`` follows, with 12-character commit ids and `gave no verdict` for a skipped commit, and the facts block carries `bisect`. Testkit leaves all three out when the test, its title, or its flags changed since the bisect, or when `bisect.bad` isn't an ancestor of `foundAt`.
95
+
92
96
  Testkit redacts the secrets it recognises in the title, in every section, and in `test.title`. It does so when it records the bug and again when it files the issue. `testkit run` applies the same rules when it stores a run result. Testkit recognises these forms.
93
97
 
94
98
  - The value of a `KEY=value` assignment whose key names a secret.
@@ -142,29 +146,34 @@ A body with no start marker has no facts. A body with a start marker and no read
142
146
 
143
147
  All paths in the block use `/` and are relative to the repository root.
144
148
 
145
- | Field | Type | Written | Meaning |
146
- | ------------------- | -------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
147
- | `v` | `1` | Always | The format version. |
148
- | `cause` | Cause id | Always | The kind of mistake in the code. |
149
- | `impact` | Impact id | Always | What is at stake for the business. |
150
- | `confidence.cause` | `high` or `low` | Always | The finder's confidence in `cause`. |
151
- | `confidence.impact` | `high` or `low` | Always | The finder's confidence in `impact`. |
152
- | `escape` | Escape id | Always | Why the existing tests did not catch the bug. |
153
- | `caughtBy` | `campaign` or `manual` | Always | `campaign` when a campaign was active at record time. |
154
- | `campaign` | Campaign id | With `caughtBy: "campaign"` | The campaign that caught the bug. |
155
- | `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. |
156
- | `test.file` | Path | Always | The regression test file. |
157
- | `test.title` | String | When the issue covers one test | The failure's title. See [Known failures](#known-failures). |
158
- | `foundAt` | Full commit id | Always | The commit the test failed at. |
159
- | `fault.path` | Path | Always | The source file that holds the mistake. |
160
- | `fault.lines` | `[start, end]` | Always | The first and last faulty line at `foundAt`, counted from 1. |
161
- | `area.id` | Area id | When the area map claims `fault.path` | The area's key in `areas`. |
162
- | `area.name` | String | With `area.id` | The area's name at filing time. |
163
- | `area.share` | Number from 0 to 1 | With `area.id` | The area's share of the repository's source bytes at `foundAt`. |
164
- | `introducedBy` | Full commit id | When the blame resolves | The newest commit among the fault lines at `foundAt`. |
165
- | `introducedMethod` | `blame` | With `introducedBy` | How Testkit found `introducedBy`. |
166
-
167
- 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.
149
+ | Field | Type | Written | Meaning |
150
+ | ------------------- | -------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
151
+ | `v` | `1` | Always | The format version. |
152
+ | `cause` | Cause id | Always | The kind of mistake in the code. |
153
+ | `impact` | Impact id | Always | What is at stake for the business. |
154
+ | `confidence.cause` | `high` or `low` | Always | The finder's confidence in `cause`. |
155
+ | `confidence.impact` | `high` or `low` | Always | The finder's confidence in `impact`. |
156
+ | `escape` | Escape id | Always | Why the existing tests did not catch the bug. |
157
+ | `caughtBy` | `campaign` or `manual` | Always | `campaign` when a campaign was active at record time. |
158
+ | `campaign` | Campaign id | With `caughtBy: "campaign"` | The campaign that caught the bug. |
159
+ | `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. |
160
+ | `test.file` | Path | Always | The regression test file. |
161
+ | `test.title` | String | When the issue covers one test | The failure's title. See [Known failures](#known-failures). |
162
+ | `foundAt` | Full commit id | Always | The commit the test failed at. |
163
+ | `fault.path` | Path | Always | The source file that holds the mistake. |
164
+ | `fault.lines` | `[start, end]` | Always | The first and last faulty line at `foundAt`, counted from 1. |
165
+ | `area.id` | Area id | When the area map claims `fault.path` | The area's key in `areas`. |
166
+ | `area.name` | String | With `area.id` | The area's name at filing time. |
167
+ | `area.share` | Number from 0 to 1 | With `area.id` | The area's share of the repository's source bytes at `foundAt`. |
168
+ | `introducedBy` | Full commit id | When the blame resolves | The newest commit among the fault lines at `foundAt`. |
169
+ | `introducedMethod` | `blame` | With `introducedBy` | How Testkit found `introducedBy`. |
170
+ | `bisect.good` | Full commit id | After `testkit bug bisect` finds it | The newest commit, on the first-parent line, where the test passes. |
171
+ | `bisect.bad` | Full commit id | With `bisect.good` | The oldest commit after `bisect.good` where the test fails. |
172
+ | `bisect.exact` | `true` or `false` | With `bisect.good` | Whether `bisect.good` is the first parent of `bisect.bad`. |
173
+
174
+ `testkit bug bisect` runs the regression test at commits on the first-parent line of `foundAt`. When `bisect.exact` is `true`, `bisect.bad` is the commit that made the test fail. Otherwise Testkit skipped the commits between them or ran out of probes, and the fault came in among them. `introducedBy` still says what `git blame` found, so the two can disagree, for example when a later commit only reformatted the faulty line.
175
+
176
+ 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`, `introducedMethod`, or `bisect`, 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.
168
177
 
169
178
  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.
170
179
 
@@ -1,3 +1,58 @@
1
+ # 0.6.5
2
+
3
+ ## `testkit bug bisect` finds the commit that broke a recorded test
4
+
5
+ `testkit bug bisect <id>` runs a recorded bug's regression test at older
6
+ commits, each in a temporary copy of the checkout with the test added, and
7
+ finds the last commit where it passes and the first where it fails. It checks
8
+ the commit that `git blame` names first, so a correct blame costs two runs,
9
+ and searches the first-parent line when the blame is wrong, for example when a
10
+ later commit only reformatted the faulty line. Every commit it runs the
11
+ test at, the checked-out one included, gets its own dependencies from a
12
+ frozen install of its lockfiles, with the checkout's ignored `.env` files and
13
+ package manager settings in place. The draft keeps both commits,
14
+ and `testkit bug file` adds them to the issue and to the facts block as
15
+ `bisect.good`, `bisect.bad`, and `bisect.exact`, with the list of commits
16
+ it ran the test at. Readers that don't know `bisect` ignore it, as
17
+ `docs/bug-records.md` allows. See
18
+ [Find the commit that broke it](../README.md#find-the-commit-that-broke-it).
19
+
20
+ ## `testkit shake` finds the condition that makes a test flaky
21
+
22
+ `testkit shake --file <test>` reruns test files under conditions that make
23
+ flaky tests fail: a seeded random order, copies of each file running at the same time
24
+ on the same services and database, a time zone 14 hours ahead of UTC and one
25
+ 11 hours behind, and every CPU core busy. Each condition runs `--runs` times
26
+ beside a control. Fisher's exact test against the control gives each condition
27
+ a verdict such as `reproduces` or `raises failures`, with p corrected for the
28
+ number of tests compared and a 95% Wilson interval for each failure rate. The
29
+ report gives a command that reproduces each failure, and Testkit runs it once
30
+ more to say whether the failure came back. The run bundles of failed runs stay
31
+ under `.testkit/shake/<id>/runs/`. `--since <ref>` shakes the test files
32
+ changed since the ref, so a job can check new tests before they merge. See
33
+ [Shake out flaky tests](../README.md#shake-out-flaky-tests).
34
+
35
+ `testkit run` takes `--vitest-shuffle <seed>`, which shuffles plain Vitest
36
+ files and their tests in the order that seed gives, and `--timezone <zone>`,
37
+ which sets `TZ` for the tests, the services Testkit starts, and a `ui` test's
38
+ browser. `--retries <n>` overrides the retries that the Vitest and Playwright
39
+ configs set, including in each project. `testkit bug record` takes all three,
40
+ and `testkit bug file` and the issue's reproduce command carry them. Testkit now passes `TZ` from its own
41
+ environment to the test processes it starts, as it passes `LANG` and `LC_ALL`.
42
+
43
+ ## `testkit prove` checks that the tests notice a change
44
+
45
+ `testkit prove --since <ref>` mutates the product code changed since the ref,
46
+ one place at a time, and runs the selected tests against each mutant in a
47
+ temporary copy of the checkout. Testkit starts the services for every run, so
48
+ an HTTP test of a service or a `ui` test of the page a service sends can catch
49
+ a mutant. Every mutated place records when a test process, a service, or a
50
+ browser page runs it, so a mutant no test noticed reads as `survived` only when
51
+ its code ran, and as `not-reached` when nothing ran it, such as code a service
52
+ serves from an old build. `--mutants` runs mutants an agent wrote, and
53
+ `--fail-under` fails a job below a mutation score. See
54
+ [Prove the tests catch a change](../README.md#prove-the-tests-catch-a-change).
55
+
1
56
  # 0.6.4
2
57
 
3
58
  ## Testkit supports Node 26 LTS
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@gfdelarue/testkit",
3
- "version": "0.6.4",
4
- "description": "Test orchestration platform for product acceptance and QA workflows",
3
+ "version": "0.6.5",
4
+ "description": "Test runner and CLI for coding agents and CI. Your agent hunts bugs, and Testkit files each one in GitHub or Gitea with a failing test.",
5
+ "keywords": [
6
+ "testing",
7
+ "test-runner",
8
+ "cli",
9
+ "ai",
10
+ "coding-agents",
11
+ "bug-hunting",
12
+ "regression-tests",
13
+ "vitest",
14
+ "playwright",
15
+ "ci",
16
+ "github",
17
+ "gitea"
18
+ ],
19
+ "homepage": "https://turnstone.cloud",
5
20
  "license": "SEE LICENSE IN LICENSE",
6
21
  "type": "module",
7
22
  "repository": {