diff --git a/.changeset/green-kings-think.md b/.changeset/green-kings-think.md new file mode 100644 index 00000000..bd5a6c66 --- /dev/null +++ b/.changeset/green-kings-think.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js": major +--- + +Every failed run ends in one FailureRecord: printed on stderr, emitted as the terminal stream event, and written to reports/mutation/failure.json. StageError and PrepareError are replaced by RunFailure, exit codes come from the failure catalog (a failing baseline test exits 5), failed baseline tests are named by project-relative file and line, and the cli.run span carries stryker.failure.code. diff --git a/.changeset/moody-snails-watch.md b/.changeset/moody-snails-watch.md new file mode 100644 index 00000000..d53309ab --- /dev/null +++ b/.changeset/moody-snails-watch.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-cli-contract": minor +--- + +RunFailed now carries a typed FailureRecord (catalog code, stage, structured evidence, cause chain, next action, replay capsule, trace id) instead of error/remediation/reason prose; the stream schema is 3.0. The package publishes contract/failure-catalog.json and FailureRecord renderers for terminal, markdown, GitHub annotations and SARIF. diff --git a/.changeset/small-results-greet.md b/.changeset/small-results-greet.md new file mode 100644 index 00000000..3c8b9a1a --- /dev/null +++ b/.changeset/small-results-greet.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-plugin-interface": major +--- + +FailedTestResult carries an optional project-relative source location and the raw stack; the unused startPosition field is removed. diff --git a/.changeset/upset-chicken-hammer.md b/.changeset/upset-chicken-hammer.md new file mode 100644 index 00000000..b6c95a52 --- /dev/null +++ b/.changeset/upset-chicken-hammer.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-vitest-runner": minor +--- + +Failed tests report the first project source frame as their location and keep the raw stack; file-level failures are named and identified by their project-relative path. diff --git a/packages/stryker-js-cli-contract/contract/failure-catalog.json b/packages/stryker-js-cli-contract/contract/failure-catalog.json new file mode 100644 index 00000000..215cd0ed --- /dev/null +++ b/packages/stryker-js-cli-contract/contract/failure-catalog.json @@ -0,0 +1,288 @@ +[ + { + "id": "ArgumentsInvalid", + "name": "ArgumentsInvalid", + "meaning": "The command line named an option, argument or subcommand the CLI does not accept.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "ConfigInvalid", + "name": "ConfigInvalid", + "meaning": "The configuration could not be read or failed validation.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "PluginNotFound", + "name": "PluginNotFound", + "meaning": "The configuration names a plugin that is not installed or not registered.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "PluginLoadFailed", + "name": "PluginLoadFailed", + "meaning": "A configured plugin refused to load: a peer is missing or unsupported, or its contribution is invalid.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "PluginImportFailed", + "name": "PluginImportFailed", + "meaning": "A configured plugin module threw while it was being imported.", + "exitCode": 4, + "nextAction": { + "primary": "reportToolDefect", + "otherwise": "fixConfiguration" + }, + "capsule": "replays" + }, + { + "id": "SurvivorsUnavailable", + "name": "SurvivorsUnavailable", + "meaning": "Survivors were requested but no matching mutation report exists.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "NoInputFiles", + "name": "NoInputFiles", + "meaning": "The configured file patterns matched no input files.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "SandboxPreparationFailed", + "name": "SandboxPreparationFailed", + "meaning": "The project could not be read or copied into the sandbox.", + "exitCode": 3, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": "retryInfrastructure" + }, + "capsule": "replays" + }, + { + "id": "InstrumentationFailed", + "name": "InstrumentationFailed", + "meaning": "The instrumenter could not read or mutate a source file.", + "exitCode": 3, + "nextAction": { + "primary": "reportToolDefect", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "CheckerFailed", + "name": "CheckerFailed", + "meaning": "A checker failed or broke its protocol while checking mutants.", + "exitCode": 3, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": "reportToolDefect" + }, + "capsule": "replays" + }, + { + "id": "TestRunnerFailed", + "name": "TestRunnerFailed", + "meaning": "The test runner could not be started or failed outside of any test.", + "exitCode": 3, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": "reportToolDefect" + }, + "capsule": "replays" + }, + { + "id": "BaselineTestsFailed", + "name": "BaselineTestsFailed", + "meaning": "One or more tests failed in the initial test run, before any mutant was applied.", + "exitCode": 5, + "nextAction": { + "primary": "fixCode", + "otherwise": "fixTest" + }, + "capsule": "replays" + }, + { + "id": "BaselineTimedOut", + "name": "BaselineTimedOut", + "meaning": "The initial test run did not finish within its time limit.", + "exitCode": 5, + "nextAction": { + "primary": "fixTest", + "otherwise": "retryInfrastructure" + }, + "capsule": "replays" + }, + { + "id": "BaselineErrored", + "name": "BaselineErrored", + "meaning": "The initial test run errored outside of any test.", + "exitCode": 5, + "nextAction": { + "primary": "fixTest", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "BaselineFoundNoTests", + "name": "BaselineFoundNoTests", + "meaning": "The initial test run found no tests to run.", + "exitCode": 2, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "WorkerBootTimedOut", + "name": "WorkerBootTimedOut", + "meaning": "A worker process did not finish booting before its boot window closed.", + "exitCode": 3, + "nextAction": { + "primary": "retryInfrastructure", + "otherwise": "reportToolDefect" + }, + "capsule": "replays" + }, + { + "id": "WorkerOutOfMemory", + "name": "WorkerOutOfMemory", + "meaning": "A worker process was killed for running out of memory.", + "exitCode": 3, + "nextAction": { + "primary": "retryInfrastructure", + "otherwise": null + }, + "capsule": "outOfMemory" + }, + { + "id": "WorkerCrashed", + "name": "WorkerCrashed", + "meaning": "A worker process exited unexpectedly.", + "exitCode": 3, + "nextAction": { + "primary": "retryInfrastructure", + "otherwise": "reportToolDefect" + }, + "capsule": "workerCrashed" + }, + { + "id": "ReporterFailed", + "name": "ReporterFailed", + "meaning": "A reporter could not be started or failed while reporting.", + "exitCode": 3, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "RunInterrupted", + "name": "RunInterrupted", + "meaning": "The run was interrupted by a signal before it finished.", + "exitCode": 130, + "nextAction": { + "primary": "retryInfrastructure", + "otherwise": null + }, + "capsule": "interrupted" + }, + { + "id": "NewSurvivors", + "name": "NewSurvivors", + "meaning": "Mutants the change can affect survived every test and are absent from the accepted survivor baseline.", + "exitCode": 1, + "nextAction": { + "primary": "killSurvivor", + "otherwise": "refactorAwayEquivalent" + }, + "capsule": "replays" + }, + { + "id": "InvariantBroken", + "name": "InvariantBroken", + "meaning": "Stryker reached a state its own design rules out; this is a defect in Stryker.", + "exitCode": 4, + "nextAction": { + "primary": "reportToolDefect", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "CatalogGap", + "name": "CatalogGap", + "meaning": "A failure no catalog entry classifies; the record carries its full cause.", + "exitCode": 4, + "nextAction": { + "primary": "reportToolDefect", + "otherwise": null + }, + "capsule": "replays" + }, + { + "id": "RecordMissing", + "name": "RecordMissing", + "meaning": "A CI job ended without a failure record or a report.", + "exitCode": null, + "nextAction": { + "primary": "reportToolDefect", + "otherwise": null + }, + "capsule": "recordMissing" + }, + { + "id": "JobTimedOut", + "name": "JobTimedOut", + "meaning": "A CI job was killed for exceeding its time limit.", + "exitCode": null, + "nextAction": { + "primary": "retryInfrastructure", + "otherwise": null + }, + "capsule": "jobTimedOut" + }, + { + "id": "BinaryMissing", + "name": "BinaryMissing", + "meaning": "A CI job could not find a binary it needs.", + "exitCode": null, + "nextAction": { + "primary": "fixConfiguration", + "otherwise": null + }, + "capsule": "binaryMissing" + } +] diff --git a/packages/stryker-js-cli-contract/contract/span-taxonomy.json b/packages/stryker-js-cli-contract/contract/span-taxonomy.json index 98beda60..0044d809 100644 --- a/packages/stryker-js-cli-contract/contract/span-taxonomy.json +++ b/packages/stryker-js-cli-contract/contract/span-taxonomy.json @@ -63,7 +63,7 @@ "stryker.run.exit_code": { "type": "number" }, - "stryker.run.error": { + "stryker.failure.code": { "type": "string" } } diff --git a/packages/stryker-js-cli-contract/contract/stream.schema.json b/packages/stryker-js-cli-contract/contract/stream.schema.json index c204b747..204b07a3 100644 --- a/packages/stryker-js-cli-contract/contract/stream.schema.json +++ b/packages/stryker-js-cli-contract/contract/stream.schema.json @@ -57,7 +57,7 @@ "schemaVersion": { "type": "string", "enum": [ - "2.0" + "3.0" ] }, "runId": { @@ -724,7 +724,7 @@ "schemaVersion": { "type": "string", "enum": [ - "2.0" + "3.0" ] }, "runId": { @@ -1006,7 +1006,7 @@ "schemaVersion": { "type": "string", "enum": [ - "2.0" + "3.0" ] }, "code": { @@ -1014,117 +1014,5856 @@ "minimum": 0, "maximum": 255 }, - "error": { - "type": "string" - }, - "remediation": { - "type": "string" - }, - "reason": { + "record": { "anyOf": [ { - "anyOf": [ - { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ArgumentsInvalid" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "argument": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "argument" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ConfigInvalid" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "detail" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PluginNotFound" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "descriptor": { + "type": "string" + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "descriptor" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PluginLoadFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "descriptor": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PeerMissing" + ] + }, + "peer": { + "type": "string" + } + }, + "required": [ + "_tag", + "peer" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PeerVersionUnsupported" + ] + }, + "peer": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "peer", + "detail" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PeerUnrecognized" + ] + }, + "peer": { + "type": "string" + } + }, + "required": [ + "_tag", + "peer" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvalidContribution" + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "detail" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "descriptor", + "reason" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PluginImportFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "descriptor": { + "type": "string" + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "descriptor" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SurvivorsUnavailable" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "no-report", + "mismatch" + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "reason" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "NoInputFiles" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SandboxPreparationFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InstrumentationFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CheckerFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "checker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "checker" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TestRunnerFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "BaselineTestsFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "testCount": { + "type": "integer", + "minimum": 0 + }, + "tests": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "column": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "file", + "line", + "column" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "file", + "location", + "message", + "stack" + ], + "additionalProperties": true + } + ], + "additionalItems": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "column": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "file", + "line", + "column" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "file", + "location", + "message", + "stack" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "testCount", + "tests" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "BaselineTimedOut" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "BaselineErrored" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "BaselineFoundNoTests" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WorkerBootTimedOut" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "workerKind": { + "type": "string", + "enum": [ + "testRunner", + "checker", + "reporter" + ] + }, + "pid": { + "type": "integer" + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "workerKind", + "pid" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WorkerOutOfMemory" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "workerKind": { + "type": "string", + "enum": [ + "testRunner", + "checker", + "reporter" + ] + }, + "pid": { + "type": "integer" + }, + "exitCode": { + "type": "integer" + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "workerKind", + "pid", + "exitCode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "WorkerCrashed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "workerKind": { + "type": "string", + "enum": [ + "testRunner", + "checker", + "reporter" + ] + }, + "pid": { + "type": "integer" + }, + "exitCode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "signal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "workerKind", + "pid", + "exitCode", + "signal" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ReporterFailed" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "reporter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "reporter" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "RunInterrupted" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "NewSurvivors" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { "type": "object", "properties": { - "_tag": { + "primary": { "type": "string", "enum": [ - "PeerMissing" + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" ] }, - "peer": { - "type": "string" + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] } }, "required": [ - "_tag", - "peer" + "primary", + "otherwise" ], "additionalProperties": true }, - { + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "survivors": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "object", + "properties": { + "mutantId": { + "type": "string", + "minLength": 1 + }, + "file": { + "type": "string" + }, + "line": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "mutantId", + "file", + "line" + ], + "additionalProperties": true + } + ], + "additionalItems": { + "type": "object", + "properties": { + "mutantId": { + "type": "string", + "minLength": 1 + }, + "file": { + "type": "string" + }, + "line": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "mutantId", + "file", + "line" + ], + "additionalProperties": true + } + }, + "unchecked": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "survivors", + "unchecked" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "InvariantBroken" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { "type": "object", "properties": { - "_tag": { + "primary": { "type": "string", "enum": [ - "PeerVersionUnsupported" + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" ] }, - "peer": { - "type": "string" - }, - "detail": { - "type": "string" + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] } }, "required": [ - "_tag", - "peer", - "detail" + "primary", + "otherwise" ], "additionalProperties": true }, - { + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "CatalogGap" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { "type": "object", "properties": { - "_tag": { + "primary": { "type": "string", "enum": [ - "PeerUnrecognized" + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" ] }, - "peer": { - "type": "string" + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] } }, "required": [ - "_tag", - "peer" + "primary", + "otherwise" ], "additionalProperties": true }, - { + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "RecordMissing" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { "type": "object", "properties": { - "_tag": { + "primary": { "type": "string", "enum": [ - "InvalidContribution" + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" ] }, - "detail": { - "type": "string" + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] } }, "required": [ - "_tag", - "detail" + "primary", + "otherwise" ], "additionalProperties": true }, - { + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "exitCode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "JobTimedOut" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { "type": "object", "properties": { - "_tag": { + "primary": { "type": "string", "enum": [ - "ImportFailed" + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" ] }, - "cause": {} + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } }, "required": [ - "_tag", - "cause" + "primary", + "otherwise" ], "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "limitSeconds": { + "type": "integer", + "minimum": 0 } - ] + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "limitSeconds" + ], + "additionalProperties": true }, { - "type": "null" + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "BinaryMissing" + ] + }, + "stage": { + "type": "string", + "enum": [ + "cli", + "config", + "prepare", + "instrument", + "check", + "dryRun", + "mutationTest", + "report", + "gate", + "ci", + "run" + ] + }, + "cause": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "stack" + ], + "additionalProperties": true + } + }, + "capsule": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Replays" + ] + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true + } + } + }, + "required": [ + "_tag", + "cwd", + "argv", + "env" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DoesNotReplay" + ] + }, + "why": { + "type": "string", + "enum": [ + "outOfMemory", + "workerCrashed", + "interrupted", + "recordMissing", + "jobTimedOut", + "binaryMissing" + ] + }, + "standIn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "_tag", + "why", + "standIn" + ], + "additionalProperties": true + } + ] + }, + "nextAction": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + "otherwise": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fixTest", + "fixCode", + "killSurvivor", + "refactorAwayEquivalent", + "fixConfiguration", + "retryInfrastructure", + "reportToolDefect" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "primary", + "otherwise" + ], + "additionalProperties": true + }, + "traceId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "binary": { + "type": "string" + } + }, + "required": [ + "_tag", + "stage", + "cause", + "capsule", + "nextAction", + "traceId", + "binary" + ], + "additionalProperties": true } ] } @@ -1133,9 +6872,7 @@ "_tag", "schemaVersion", "code", - "error", - "remediation", - "reason" + "record" ], "additionalProperties": true }, @@ -1151,7 +6888,7 @@ "schemaVersion": { "type": "string", "enum": [ - "2.0" + "3.0" ] }, "code": { diff --git a/packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md b/packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md index 1d11c669..8608e358 100644 --- a/packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md +++ b/packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md @@ -4,6 +4,11 @@ ```ts +// @public (undocumented) +export namespace FailureRecord { + export { Capsule, CapsuleRule, CatalogEntry, CatalogExitCode, CauseLink, DoesNotReplay, EnvEntry, FailedTestEvidence, FailureCatalog, FailureCode, FailureEvidence, FailureRecord, FailureRecordFile, FailureStage, NextAction, NextActionKind, NonReplayReason, PluginLoadRefusal, RecordContext, Replays, SourceLocation, TraceId, WorkerKind, annotationsOf, failureCatalogEntries, markdownOf, recordOf, sarifTextOf, terminalTextOf }; +} + // @public (undocumented) export namespace OutputMode { export { ModeSignal, OutputMode }; diff --git a/packages/stryker-js-cli-contract/scripts/contract-documents.ts b/packages/stryker-js-cli-contract/scripts/contract-documents.ts index b3c2cb57..4bf0821b 100644 --- a/packages/stryker-js-cli-contract/scripts/contract-documents.ts +++ b/packages/stryker-js-cli-contract/scripts/contract-documents.ts @@ -2,6 +2,7 @@ import * as JsonSchema from 'effect/JsonSchema' import * as Result from 'effect/Result' import * as Schema from 'effect/Schema' +import { failureCatalogEntries } from '../src/failure-catalog.js' import { RunEvent } from '../src/RunEvent/mod.js' import { spanMembers } from '../src/SpanTaxonomy.js' import { SpanDocuments, type SpanMember } from '../src/SpanTaxonomy.schema.js' @@ -19,6 +20,8 @@ export const streamDocumentSource = (): string => { export const stockCatalogDocumentSource = (): string => `${JSON.stringify(StockCatalog.entries, null, 2)}\n` +export const failureCatalogDocumentSource = (): string => `${JSON.stringify(failureCatalogEntries, null, 2)}\n` + export const spanDocumentOf = (member: SpanMember) => ({ id: member.name, name: member.name, diff --git a/packages/stryker-js-cli-contract/scripts/generate-contract.ts b/packages/stryker-js-cli-contract/scripts/generate-contract.ts index c768cc99..3dfceea4 100644 --- a/packages/stryker-js-cli-contract/scripts/generate-contract.ts +++ b/packages/stryker-js-cli-contract/scripts/generate-contract.ts @@ -2,7 +2,12 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { spanTaxonomyDocumentSource, stockCatalogDocumentSource, streamDocumentSource } from './contract-documents.js' +import { + failureCatalogDocumentSource, + spanTaxonomyDocumentSource, + stockCatalogDocumentSource, + streamDocumentSource, +} from './contract-documents.js' const packageRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -15,3 +20,4 @@ const write = (relativePath: string, contents: string): void => { write('contract/stream.schema.json', streamDocumentSource()) write('contract/stock-catalog.json', stockCatalogDocumentSource()) write('contract/span-taxonomy.json', spanTaxonomyDocumentSource()) +write('contract/failure-catalog.json', failureCatalogDocumentSource()) diff --git a/packages/stryker-js-cli-contract/src/FailureRecord/mod.ts b/packages/stryker-js-cli-contract/src/FailureRecord/mod.ts new file mode 100644 index 00000000..029fbb98 --- /dev/null +++ b/packages/stryker-js-cli-contract/src/FailureRecord/mod.ts @@ -0,0 +1,23 @@ +export { FailureCatalog, failureCatalogEntries, type RecordContext, recordOf } from '../failure-catalog.js' +export { CapsuleRule, CatalogEntry, CatalogExitCode } from '../failure-catalog.schema.js' +export { + Capsule, + CauseLink, + DoesNotReplay, + EnvEntry, + FailedTestEvidence, + FailureCode, + FailureEvidence, + FailureRecord, + FailureRecordFile, + FailureStage, + NextAction, + NextActionKind, + NonReplayReason, + PluginLoadRefusal, + Replays, + SourceLocation, + TraceId, + WorkerKind, +} from '../failure-record.schema.js' +export { annotationsOf, markdownOf, sarifTextOf, terminalTextOf } from '../render-failure.js' diff --git a/packages/stryker-js-cli-contract/src/SpanTaxonomy.schema.ts b/packages/stryker-js-cli-contract/src/SpanTaxonomy.schema.ts index e0da51b7..150e4afa 100644 --- a/packages/stryker-js-cli-contract/src/SpanTaxonomy.schema.ts +++ b/packages/stryker-js-cli-contract/src/SpanTaxonomy.schema.ts @@ -27,6 +27,8 @@ export interface SpanMember { export const SpanAttributeText = S.String +export const SpanAttributeOptionalText = S.optionalKey(SpanAttributeText) + export const SpanAttributeCount = S.Finite export const SpanRunStatus = S.Literals(['success', 'failure', 'timed_out', 'aborted', 'skipped']) diff --git a/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts b/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts index 6f71ed67..6e1287fe 100644 --- a/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts +++ b/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts @@ -1,7 +1,13 @@ import { Mutant, Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as S from 'effect/Schema' -import { SpanAttributeCount, SpanAttributeText, type SpanMember, SpanRunStatus } from './SpanTaxonomy.schema.js' +import { + SpanAttributeCount, + SpanAttributeOptionalText, + SpanAttributeText, + type SpanMember, + SpanRunStatus, +} from './SpanTaxonomy.schema.js' const protocolMethods: ReadonlyArray = [ ...new Set([ @@ -31,7 +37,7 @@ export const Spans = { attributes: { 'stryker.run.outcome': SpanAttributeText, 'stryker.run.exit_code': SpanAttributeCount, - 'stryker.run.error': SpanAttributeText, + 'stryker.failure.code': SpanAttributeOptionalText, }, }, commandRunnerRun: { name: 'stryker.command_runner.run', attributes: {} }, diff --git a/packages/stryker-js-cli-contract/src/failure-catalog.schema.ts b/packages/stryker-js-cli-contract/src/failure-catalog.schema.ts new file mode 100644 index 00000000..48d229c0 --- /dev/null +++ b/packages/stryker-js-cli-contract/src/failure-catalog.schema.ts @@ -0,0 +1,19 @@ +import * as S from 'effect/Schema' + +import { FailureCode, NextAction, NonReplayReason } from './failure-record.schema.js' + +export const CapsuleRule = S.Union([S.Literal('replays'), NonReplayReason]) +export type CapsuleRule = typeof CapsuleRule.Type + +export const CatalogExitCode = S.Literals([1, 2, 3, 4, 5, 130]) +export type CatalogExitCode = typeof CatalogExitCode.Type + +export const CatalogEntry = S.Struct({ + id: FailureCode, + name: FailureCode, + meaning: S.NonEmptyString, + exitCode: S.NullOr(CatalogExitCode), + nextAction: NextAction, + capsule: CapsuleRule, +}) +export type CatalogEntry = typeof CatalogEntry.Type diff --git a/packages/stryker-js-cli-contract/src/failure-catalog.ts b/packages/stryker-js-cli-contract/src/failure-catalog.ts new file mode 100644 index 00000000..a92ebe77 --- /dev/null +++ b/packages/stryker-js-cli-contract/src/failure-catalog.ts @@ -0,0 +1,209 @@ +import { dual } from 'effect/Function' + +import type { CapsuleRule, CatalogEntry } from './failure-catalog.schema.js' +import { + type Capsule, + type CauseLink, + type EnvEntry, + FailureCode, + type FailureEvidence, + type FailureRecord, + type NextAction, + type TraceId, +} from './failure-record.schema.js' + +type CatalogFacts = Omit + +const action = (primary: NextAction['primary'], otherwise: NextAction['otherwise'] = null): NextAction => ({ + primary, + otherwise, +}) + +export const FailureCatalog = { + ArgumentsInvalid: { + meaning: 'The command line named an option, argument or subcommand the CLI does not accept.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + ConfigInvalid: { + meaning: 'The configuration could not be read or failed validation.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + PluginNotFound: { + meaning: 'The configuration names a plugin that is not installed or not registered.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + PluginLoadFailed: { + meaning: 'A configured plugin refused to load: a peer is missing or unsupported, or its contribution is invalid.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + PluginImportFailed: { + meaning: 'A configured plugin module threw while it was being imported.', + exitCode: 4, + nextAction: action('reportToolDefect', 'fixConfiguration'), + capsule: 'replays', + }, + SurvivorsUnavailable: { + meaning: 'Survivors were requested but no matching mutation report exists.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + NoInputFiles: { + meaning: 'The configured file patterns matched no input files.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + SandboxPreparationFailed: { + meaning: 'The project could not be read or copied into the sandbox.', + exitCode: 3, + nextAction: action('fixConfiguration', 'retryInfrastructure'), + capsule: 'replays', + }, + InstrumentationFailed: { + meaning: 'The instrumenter could not read or mutate a source file.', + exitCode: 3, + nextAction: action('reportToolDefect'), + capsule: 'replays', + }, + CheckerFailed: { + meaning: 'A checker failed or broke its protocol while checking mutants.', + exitCode: 3, + nextAction: action('fixConfiguration', 'reportToolDefect'), + capsule: 'replays', + }, + TestRunnerFailed: { + meaning: 'The test runner could not be started or failed outside of any test.', + exitCode: 3, + nextAction: action('fixConfiguration', 'reportToolDefect'), + capsule: 'replays', + }, + BaselineTestsFailed: { + meaning: 'One or more tests failed in the initial test run, before any mutant was applied.', + exitCode: 5, + nextAction: action('fixCode', 'fixTest'), + capsule: 'replays', + }, + BaselineTimedOut: { + meaning: 'The initial test run did not finish within its time limit.', + exitCode: 5, + nextAction: action('fixTest', 'retryInfrastructure'), + capsule: 'replays', + }, + BaselineErrored: { + meaning: 'The initial test run errored outside of any test.', + exitCode: 5, + nextAction: action('fixTest'), + capsule: 'replays', + }, + WorkerBootTimedOut: { + meaning: 'A worker process did not finish booting before its boot window closed.', + exitCode: 3, + nextAction: action('retryInfrastructure', 'reportToolDefect'), + capsule: 'replays', + }, + BaselineFoundNoTests: { + meaning: 'The initial test run found no tests to run.', + exitCode: 2, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + WorkerOutOfMemory: { + meaning: 'A worker process was killed for running out of memory.', + exitCode: 3, + nextAction: action('retryInfrastructure'), + capsule: 'outOfMemory', + }, + WorkerCrashed: { + meaning: 'A worker process exited unexpectedly.', + exitCode: 3, + nextAction: action('retryInfrastructure', 'reportToolDefect'), + capsule: 'workerCrashed', + }, + ReporterFailed: { + meaning: 'A reporter could not be started or failed while reporting.', + exitCode: 3, + nextAction: action('fixConfiguration'), + capsule: 'replays', + }, + RunInterrupted: { + meaning: 'The run was interrupted by a signal before it finished.', + exitCode: 130, + nextAction: action('retryInfrastructure'), + capsule: 'interrupted', + }, + NewSurvivors: { + meaning: 'Mutants the change can affect survived every test and are absent from the accepted survivor baseline.', + exitCode: 1, + nextAction: action('killSurvivor', 'refactorAwayEquivalent'), + capsule: 'replays', + }, + InvariantBroken: { + meaning: 'Stryker reached a state its own design rules out; this is a defect in Stryker.', + exitCode: 4, + nextAction: action('reportToolDefect'), + capsule: 'replays', + }, + CatalogGap: { + meaning: 'A failure no catalog entry classifies; the record carries its full cause.', + exitCode: 4, + nextAction: action('reportToolDefect'), + capsule: 'replays', + }, + RecordMissing: { + meaning: 'A CI job ended without a failure record or a report.', + exitCode: null, + nextAction: action('reportToolDefect'), + capsule: 'recordMissing', + }, + JobTimedOut: { + meaning: 'A CI job was killed for exceeding its time limit.', + exitCode: null, + nextAction: action('retryInfrastructure'), + capsule: 'jobTimedOut', + }, + BinaryMissing: { + meaning: 'A CI job could not find a binary it needs.', + exitCode: null, + nextAction: action('fixConfiguration'), + capsule: 'binaryMissing', + }, +} as const satisfies { readonly [Code in FailureCode]: CatalogFacts } + +export interface RecordContext { + readonly cause: ReadonlyArray + readonly cwd: string + readonly argv: readonly [string, ...Array] + readonly env: ReadonlyArray + readonly traceId: TraceId | null +} + +const capsuleOf = (rule: CapsuleRule, context: RecordContext): Capsule => + rule === 'replays' + ? { _tag: 'Replays', cwd: context.cwd, argv: context.argv, env: context.env } + : { _tag: 'DoesNotReplay', why: rule, standIn: context.argv.join(' ') } + +export const recordOf: { + (context: RecordContext): (evidence: FailureEvidence) => FailureRecord + (evidence: FailureEvidence, context: RecordContext): FailureRecord +} = dual(2, (evidence: FailureEvidence, context: RecordContext): FailureRecord => ({ + ...evidence, + cause: context.cause, + capsule: capsuleOf(FailureCatalog[evidence._tag].capsule, context), + nextAction: FailureCatalog[evidence._tag].nextAction, + traceId: context.traceId, +})) + +export const failureCatalogEntries: ReadonlyArray = FailureCode.literals.map((code) => ({ + id: code, + name: code, + ...FailureCatalog[code], +})) diff --git a/packages/stryker-js-cli-contract/src/failure-record.schema.ts b/packages/stryker-js-cli-contract/src/failure-record.schema.ts new file mode 100644 index 00000000..12997e51 --- /dev/null +++ b/packages/stryker-js-cli-contract/src/failure-record.schema.ts @@ -0,0 +1,151 @@ +import * as S from 'effect/Schema' + +export const FailureStage = S.Literals([ + 'cli', + 'config', + 'prepare', + 'instrument', + 'check', + 'dryRun', + 'mutationTest', + 'report', + 'gate', + 'ci', + 'run', +]) +export type FailureStage = typeof FailureStage.Type + +export const NextActionKind = S.Literals([ + 'fixTest', + 'fixCode', + 'killSurvivor', + 'refactorAwayEquivalent', + 'fixConfiguration', + 'retryInfrastructure', + 'reportToolDefect', +]) +export type NextActionKind = typeof NextActionKind.Type + +export const NextAction = S.Struct({ primary: NextActionKind, otherwise: S.NullOr(NextActionKind) }) +export type NextAction = typeof NextAction.Type + +export const CauseLink = S.Struct({ kind: S.String, message: S.String, stack: S.NullOr(S.String) }) +export type CauseLink = typeof CauseLink.Type + +export const EnvEntry = S.Struct({ name: S.NonEmptyString, value: S.String }) +export type EnvEntry = typeof EnvEntry.Type + +export const NonReplayReason = S.Literals([ + 'outOfMemory', + 'workerCrashed', + 'interrupted', + 'recordMissing', + 'jobTimedOut', + 'binaryMissing', +]) +export type NonReplayReason = typeof NonReplayReason.Type + +export const Replays = S.TaggedStruct('Replays', { + cwd: S.NonEmptyString, + argv: S.NonEmptyArray(S.String), + env: S.Array(EnvEntry), +}) +export type Replays = typeof Replays.Type + +export const DoesNotReplay = S.TaggedStruct('DoesNotReplay', { why: NonReplayReason, standIn: S.NonEmptyString }) +export type DoesNotReplay = typeof DoesNotReplay.Type + +export const Capsule = S.Union([Replays, DoesNotReplay]) +export type Capsule = typeof Capsule.Type + +export const TraceId = S.String.check(S.isPattern(/^[0-9a-f]{32}$/u)) +export type TraceId = typeof TraceId.Type + +const SourceLine = S.Int.check(S.isGreaterThanOrEqualTo(1)) + +export const SourceLocation = S.Struct({ file: S.NonEmptyString, line: SourceLine, column: SourceLine }) +export type SourceLocation = typeof SourceLocation.Type + +export const FailedTestEvidence = S.Struct({ + id: S.String, + name: S.String, + file: S.NullOr(S.String), + location: S.NullOr(SourceLocation), + message: S.String, + stack: S.NullOr(S.String), +}) +export type FailedTestEvidence = typeof FailedTestEvidence.Type + +export const WorkerKind = S.Literals(['testRunner', 'checker', 'reporter']) +export type WorkerKind = typeof WorkerKind.Type + +export const PluginLoadRefusal = S.Union([ + S.TaggedStruct('PeerMissing', { peer: S.String }), + S.TaggedStruct('PeerVersionUnsupported', { peer: S.String, detail: S.String }), + S.TaggedStruct('PeerUnrecognized', { peer: S.String }), + S.TaggedStruct('InvalidContribution', { detail: S.String }), +]) +export type PluginLoadRefusal = typeof PluginLoadRefusal.Type + +const NonNegativeInt = S.Int.check(S.isGreaterThanOrEqualTo(0)) + +const RecordShared = { + stage: FailureStage, + cause: S.Array(CauseLink), + capsule: Capsule, + nextAction: NextAction, + traceId: S.NullOr(TraceId), +} + +const variant = (tag: Tag, fields: Fields) => ({ + tag, + evidence: S.TaggedStruct(tag, { stage: FailureStage, ...fields }), + record: S.TaggedStruct(tag, { ...RecordShared, ...fields }), +}) + +const SurvivorEvidence = S.Struct({ mutantId: S.NonEmptyString, file: S.String, line: SourceLine }) + +const VARIANTS = [ + variant('ArgumentsInvalid', { argument: S.NullOr(S.String) }), + variant('ConfigInvalid', { detail: S.String }), + variant('PluginNotFound', { descriptor: S.String }), + variant('PluginLoadFailed', { descriptor: S.String, reason: PluginLoadRefusal }), + variant('PluginImportFailed', { descriptor: S.String }), + variant('SurvivorsUnavailable', { reason: S.Literals(['no-report', 'mismatch']) }), + variant('NoInputFiles', {}), + variant('SandboxPreparationFailed', {}), + variant('InstrumentationFailed', {}), + variant('CheckerFailed', { checker: S.NullOr(S.String) }), + variant('TestRunnerFailed', {}), + variant('BaselineTestsFailed', { testCount: NonNegativeInt, tests: S.NonEmptyArray(FailedTestEvidence) }), + variant('BaselineTimedOut', {}), + variant('BaselineErrored', {}), + variant('BaselineFoundNoTests', {}), + variant('WorkerBootTimedOut', { workerKind: WorkerKind, pid: S.Int }), + variant('WorkerOutOfMemory', { workerKind: WorkerKind, pid: S.Int, exitCode: S.Int }), + variant('WorkerCrashed', { + workerKind: WorkerKind, + pid: S.Int, + exitCode: S.NullOr(S.Int), + signal: S.NullOr(S.String), + }), + variant('ReporterFailed', { reporter: S.NullOr(S.String) }), + variant('RunInterrupted', {}), + variant('NewSurvivors', { survivors: S.NonEmptyArray(SurvivorEvidence), unchecked: NonNegativeInt }), + variant('InvariantBroken', {}), + variant('CatalogGap', {}), + variant('RecordMissing', { exitCode: S.NullOr(S.Int) }), + variant('JobTimedOut', { limitSeconds: NonNegativeInt }), + variant('BinaryMissing', { binary: S.String }), +] as const + +export const FailureEvidence = S.Union(VARIANTS.map((each) => each.evidence)) +export type FailureEvidence = typeof FailureEvidence.Type + +export const FailureRecord = S.Union(VARIANTS.map((each) => each.record)) +export type FailureRecord = typeof FailureRecord.Type + +export const FailureCode = S.Literals(VARIANTS.map((each) => each.tag)) +export type FailureCode = typeof FailureCode.Type + +export const FailureRecordFile = S.fromJsonString(FailureRecord) diff --git a/packages/stryker-js-cli-contract/src/mod.ts b/packages/stryker-js-cli-contract/src/mod.ts index 7953746a..fdf30aad 100644 --- a/packages/stryker-js-cli-contract/src/mod.ts +++ b/packages/stryker-js-cli-contract/src/mod.ts @@ -1,3 +1,4 @@ +export * as FailureRecord from './FailureRecord/mod.js' export * as OutputMode from './OutputMode/mod.js' export * as RunEvent from './RunEvent/mod.js' export * as SpanTaxonomy from './SpanTaxonomy/mod.js' diff --git a/packages/stryker-js-cli-contract/src/render-failure.ts b/packages/stryker-js-cli-contract/src/render-failure.ts new file mode 100644 index 00000000..7cc133f6 --- /dev/null +++ b/packages/stryker-js-cli-contract/src/render-failure.ts @@ -0,0 +1,408 @@ +import * as Arr from 'effect/Array' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as Predicate from 'effect/Predicate' + +import { FailureCatalog } from './failure-catalog.js' +import { + type Capsule, + type CauseLink, + type FailedTestEvidence, + type FailureCode, + FailureRecord, + type NextAction, + type PluginLoadRefusal, + type Replays, +} from './failure-record.schema.js' + +const meaningOf = (code: FailureCode): string => FailureCatalog[code].meaning + +const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/u + +const singleQuoted = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + +const isShellSafe = (value: string): boolean => value.length > 0 && SHELL_SAFE.test(value) + +const shellQuote = (value: string): string => (isShellSafe(value) ? value : singleQuoted(value)) + +const escapeCommandData = (value: string): string => + value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + +const escapeCommandProperty = (value: string): string => + escapeCommandData(value).replaceAll(':', '%3A').replaceAll(',', '%2C') + +const indentLines = (text: string, prefix: string): ReadonlyArray => + text.split('\n').map((line) => `${prefix}${line}`) + +const locationTextOf = (location: { readonly file: string; readonly line: number; readonly column: number }): string => + `${location.file}:${location.line}:${location.column}` + +const textOrFallback = (value: string | null, fallback: string): string => (value === null ? fallback : value) + +const numberOrFallback = (value: number | null, fallback: string): string => value === null ? fallback : `${value}` + +const refusalTextOf = (refusal: PluginLoadRefusal): string => + Match.value(refusal).pipe( + Match.tagsExhaustive({ + PeerMissing: (missing) => `the peer ${missing.peer} is not installed`, + PeerVersionUnsupported: (unsupported) => + `the peer ${unsupported.peer} is at an unsupported version: ${unsupported.detail}`, + PeerUnrecognized: (unrecognized) => `the peer ${unrecognized.peer} is not recognized`, + InvalidContribution: (invalid) => `its contribution is invalid: ${invalid.detail}`, + }), + ) + +const nonNullPrefixOf = (value: string | null): string => (value === null ? '' : `${value} `) + +const failedTestWhereOf = (test: FailedTestEvidence): string => + test.location === null ? nonNullPrefixOf(test.file) : `${locationTextOf(test.location)} ` + +const failedTestHeadingOf = (test: FailedTestEvidence): string => `${failedTestWhereOf(test)}${test.name}` + +const unlocatedStackOf = (test: FailedTestEvidence): string | null => (test.location === null ? test.stack : null) + +const baselineTestLinesOf = ( + testCount: number, + tests: ReadonlyArray, +): ReadonlyArray => [ + `${tests.length} of ${testCount} test(s) failed`, + ...Arr.flatMap(tests, (test) => [ + `- ${failedTestHeadingOf(test)}`, + ` message: ${test.message}`, + ...Option.match(Option.fromNullishOr(unlocatedStackOf(test)), { + onNone: (): ReadonlyArray => [], + onSome: (stack) => [' stack:', ...indentLines(stack, ' ')], + }), + ]), +] + +const evidenceLinesOf = (record: FailureRecord): ReadonlyArray => + Match.value(record).pipe( + Match.tagsExhaustive({ + ArgumentsInvalid: (evidence) => + evidence.argument === null ? ['no argument was named'] : [`argument: ${evidence.argument}`], + ConfigInvalid: (evidence) => [`detail: ${evidence.detail}`], + PluginNotFound: (evidence) => [`descriptor: ${evidence.descriptor}`], + PluginLoadFailed: (evidence) => [ + `descriptor: ${evidence.descriptor}`, + `refusal: ${refusalTextOf(evidence.reason)}`, + ], + PluginImportFailed: (evidence) => [`descriptor: ${evidence.descriptor}`], + SurvivorsUnavailable: (evidence) => [`reason: ${evidence.reason}`], + NoInputFiles: () => [], + SandboxPreparationFailed: () => [], + InstrumentationFailed: () => [], + CheckerFailed: (evidence) => [`checker: ${textOrFallback(evidence.checker, '(not named)')}`], + TestRunnerFailed: () => [], + BaselineTestsFailed: (evidence) => baselineTestLinesOf(evidence.testCount, evidence.tests), + BaselineTimedOut: () => [], + BaselineErrored: () => [], + BaselineFoundNoTests: () => [], + WorkerBootTimedOut: (evidence) => [ + `worker: ${evidence.workerKind} pid ${evidence.pid}`, + 'its boot window closed before it reported ready', + ], + WorkerOutOfMemory: (evidence) => [ + `worker: ${evidence.workerKind} pid ${evidence.pid}`, + `exit code: ${evidence.exitCode}`, + ], + WorkerCrashed: (evidence) => [ + `worker: ${evidence.workerKind} pid ${evidence.pid}`, + `exit code: ${numberOrFallback(evidence.exitCode, '(no exit code)')}`, + `signal: ${textOrFallback(evidence.signal, '(no signal)')}`, + ], + ReporterFailed: (evidence) => [`reporter: ${textOrFallback(evidence.reporter, '(not named)')}`], + RunInterrupted: () => [], + NewSurvivors: (evidence) => [ + `survivors: ${evidence.survivors.length} listed, ${evidence.unchecked} unchecked`, + ...evidence.survivors.map((survivor) => `${survivor.file}:${survivor.line} ${survivor.mutantId}`), + ], + InvariantBroken: () => [], + CatalogGap: () => [], + RecordMissing: (evidence) => [`exit code: ${numberOrFallback(evidence.exitCode, '(not recorded)')}`], + JobTimedOut: (evidence) => [`time limit: ${evidence.limitSeconds}s`], + BinaryMissing: (evidence) => [`binary: ${evidence.binary}`], + }), + ) + +const stackShownFor = (record: FailureRecord) => (link: CauseLink): string | null => + Predicate.isTagged(record, 'CatalogGap') ? link.stack : null + +const causeLinesOf = (record: FailureRecord): ReadonlyArray => + Arr.flatMap(record.cause, (link) => [ + `${link.kind}: ${link.message}`, + ...Option.match(Option.fromNullishOr(stackShownFor(record)(link)), { + onNone: (): ReadonlyArray => [], + onSome: (stack) => indentLines(stack, ' '), + }), + ]) + +const reproduceCommandOf = (replays: Replays): string => + `(cd ${shellQuote(replays.cwd)} && ${ + [ + ...replays.env.map((entry) => `${entry.name}=${shellQuote(entry.value)}`), + ...replays.argv.map(shellQuote), + ].join(' ') + })` + +const capsuleLinesOf = (capsule: Capsule): ReadonlyArray => + Match.value(capsule).pipe( + Match.tagsExhaustive({ + Replays: (replays) => [`reproduce: ${reproduceCommandOf(replays)}`], + DoesNotReplay: (refusal) => [`does not replay (${refusal.why}); instead: ${refusal.standIn}`], + }), + ) + +const otherwiseLinesOf = (record: FailureRecord): ReadonlyArray => + record.nextAction.otherwise === null ? [] : [`otherwise: ${record.nextAction.otherwise}`] + +const traceLinesOf = (record: FailureRecord): ReadonlyArray => + record.traceId === null ? [] : [`trace: ${record.traceId}`] + +export const terminalTextOf = (record: FailureRecord): string => + [ + `${record._tag}: ${meaningOf(record._tag)}`, + ...evidenceLinesOf(record), + ...causeLinesOf(record), + `next: ${record.nextAction.primary}`, + ...otherwiseLinesOf(record), + ...capsuleLinesOf(record.capsule), + ...traceLinesOf(record), + ].join('\n') + +const isBulletLine = (line: string): boolean => line.startsWith('- ') + +const indentedBulletOf = (line: string): string => (line.startsWith(' ') ? ` - ${line.trimStart()}` : `- ${line}`) + +const markdownBulletOf = (line: string): string => (isBulletLine(line) ? line : indentedBulletOf(line)) + +const otherwiseSuffixOf = (nextAction: NextAction): string => + nextAction.otherwise === null ? '' : ` (otherwise \`${nextAction.otherwise}\`)` + +const bulletsOf = (facts: ReadonlyArray): ReadonlyArray => + facts.length === 0 ? ['- no further evidence'] : facts.map(markdownBulletOf) + +const markdownCapsuleLinesOf = (capsule: Capsule, traceId: string | null): ReadonlyArray => [ + ...Match.value(capsule).pipe( + Match.tagsExhaustive({ + Replays: (replays) => ['**Replay:**', '', '```bash', reproduceCommandOf(replays), '```'], + DoesNotReplay: (refusal) => [`**Replay:** does not replay (${refusal.why}); instead: ${refusal.standIn}`], + }), + ), + ...(traceId === null ? [] : ['', `**Trace:** \`${traceId}\``]), +] + +export const markdownOf = (record: FailureRecord): string => { + const facts = [...evidenceLinesOf(record), ...causeLinesOf(record)] + return [ + `## \`${record._tag}\`: ${meaningOf(record._tag)}`, + '', + ...bulletsOf(facts), + '', + `**Next:** \`${record.nextAction.primary}\`${otherwiseSuffixOf(record.nextAction)}`, + '', + ...markdownCapsuleLinesOf(record.capsule, record.traceId), + '', + ].join('\n') +} + +interface LocatedEvidence { + readonly file: string + readonly line: number + readonly column: number | null + readonly message: string +} + +const locatedEvidenceOf = (record: FailureRecord): ReadonlyArray => + Match.value(record).pipe( + Match.tagsExhaustive({ + BaselineTestsFailed: (evidence) => + Arr.flatMap(evidence.tests, (test) => + test.location === null + ? [] + : [ + { + file: test.location.file, + line: test.location.line, + column: test.location.column, + message: `${test.name}: ${test.message}`, + }, + ]), + NewSurvivors: (evidence) => + evidence.survivors.map((survivor) => ({ + file: survivor.file, + line: survivor.line, + column: null, + message: `surviving mutant ${survivor.mutantId}`, + })), + ArgumentsInvalid: () => [], + ConfigInvalid: () => [], + PluginNotFound: () => [], + PluginLoadFailed: () => [], + PluginImportFailed: () => [], + SurvivorsUnavailable: () => [], + NoInputFiles: () => [], + SandboxPreparationFailed: () => [], + InstrumentationFailed: () => [], + CheckerFailed: () => [], + TestRunnerFailed: () => [], + BaselineTimedOut: () => [], + BaselineErrored: () => [], + BaselineFoundNoTests: () => [], + WorkerBootTimedOut: () => [], + WorkerOutOfMemory: () => [], + WorkerCrashed: () => [], + ReporterFailed: () => [], + RunInterrupted: () => [], + InvariantBroken: () => [], + CatalogGap: () => [], + RecordMissing: () => [], + JobTimedOut: () => [], + BinaryMissing: () => [], + }), + ) + +const annotationOf = (code: FailureCode, located: LocatedEvidence): string => { + const properties = [ + `title=${escapeCommandProperty(code)}`, + `file=${escapeCommandProperty(located.file)}`, + `line=${escapeCommandProperty(`${located.line}`)}`, + ...(located.column === null ? [] : [`col=${escapeCommandProperty(`${located.column}`)}`]), + ].join(',') + return `::error ${properties}::${escapeCommandData(located.message)}` +} + +const firstEvidenceLineOf = (record: FailureRecord): Option.Option => Arr.head(evidenceLinesOf(record)) + +const summaryAnnotationOf = (record: FailureRecord): string => { + const summary = Option.match(firstEvidenceLineOf(record), { + onNone: () => meaningOf(record._tag), + onSome: (first) => `${meaningOf(record._tag)} — ${first}`, + }) + return `::error title=${escapeCommandProperty(record._tag)}::${escapeCommandData(summary)}` +} + +export const annotationsOf = (record: FailureRecord): ReadonlyArray => { + const located = locatedEvidenceOf(record) + return located.length === 0 + ? [summaryAnnotationOf(record)] + : located.map((entry) => annotationOf(record._tag, entry)) +} + +export const sarifTextOf = (record: FailureRecord): string => + [ + `${record._tag}: ${meaningOf(record._tag)}`, + ...Option.toArray(firstEvidenceLineOf(record)), + `next: ${record.nextAction.primary}`, + ].join(' — ') + +if (import.meta.vitest !== void 0) { + const { it } = await import('@systemfsoftware/vitest') + + const FORBIDDEN = /\[object Object\]|Unknown failure|_tag|~(?:stryker|systemfsoftware|effect)\b/u + const LINE_BREAK = /[\r\n]/u + const JOIN = '\u0000' + + const locatedAnnotationTextOf = ( + code: FailureCode, + file: string, + line: number, + column: number | null, + message: string, + ): string => + `::error title=${escapeCommandProperty(code)},file=${escapeCommandProperty(file)},` + + `line=${escapeCommandProperty(`${line}`)}` + + `${column === null ? '' : `,col=${escapeCommandProperty(`${column}`)}`}::${escapeCommandData(message)}` + + const expectedAnnotationsOf = (record: FailureRecord): ReadonlyArray => + Match.value(record).pipe( + Match.tag('BaselineTestsFailed', (evidence) => + Arr.flatMap(evidence.tests, (test) => + test.location === null + ? [] + : [ + locatedAnnotationTextOf( + 'BaselineTestsFailed', + test.location.file, + test.location.line, + test.location.column, + `${test.name}: ${test.message}`, + ), + ])), + Match.tag('NewSurvivors', (evidence) => + evidence.survivors.map((survivor) => + locatedAnnotationTextOf( + 'NewSurvivors', + survivor.file, + survivor.line, + null, + `surviving mutant ${survivor.mutantId}`, + ) + )), + Match.orElse((): ReadonlyArray => []), + ) + + const summaryOnly = (annotations: ReadonlyArray, code: FailureCode): boolean => + annotations.length === 1 && Arr.every(annotations, (line) => line.startsWith(`::error title=${code}::`)) + + const sameAnnotations = (actual: ReadonlyArray, expected: ReadonlyArray): boolean => + actual.length === expected.length && actual.every((line, index) => line === expected[index]) + + it.prop( + '∀r_FailureRecord_≡AnnotationsCoverExactlyTheLocatedEvidence', + { of: [FailureRecord], subject: annotationsOf }, + (subject, [record]) => { + const expected = expectedAnnotationsOf(record) + const annotations = subject(record) + return expected.length === 0 ? summaryOnly(annotations, record._tag) : sameAnnotations(annotations, expected) + }, + ) + + it.prop( + '∀r_FailureRecord_≡AnnotationsLeakNoInternalTag', + { of: [FailureRecord], subject: annotationsOf }, + (subject, [record]) => !FORBIDDEN.test(subject(record).join(JOIN)), + ) + + it.prop( + '∀r_FailureRecord_≡AnnotationsNeverSplitAWorkflowCommand', + { of: [FailureRecord], subject: annotationsOf }, + (subject, [record]) => !LINE_BREAK.test(subject(record).join(JOIN)), + ) + + it.prop( + '∀r_FailureRecord_≡TerminalTextLeaksNoInternalTag', + { of: [FailureRecord], subject: terminalTextOf }, + (subject, [record]) => !FORBIDDEN.test(subject(record)), + ) + + it.prop( + '∀r_FailureRecord_≡TerminalTextStatesTheNextAction', + { of: [FailureRecord], subject: terminalTextOf }, + (subject, [record]) => subject(record).includes(`next: ${record.nextAction.primary}`), + ) + + it.prop( + '∀r_FailureRecord_≡MarkdownLeaksNoInternalTag', + { of: [FailureRecord], subject: markdownOf }, + (subject, [record]) => !FORBIDDEN.test(subject(record)), + ) + + it.prop( + '∀r_FailureRecord_≡MarkdownStatesTheNextAction', + { of: [FailureRecord], subject: markdownOf }, + (subject, [record]) => subject(record).includes(`\`${record.nextAction.primary}\``), + ) + + it.prop( + '∀r_FailureRecord_≡SarifLeaksNoInternalTag', + { of: [FailureRecord], subject: sarifTextOf }, + (subject, [record]) => !FORBIDDEN.test(subject(record)), + ) + + it.prop( + '∀r_FailureRecord_≡SarifStatesTheNextAction', + { of: [FailureRecord], subject: sarifTextOf }, + (subject, [record]) => subject(record).endsWith(`next: ${record.nextAction.primary}`), + ) +} diff --git a/packages/stryker-js-cli-contract/src/run-event.schema.ts b/packages/stryker-js-cli-contract/src/run-event.schema.ts index 432c826c..96909d8c 100644 --- a/packages/stryker-js-cli-contract/src/run-event.schema.ts +++ b/packages/stryker-js-cli-contract/src/run-event.schema.ts @@ -3,8 +3,8 @@ import { Plugin, Report } from '@systemfsoftware/stryker-js-plugin-interface' import { SchemaGetter } from 'effect' import * as S from 'effect/Schema' +import { FailureRecord } from './failure-record.schema.js' import { ModeSignal, OutputMode } from './output-mode.schema.js' -import { PluginLoadFailureReason } from './plugin-load-failure-reason.schema.js' import { StreamSchemaVersion } from './stream-version.schema.js' export const RunPhase = S.Literals(['prepare', 'instrument', 'dry-run', 'mutation-test']) @@ -256,9 +256,7 @@ export class FeedbackReported extends S.TaggedClass()('feedbac export class RunFailed extends S.TaggedClass()('error', { schemaVersion: StreamSchemaVersion, code: Plugin.ExitCode, - error: S.String, - remediation: S.String, - reason: S.NullOr(PluginLoadFailureReason), + record: FailureRecord, }) {} export class HelpRendered extends S.TaggedClass()('help', { diff --git a/packages/stryker-js-cli-contract/src/stream-version.schema.ts b/packages/stryker-js-cli-contract/src/stream-version.schema.ts index 9a764165..5378825a 100644 --- a/packages/stryker-js-cli-contract/src/stream-version.schema.ts +++ b/packages/stryker-js-cli-contract/src/stream-version.schema.ts @@ -1,4 +1,4 @@ import * as S from 'effect/Schema' -export const StreamSchemaVersion = S.Literal('2.0') +export const StreamSchemaVersion = S.Literal('3.0') export type StreamSchemaVersion = typeof StreamSchemaVersion.Type diff --git a/packages/stryker-js-cli-contract/tests/contract-documents.integration.test.ts b/packages/stryker-js-cli-contract/tests/contract-documents.integration.test.ts index 781ebcc2..c9dacab7 100644 --- a/packages/stryker-js-cli-contract/tests/contract-documents.integration.test.ts +++ b/packages/stryker-js-cli-contract/tests/contract-documents.integration.test.ts @@ -7,6 +7,7 @@ import * as Layer from 'effect/Layer' import * as Path from 'effect/Path' import { + failureCatalogDocumentSource, spanTaxonomyDocumentSource, stockCatalogDocumentSource, streamDocumentSource, @@ -56,6 +57,23 @@ Feature('Regenerating the published CLI contract documents') ), ) + scenario( + 'The committed failure catalog document is byte for byte what the generator writes from the catalog', + Gherkin.Do.pipe( + Given('the committed failure catalog document')( + 'committed', + () => readCommitted('contract/failure-catalog.json'), + ), + When('the generator reconstructs the document from the failure catalog')( + 'regenerated', + () => Effect.sync(() => failureCatalogDocumentSource()), + ), + Then('the regenerated document equals the committed bytes')((s, expect) => + expect(s.regenerated).toEqual(s.committed) + ), + ), + ) + scenario( 'The committed span taxonomy document is byte for byte what the generator writes from the taxonomy', Gherkin.Do.pipe( diff --git a/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md b/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md index 3951f1ba..0768e4d9 100644 --- a/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md +++ b/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md @@ -51,7 +51,7 @@ export namespace Reporter { // @public (undocumented) export namespace TestRunner { - export { BaseTestResult, CompleteDryRunResult, CoverageAnalysis, CoverageAnalysisSchema, DryRunOptions, DryRunOptionsSchema, DryRunResult, DryRunResultSchema, DryRunStatus, ErrorDryRunResult, ErrorMutantRunResult, ExecutedTest, ExecutedTestSchema, FailedTestResult, HitLimitReason, HitLimitReasonPrefix, HitLimitReasonText, KilledMutantRunResult, MutantRunResult, MutantRunResultSchema, MutantRunStatus, SkippedTestResult, SuccessTestResult, SurvivedMutantRunResult, TestId, TestResult, TestResultSchema, TestRunner, TestRunnerCapabilities, TestRunnerCapabilitiesSchema, TestRunnerFailed, TestRunnerService, TestStatus, TimeoutDryRunResult, TimeoutMutantRunResult, WallClockTimeoutReason }; + export { BaseTestResult, CompleteDryRunResult, CoverageAnalysis, CoverageAnalysisSchema, DryRunOptions, DryRunOptionsSchema, DryRunResult, DryRunResultSchema, DryRunStatus, ErrorDryRunResult, ErrorMutantRunResult, ExecutedTest, ExecutedTestSchema, FailedTestResult, HitLimitReason, HitLimitReasonPrefix, HitLimitReasonText, KilledMutantRunResult, MutantRunResult, MutantRunResultSchema, MutantRunStatus, SkippedTestResult, SuccessTestResult, SurvivedMutantRunResult, TestFailureLocation, TestId, TestResult, TestResultSchema, TestRunner, TestRunnerCapabilities, TestRunnerCapabilitiesSchema, TestRunnerFailed, TestRunnerService, TestStatus, TimeoutDryRunResult, TimeoutMutantRunResult, WallClockTimeoutReason }; } // @public (undocumented) diff --git a/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts b/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts index 1476819d..1e313c93 100644 --- a/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts +++ b/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts @@ -1,7 +1,6 @@ /// import * as S from 'effect/Schema' -import { Position } from './Location.schema.js' import { NonNegativeFinite, NonNegativeInt } from './Metrics.schema.js' import { MutantCoverageSchema, RunOptionsFields } from './Mutant.schema.js' import type { MutantCoverage, RunOptions } from './Mutant.schema.js' @@ -27,11 +26,21 @@ const TestResultBase = { name: S.String, timeSpentMs: NonNegativeFinite, fileName: S.optionalKey(S.String), - startPosition: S.optionalKey(Position), } +const SourceOrdinal = S.Int.check(S.isGreaterThanOrEqualTo(1)) + +export const TestFailureLocation = S.Struct({ file: S.NonEmptyString, line: SourceOrdinal, column: SourceOrdinal }) +export type TestFailureLocation = typeof TestFailureLocation.Type + export const TestResultSchema = S.Union([ - S.Struct({ ...TestResultBase, status: S.Literal('failed'), failureMessage: S.String }), + S.Struct({ + ...TestResultBase, + status: S.Literal('failed'), + failureMessage: S.String, + location: S.optionalKey(TestFailureLocation), + stack: S.optionalKey(S.String), + }), S.Struct({ ...TestResultBase, status: S.Literal('skipped') }), S.Struct({ ...TestResultBase, status: S.Literal('success') }), ]) @@ -98,12 +107,13 @@ export interface BaseTestResult { readonly name: string readonly timeSpentMs: number readonly fileName?: string - readonly startPosition?: Position } export interface FailedTestResult extends BaseTestResult { readonly status: 'failed' readonly failureMessage: string + readonly location?: TestFailureLocation + readonly stack?: string } export interface SkippedTestResult extends BaseTestResult { diff --git a/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts b/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts index d20ca61f..3cb4073b 100644 --- a/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts +++ b/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts @@ -15,10 +15,10 @@ import * as Result from 'effect/Result' import * as S from 'effect/Schema' import type { RunnerTask, RunnerTestCase, RunnerTestFile, RunnerTestSuite } from 'vitest' -import { testRecordOf } from './drivers/vitest-node.js' +import { errorTextOf, failureFramesOf, testRecordOf } from './drivers/vitest-node.js' import { interpretVitestDryRun } from './interpret-vitest-dry-run.workflow.js' import { makeMutantRunCell } from './MutantRun.cell.js' -import { VitestDryRunCommand } from './vitest-run-command.schema.js' +import { VitestDryRunCommand, type VitestFileFailure } from './vitest-run-command.schema.js' import { interpretVitestTestRun } from './vitest-test-run.js' import { CoverageDecodeFailed, type TestRunnerPhase } from './VitestRunner.schema.js' import { testFileOrder } from './VitestRuntime.blueprint.js' @@ -92,13 +92,11 @@ const collectTestsFromSuite = (suite: RunnerTestSuite): readonly RunnerTestCase[ }) ) -const messageOfError = (error: { readonly message?: string }): Option.Option => - Option.filter(Option.fromNullishOr(error.message), (message) => message.length > 0) - const fileFailureMessages = (file: RunnerTestFile): readonly string[] => Option.match(Option.fromNullishOr(file.result?.errors), { onNone: (): readonly string[] => [], - onSome: (errors) => errors.flatMap((error) => Option.toArray(messageOfError(error))), + onSome: (errors) => + errors.flatMap((error) => Option.toArray(Option.filter(errorTextOf(error), (message) => message.length > 0))), }) const fileFailureMessage = (file: RunnerTestFile): string => { @@ -106,6 +104,27 @@ const fileFailureMessage = (file: RunnerTestFile): string => { return messages.length === 0 ? 'StrykerJS: the test file failed to load' : messages.join('\n') } +const fileFailureOf = (file: RunnerTestFile): VitestFileFailure => { + const first = Option.flatMap( + Option.fromNullishOr(file.result?.errors), + (errors) => Option.fromNullishOr(errors[0]), + ) + return { + fileName: file.filepath, + message: fileFailureMessage(file), + ...Option.match(first, { + onNone: () => ({}), + onSome: (error) => ({ + ...Option.match(Option.fromNullishOr(error.stack), { + onNone: () => ({}), + onSome: (stack) => ({ stack }), + }), + frames: failureFramesOf(error), + }), + }), + } +} + const SKIPPED_TEST_MODES: ReadonlySet = new Set(['skip', 'todo']) const isCollectableTest = (test: RunnerTestCase): boolean => @@ -331,9 +350,7 @@ const makeRunner = Effect.fn(SpanTaxonomy.Spans.vitestRunnerMake.name)(function* })) const records = collected.flatMap((entry) => entry.tests).map(testRecordOf) const fileFailures = collected.flatMap(({ file, tests }) => - fileFailedWithoutFailingTest(file, tests) - ? [{ fileName: file.filepath, message: fileFailureMessage(file) }] - : [] + fileFailedWithoutFailingTest(file, tests) ? [fileFailureOf(file)] : [] ) const externalError = hasExternalErrors(self) yield* Effect.annotateCurrentSpan({ diff --git a/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts b/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts index 5516c55e..a808c3ac 100644 --- a/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts +++ b/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts @@ -6,7 +6,7 @@ import * as Predicate from 'effect/Predicate' import * as S from 'effect/Schema' import type { Vitest } from 'vitest/node' -import type { VitestTestRecord } from '../vitest-run-command.schema.js' +import type { VitestFailureFrame, VitestTestRecord } from '../vitest-run-command.schema.js' export type VitestValue = S.Schema.Type @@ -160,14 +160,49 @@ const stringFieldOf = (value: A, key: string): Option.Option(value: A, key: string): Option.Option => Option.filter(propertyOf(value, key), Predicate.isNumber) -const firstErrorMessageOf = (result: A): Option.Option => +const firstErrorOf = (result: A): Option.Option => Option.flatMap( Option.flatMap(propertyOf(result, 'errors'), (errors) => Option.flatMap(Option.liftPredicate(errors, Array.isArray), (list) => Option.fromNullishOr(list[0]))), (first) => - stringFieldOf(first, 'message'), + Option.liftPredicate(first, Predicate.isObject), ) +const frameOf = (value: VitestValue): Option.Option => + Option.flatMap( + stringFieldOf(value, 'file'), + (file) => + Option.flatMap(numberFieldOf(value, 'line'), (line) => + Option.map(numberFieldOf(value, 'column'), (column) => ({ file, line, column }))), + ) + +export const failureFramesOf = (error: A): readonly VitestFailureFrame[] => + Option.getOrElse( + Option.map( + Option.filter(propertyOf(error, 'stacks'), Array.isArray), + (list) => list.flatMap((entry) => Option.toArray(frameOf(entry))), + ), + (): readonly VitestFailureFrame[] => [], + ) + +const causeTextsOf = (error: A): readonly string[] => + Option.match(propertyOf(error, 'cause'), { + onNone: (): readonly string[] => [], + onSome: (cause) => Option.toArray(errorTextOf(cause)), + }) + +export const errorTextOf = (error: A): Option.Option => + Option.map(stringFieldOf(error, 'message'), (message) => [message, ...causeTextsOf(error)].join('\n')) + +const errorTextOfResult = (result: A): Option.Option => + Option.flatMap(firstErrorOf(result), errorTextOf) + +const failureNameOf = (result: A): Option.Option => + Option.flatMap(firstErrorOf(result), (error) => stringFieldOf(error, 'name')) + +const failureStackOf = (result: A): Option.Option => + Option.flatMap(firstErrorOf(result), (error) => stringFieldOf(error, 'stack')) + const suiteNamesOf = (suite: A): readonly string[] => Option.match(recordOption(suite), { onNone: (): readonly string[] => [], @@ -182,20 +217,27 @@ const suiteErrorOf = (suite: A): string | undefined => Option.match(recordOption(suite), { onNone: (): string | undefined => undefined, onSome: (record) => - Option.match(firstErrorMessageOf(fieldOf(record, 'result')), { + Option.match(errorTextOfResult(fieldOf(record, 'result')), { onNone: () => suiteErrorOf(fieldOf(record, 'suite')), onSome: (message) => message, }), }) -export const testRecordOf = (test: A): VitestTestRecord => ({ - name: textFieldOf(test, 'name'), - fullTestName: Option.getOrUndefined(stringFieldOf(test, 'fullTestName')), - suiteNames: suiteNamesOf(fieldOf(test, 'suite')), - fileName: Option.getOrUndefined(stringFieldOf(fieldOf(test, 'file'), 'filepath')), - mode: Option.getOrUndefined(stringFieldOf(test, 'mode')), - state: Option.getOrUndefined(stringFieldOf(fieldOf(test, 'result'), 'state')), - durationMs: Option.getOrUndefined(numberFieldOf(fieldOf(test, 'result'), 'duration')), - errorMessage: Option.getOrUndefined(firstErrorMessageOf(fieldOf(test, 'result'))), - suiteErrorMessage: suiteErrorOf(fieldOf(test, 'suite')), -}) +export const testRecordOf = (test: A): VitestTestRecord => { + const result = fieldOf(test, 'result') + const error = firstErrorOf(result) + return { + name: textFieldOf(test, 'name'), + fullTestName: Option.getOrUndefined(stringFieldOf(test, 'fullTestName')), + suiteNames: suiteNamesOf(fieldOf(test, 'suite')), + fileName: Option.getOrUndefined(stringFieldOf(fieldOf(test, 'file'), 'filepath')), + mode: Option.getOrUndefined(stringFieldOf(test, 'mode')), + state: Option.getOrUndefined(stringFieldOf(result, 'state')), + durationMs: Option.getOrUndefined(numberFieldOf(result, 'duration')), + errorMessage: Option.getOrUndefined(errorTextOfResult(result)), + errorName: Option.getOrUndefined(failureNameOf(result)), + errorStack: Option.getOrUndefined(failureStackOf(result)), + errorFrames: Option.getOrUndefined(Option.map(error, failureFramesOf)), + suiteErrorMessage: suiteErrorOf(fieldOf(test, 'suite')), + } +} diff --git a/packages/stryker-js-vitest-runner/src/vitest-run-command.schema.ts b/packages/stryker-js-vitest-runner/src/vitest-run-command.schema.ts index bcb4618d..7bb35601 100644 --- a/packages/stryker-js-vitest-runner/src/vitest-run-command.schema.ts +++ b/packages/stryker-js-vitest-runner/src/vitest-run-command.schema.ts @@ -3,6 +3,15 @@ import { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' import { Report, TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' import * as S from 'effect/Schema' +const VitestSourceOrdinal = S.Int.check(S.isGreaterThanOrEqualTo(1)) + +export const VitestFailureFrame = S.Struct({ + file: S.String, + line: VitestSourceOrdinal, + column: VitestSourceOrdinal, +}) +export type VitestFailureFrame = S.Schema.Type + export const VitestTestRecord = S.Struct({ name: S.String, fullTestName: S.optional(S.String), @@ -12,6 +21,9 @@ export const VitestTestRecord = S.Struct({ state: S.optional(S.String), durationMs: S.optional(Report.NonNegativeFinite), errorMessage: S.optional(S.String), + errorName: S.optional(S.String), + errorStack: S.optional(S.String), + errorFrames: S.Array(VitestFailureFrame).pipe(S.optional), suiteErrorMessage: S.optional(S.String), }) export type VitestTestRecord = S.Schema.Type @@ -19,6 +31,8 @@ export type VitestTestRecord = S.Schema.Type export const VitestFileFailure = S.Struct({ fileName: S.String, message: S.String, + stack: S.optional(S.String), + frames: S.Array(VitestFailureFrame).pipe(S.optional), }) export type VitestFileFailure = S.Schema.Type diff --git a/packages/stryker-js-vitest-runner/src/vitest-test-run.ts b/packages/stryker-js-vitest-runner/src/vitest-test-run.ts index d6ad23ac..92a7c68f 100644 --- a/packages/stryker-js-vitest-runner/src/vitest-test-run.ts +++ b/packages/stryker-js-vitest-runner/src/vitest-test-run.ts @@ -3,7 +3,12 @@ import { TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' import * as Boolean from 'effect/Boolean' import * as Match from 'effect/Match' import * as Option from 'effect/Option' -import type { VitestFileFailure, VitestTestRecord, VitestTestRun } from './vitest-run-command.schema.js' +import type { + VitestFailureFrame, + VitestFileFailure, + VitestTestRecord, + VitestTestRun, +} from './vitest-run-command.schema.js' type TestResultEncoded = (typeof TestRunner.TestResultSchema)['Encoded'] @@ -40,6 +45,50 @@ const testNameOf = (record: VitestTestRecord): string => () => [...record.suiteNames, record.name].join(SUITE_SEPARATOR).trim(), ) +const isInternalFilePath = (file: string): boolean => file.startsWith('node:') || file.startsWith('file://') + +const isNodeModulesPath = (file: string): boolean => file.split(/[/\\]/).includes('node_modules') + +const hasSourcePosition = (frame: VitestFailureFrame): boolean => frame.line >= 1 && frame.column >= 1 + +const isExternalFrame = (frame: VitestFailureFrame): boolean => frame.file.length > 0 && !isInternalFilePath(frame.file) + +const isProjectFileFrame = (frame: VitestFailureFrame): boolean => + isExternalFrame(frame) && !isNodeModulesPath(frame.file) + +const isProjectSourceFrame = (frame: VitestFailureFrame): boolean => + isProjectFileFrame(frame) && hasSourcePosition(frame) + +const locationOf = ( + frames: readonly VitestFailureFrame[] | undefined, + projectRoot: string, +): Option.Option => + Option.flatMap( + Option.fromNullishOr(frames), + (present) => + Option.flatMap( + Option.fromNullishOr(present.find(isProjectSourceFrame)), + (frame) => { + const file = projectRelativePath(stripProjectRoot(frame.file, projectRoot)) + return file.length > 0 ? Option.some({ file, line: frame.line, column: frame.column }) : Option.none() + }, + ), + ) + +const failureEvidenceOf = ( + location: Option.Option, + stack: string | undefined, +): { readonly location?: TestRunner.TestFailureLocation; readonly stack?: string } => ({ + ...Option.match(location, { + onNone: () => ({}), + onSome: (present) => ({ location: present }), + }), + ...Option.match(Option.fromNullishOr(stack), { + onNone: () => ({}), + onSome: (present) => ({ stack: present }), + }), +}) + const testStatusOf = (record: VitestTestRecord): TestRunner.TestStatus => Boolean.match(isSkippedMode(record.mode) || isSkippedMode(record.state), { onTrue: (): TestRunner.TestStatus => 'skipped', @@ -65,6 +114,12 @@ const fileNameFieldOf = (record: VitestTestRecord): { readonly fileName?: string onSome: (fileName) => ({ fileName }), }) +const failureEvidenceOfRecord = ( + record: VitestTestRecord, + projectRoot: string, +): { readonly location?: TestRunner.TestFailureLocation; readonly stack?: string } => + failureEvidenceOf(locationOf(record.errorFrames, projectRoot), record.errorStack) + const resultOf = (record: VitestTestRecord, projectRoot: string): TestResultEncoded => { const base = { id: projectRelativeId(`${fileNameOf(record)}#${testNameOf(record)}`, projectRoot), @@ -75,28 +130,42 @@ const resultOf = (record: VitestTestRecord, projectRoot: string): TestResultEnco return Match.value(testStatusOf(record)).pipe( Match.when( 'failed', - (): TestResultEncoded => ({ ...base, status: 'failed', failureMessage: failureMessageOf(record) }), + (): TestResultEncoded => ({ + ...base, + status: 'failed', + failureMessage: failureMessageOf(record), + ...failureEvidenceOfRecord(record, projectRoot), + }), ), Match.when( 'skipped', (): TestResultEncoded => Option.match(Option.fromNullishOr(record.suiteErrorMessage), { onNone: (): TestResultEncoded => ({ ...base, status: 'skipped' }), - onSome: (failureMessage): TestResultEncoded => ({ ...base, status: 'failed', failureMessage }), + onSome: (failureMessage): TestResultEncoded => ({ + ...base, + status: 'failed', + failureMessage, + ...failureEvidenceOfRecord(record, projectRoot), + }), }), ), Match.orElse((): TestResultEncoded => ({ ...base, status: 'success' })), ) } -const fileFailureResultOf = (failure: VitestFileFailure, projectRoot: string): TestResultEncoded => ({ - id: projectRelativeId(`${failure.fileName}#${failure.fileName}`, projectRoot), - name: failure.fileName, - timeSpentMs: 0, - status: 'failed', - failureMessage: failure.message, - fileName: failure.fileName, -}) +const fileFailureResultOf = (failure: VitestFileFailure, projectRoot: string): TestResultEncoded => { + const file = projectRelativePath(stripProjectRoot(failure.fileName, projectRoot)) + return { + id: `${file}#${file}`, + name: file, + timeSpentMs: 0, + status: 'failed', + failureMessage: failure.message, + fileName: failure.fileName, + ...failureEvidenceOf(locationOf(failure.frames, projectRoot), failure.stack), + } +} export const interpretVitestTestRun = (run: VitestTestRun): readonly TestResultEncoded[] => [ ...run.records.map((record) => resultOf(record, run.projectRoot)), @@ -116,6 +185,46 @@ if (import.meta.vitest !== void 0) { fileFailures: [], }) + type FailedTestResultEncoded = Extract + + const isFailedResult = (only: TestResultEncoded): only is FailedTestResultEncoded => only.status === 'failed' + + const locationOfFailure = ( + only: TestResultEncoded, + ): Option.Option> => + Option.flatMap(Option.liftPredicate(only, isFailedResult), (failed) => Option.fromNullishOr(failed.location)) + + const positionsEqual = ( + location: NonNullable, + line: number, + column: number, + ): boolean => location.line === line && location.column === column + + const locatedAt = (only: TestResultEncoded, file: string, line: number, column: number): boolean => + Option.exists( + locationOfFailure(only), + (location) => location.file === file && positionsEqual(location, line, column), + ) + + const keptStackWithoutLocation = (only: TestResultEncoded, stack: string): boolean => + Option.exists( + Option.liftPredicate(only, isFailedResult), + (failed) => failed.stack === stack && failed.location === undefined, + ) + + const hasFailureMessage = (only: TestResultEncoded): boolean => 'failureMessage' in only + + const hasOptionalEvidence = (only: TestResultEncoded): boolean => 'location' in only || 'stack' in only + + const unfailedShapeIsBare = (only: TestResultEncoded): boolean => + !hasFailureMessage(only) && !hasOptionalEvidence(only) + + const hasWidenedFailureShape = (only: TestResultEncoded): boolean => + Boolean.match(isFailedResult(only), { + onTrue: () => hasFailureMessage(only), + onFalse: () => unfailedShapeIsBare(only), + }) + it.prop( '∀r_TestRecord_≡FullTestNameOrSuitePath', { of: [Records.VitestTestRecord, S.String], subject: interpretVitestTestRun }, @@ -136,11 +245,11 @@ if (import.meta.vitest !== void 0) { ) it.prop( - '∀r_TestRecord_≡FailureMessageIffFailed', + '∀r_TestRecord_≡WidenedFailureShape', { of: [Records.VitestTestRecord, S.String], subject: interpretVitestTestRun }, (subject, [record, projectRoot]) => { const [only] = subject(singleRecordRun(record, projectRoot)) - return (only.status === 'failed') === ('failureMessage' in only) + return hasWidenedFailureShape(only) }, ) @@ -162,12 +271,82 @@ if (import.meta.vitest !== void 0) { }, ) + const FileStem = S.String.check(S.isPattern(/^[a-z]{1,8}$/)) + it.prop( - '∀f_FileFailure_≡NamedAfterTheFile', - { of: [Records.VitestFileFailure, S.String], subject: interpretVitestTestRun }, - (subject, [failure, projectRoot]) => { - const [only] = subject({ projectRoot, records: [], fileFailures: [failure] }) - return only.name === failure.fileName && only.fileName === failure.fileName + '∀f_FileFailure_≡NamedAfterTheProjectRelativeFile', + { of: [FileStem], subject: interpretVitestTestRun }, + (subject, [stem]) => { + const [only] = subject({ + projectRoot: '/project', + records: [], + fileFailures: [{ fileName: `/project/tests/${stem}.spec.ts`, message: 'load failed' }], + }) + return only.name === `tests/${stem}.spec.ts` && only.id === `tests/${stem}.spec.ts#tests/${stem}.spec.ts` + }, + ) + + const SourceOrdinal = S.Int.check(S.isGreaterThanOrEqualTo(1)) + + const failingRecord = (overrides: Partial): VitestTestRecord => ({ + name: 'fails', + suiteNames: [], + ...overrides, + }) + + it.prop( + '∀r_ProjectFrame_≡LocatedThere', + { of: [SourceOrdinal, SourceOrdinal], subject: interpretVitestTestRun }, + (subject, [line, column]) => { + const record = failingRecord({ + fileName: 'src/a.ts', + errorMessage: 'boom', + errorStack: 'Error: boom', + errorFrames: [ + { file: '/project/node_modules/vitest/dist/runner.js', line: 1, column: 1 }, + { file: '/project/src/a.ts', line, column }, + ], + }) + const [only] = subject(singleRecordRun(record, '/project')) + return locatedAt(only, 'src/a.ts', line, column) + }, + ) + + it.prop( + '∀r_NonProjectFrames_≡StackKeptWithoutLocation', + { of: [S.String, SourceOrdinal], subject: interpretVitestTestRun }, + (subject, [stack, line]) => { + const record = failingRecord({ + errorMessage: 'boom', + errorStack: stack, + errorFrames: [ + { file: 'node:internal/timers', line, column: line }, + { file: '/project/node_modules/x/y.js', line, column: line }, + ], + }) + const [only] = subject(singleRecordRun(record, '/project')) + return keptStackWithoutLocation(only, stack) + }, + ) + + it.prop( + '∀f_ProjectFrame_≡LocatedThere', + { of: [SourceOrdinal, SourceOrdinal], subject: interpretVitestTestRun }, + (subject, [line, column]) => { + const [only] = subject({ + projectRoot: '/project', + records: [], + fileFailures: [{ + fileName: 'tests/a.spec.ts', + message: 'load failed', + stack: 'SyntaxError: bad', + frames: [ + { file: '/project/node_modules/vite/dist/x.js', line: 4, column: 2 }, + { file: '/project/tests/a.spec.ts', line, column }, + ], + }], + }) + return locatedAt(only, 'tests/a.spec.ts', line, column) }, ) } diff --git a/packages/stryker-js/etc/stryker-js.api.md b/packages/stryker-js/etc/stryker-js.api.md index e9d0a4e4..1e8994a6 100644 --- a/packages/stryker-js/etc/stryker-js.api.md +++ b/packages/stryker-js/etc/stryker-js.api.md @@ -16,7 +16,7 @@ export namespace Configuration { // @public (undocumented) export namespace Engine { - export { DryRunDone, EnginePorts, IdGenerator, IdGeneratorShape, IncrementalReportSchema, InstrumentDone, MutationTestDone, PrepareDone, PrepareExecutorArgs, Project, ProjectFile, RunEnvironment, RunEnvironmentShape, RunStageServices, SandboxHandle, StageError, StageServices, StrykerError, StrykerRun, TestCoverage, WiredRunLayer, mutationTestCell, nodePlatformLayer, strykerCell }; + export { DryRunDone, EnginePorts, IdGenerator, IdGeneratorShape, IncrementalReportSchema, InstrumentDone, MutationTestDone, PrepareDone, PrepareExecutorArgs, Project, ProjectFile, RunEnvironment, RunEnvironmentShape, RunFailure, RunStageServices, SandboxHandle, StageServices, StrykerError, StrykerRun, TestCoverage, WiredRunLayer, mutationTestCell, nodePlatformLayer, strykerCell }; } // @public (undocumented) diff --git a/packages/stryker-js/src/Checker/Checker.blueprint.ts b/packages/stryker-js/src/Checker/Checker.blueprint.ts index a0609b4e..f580a376 100644 --- a/packages/stryker-js/src/Checker/Checker.blueprint.ts +++ b/packages/stryker-js/src/Checker/Checker.blueprint.ts @@ -52,6 +52,7 @@ const acquire = Effect.fnUntraced(function*(spec: CheckerSpec) { workingDirectory: spec.workingDirectory, execArgv: [...nodeArgsOf(spec.options)], tempDirPrefix: TEMP_DIR_PREFIX, + workerKind: 'checker', }).pipe( Effect.mapError((error) => Match.value(error).pipe( diff --git a/packages/stryker-js/src/Checker/Checker.handle.ts b/packages/stryker-js/src/Checker/Checker.handle.ts index 794edf10..8a63d485 100644 --- a/packages/stryker-js/src/Checker/Checker.handle.ts +++ b/packages/stryker-js/src/Checker/Checker.handle.ts @@ -63,7 +63,7 @@ export interface CheckerResourceService { } export const connectionCrashed = (cause: string): ChildProcessCrashedError => - ChildProcessCrashedError.make({ pid: 0, exit: { _tag: 'Code', code: 1 }, cause }) + ChildProcessCrashedError.make({ pid: 0, exit: { _tag: 'Code', code: 1 }, workerKind: 'checker', cause }) const recordCheckerCall = ( span: SpanTaxonomy.SpanMember, diff --git a/packages/stryker-js/src/Checker/checker-pool.blueprint.ts b/packages/stryker-js/src/Checker/checker-pool.blueprint.ts index 95d2727a..ae40f53a 100644 --- a/packages/stryker-js/src/Checker/checker-pool.blueprint.ts +++ b/packages/stryker-js/src/Checker/checker-pool.blueprint.ts @@ -10,7 +10,7 @@ import type * as Scope from 'effect/Scope' import type { LoadedPlugins } from '../Plugins.schema.js' import { PluginNotFoundError } from '../PluginsError.schema.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { ConfiguredPluginModulePath, resolveConfiguredPlugin, @@ -36,15 +36,15 @@ const CHECKER_ACQUIRE_RETRIES = 2 const checkerWorkerSpawnOf = ( loaded: Pick, configured: ConfiguredPluginModulePath, -): Effect.Effect => +): Effect.Effect => Effect.mapError( Effect.fromResult( resolveConfiguredPlugin(WorkerSpawnCommand.make({ sources: loaded.pluginSources, kind: 'Checker', configured })), ), (missing) => - StageError.make({ - stage: 'mutationTest', - reason: missing.reason, + RunFailure.make({ + evidence: { _tag: 'PluginNotFound', stage: 'config', descriptor: missing.descriptor }, + detail: missing.reason, cause: PluginNotFoundError.make({ descriptor: missing.descriptor }), }), ) diff --git a/packages/stryker-js/src/Checker/checker-pool.handle.ts b/packages/stryker-js/src/Checker/checker-pool.handle.ts index 7e20b105..27d53ee9 100644 --- a/packages/stryker-js/src/Checker/checker-pool.handle.ts +++ b/packages/stryker-js/src/Checker/checker-pool.handle.ts @@ -14,7 +14,7 @@ import * as Scope from 'effect/Scope' import * as Stream from 'effect/Stream' import type { CheckerContractBroken } from '../admit-checker-answer.workflow.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import type { CheckerCrash, CheckerResourceService } from './Checker.handle.js' import { checkPlans as checkPlansWithChecker, groupPlans as groupPlansWithChecker } from './Checker.plans.js' import { @@ -29,7 +29,7 @@ export type TypeId = typeof TypeId export type CheckerSlot = { readonly checkerName: string; readonly checker: CheckerResourceService }[] -export type CheckerPool = Pool.Pool +export type CheckerPool = Pool.Pool export interface CheckedPlans { readonly passedPlans: readonly Mutant.MutantRunPlan[] @@ -44,31 +44,36 @@ export const isCheckerPoolHandle = CheckerPoolHandle.is export const makeCheckerPoolHandle = (pool: CheckerPool): CheckerPoolHandle => CheckerPoolHandle.make({}, pool) -export const isCheckerCrash = (error: StageError | CheckerCrash): boolean => +export const isCheckerCrash = (error: RunFailure | CheckerCrash): boolean => Match.value(error).pipe( Match.tag('ChildProcessCrashedError', 'OutOfMemoryError', () => true), Match.orElse(() => false), ) -const checkerBreachToStageError = (error: CheckerContractBroken | Checker.CheckerFailed): StageError => +const checkerBreachToRunFailure = (error: CheckerContractBroken | Checker.CheckerFailed): RunFailure => Match.value(error).pipe( Match.tag( 'CheckerFailed', - (failed) => StageError.make({ stage: 'mutationTest', reason: failed.cause, cause: failed }), + (failed) => + RunFailure.make({ + evidence: { _tag: 'CheckerFailed', stage: 'check', checker: failed.checkerName }, + detail: failed.cause, + cause: failed, + }), ), Match.tag('CheckerAnsweredUnrequested', (breach) => - StageError.make({ - stage: 'mutationTest', - reason: + RunFailure.make({ + evidence: { _tag: 'CheckerFailed', stage: 'check', checker: breach.checkerName }, + detail: `Checker "${breach.checkerName}" answered about mutants it was not asked about (${breach.phase} phase): ${ breach.unrequestedIds.join(', ') }`, cause: breach, })), Match.tag('CheckerSkippedRequested', (breach) => - StageError.make({ - stage: 'mutationTest', - reason: `Checker "${breach.checkerName}" skipped requested mutants (${breach.phase} phase): ${ + RunFailure.make({ + evidence: { _tag: 'CheckerFailed', stage: 'check', checker: breach.checkerName }, + detail: `Checker "${breach.checkerName}" skipped requested mutants (${breach.phase} phase): ${ breach.missingIds.join(', ') }`, cause: breach, @@ -88,7 +93,7 @@ const onCheckerSlot = ( run: ( checker: CheckerResourceService, ) => Effect.Effect, -): Effect.Effect => +): Effect.Effect => Pool.use(pool, (slot) => Option.match(Option.fromUndefinedOr(slot[checkerIndex]), { onNone: () => Effect.die(new Error(`checker slot has no entry at index ${checkerIndex}`)), @@ -97,9 +102,9 @@ const onCheckerSlot = ( Effect.catchTags({ OutOfMemoryError: (error) => invalidateSlot(pool, slot, error), ChildProcessCrashedError: (error) => invalidateSlot(pool, slot, error), - CheckerFailed: (error) => error.pipe(checkerBreachToStageError, Effect.fail), - CheckerAnsweredUnrequested: (error) => error.pipe(checkerBreachToStageError, Effect.fail), - CheckerSkippedRequested: (error) => error.pipe(checkerBreachToStageError, Effect.fail), + CheckerFailed: (error) => error.pipe(checkerBreachToRunFailure, Effect.fail), + CheckerAnsweredUnrequested: (error) => error.pipe(checkerBreachToRunFailure, Effect.fail), + CheckerSkippedRequested: (error) => error.pipe(checkerBreachToRunFailure, Effect.fail), }), ), })) @@ -135,12 +140,12 @@ export const splitCheckedPlans = Effect.fn(SpanTaxonomy.Spans.checkerPoolSplitCh return { passedPlans, failedChecks } }) -const checkerNamesOf = (pool: CheckerPool): Effect.Effect => +const checkerNamesOf = (pool: CheckerPool): Effect.Effect => Pool.use(pool, (slot) => Effect.succeed(slot.map(({ checkerName }) => checkerName))) const failedElement = ( failedChecks: readonly (readonly [Mutant.MutantRunPlan, Checker.FailedCheckResult])[], -): Stream.Stream => +): Stream.Stream => Option.match(Array.head(failedChecks), { onNone: () => Stream.empty, onSome: () => Stream.succeed({ passedPlans: noPassedPlans, failedChecks }), @@ -151,7 +156,7 @@ const checkedGroupsFor = ( checkerNames: readonly string[], checkerIndex: number, plans: readonly Mutant.MutantRunPlan[], -): Stream.Stream => +): Stream.Stream => Option.match(Array.get(checkerNames, checkerIndex), { onNone: () => Stream.succeed({ passedPlans: plans, failedChecks: noFailedChecks }), onSome: (checkerName) => @@ -182,17 +187,17 @@ const checkedGroupsFor = ( export const checkPlansStream: { ( plans: readonly Mutant.MutantRunPlan[], - ): (checkerPool: CheckerPoolHandle | undefined) => Stream.Stream + ): (checkerPool: CheckerPoolHandle | undefined) => Stream.Stream ( checkerPool: CheckerPoolHandle | undefined, plans: readonly Mutant.MutantRunPlan[], - ): Stream.Stream + ): Stream.Stream } = dual( 2, ( checkerPool: CheckerPoolHandle | undefined, plans: readonly Mutant.MutantRunPlan[], - ): Stream.Stream => + ): Stream.Stream => Option.fromNullishOr(checkerPool).pipe( Option.match({ onNone: () => Stream.succeed({ passedPlans: plans, failedChecks: noFailedChecks }), @@ -230,18 +235,18 @@ export const runCheckedPlans: { ( execution: CheckedPlansExecution, ): ( - self: Stream.Stream, - ) => Stream.Stream + self: Stream.Stream, + ) => Stream.Stream ( - self: Stream.Stream, + self: Stream.Stream, execution: CheckedPlansExecution, - ): Stream.Stream + ): Stream.Stream } = dual( 2, ( - self: Stream.Stream, + self: Stream.Stream, execution: CheckedPlansExecution, - ): Stream.Stream => + ): Stream.Stream => self.pipe( Stream.flatMap(({ passedPlans, failedChecks }) => Stream.fromIterable<() => Effect.Effect>([ diff --git a/packages/stryker-js/src/ConfigError.schema.ts b/packages/stryker-js/src/ConfigError.schema.ts index aec54a94..9b219195 100644 --- a/packages/stryker-js/src/ConfigError.schema.ts +++ b/packages/stryker-js/src/ConfigError.schema.ts @@ -1,3 +1,4 @@ +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import * as S from 'effect/Schema' export class ConfigFileNotFoundError extends S.TaggedError()( @@ -6,7 +7,9 @@ export class ConfigFileNotFoundError extends S.TaggedError()('C message: S.String, cause: S.Defect(), }) { - readonly exitClass = 'ConfigError' as const + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'ConfigInvalid', stage: 'config', detail: this.message } + } } export class ConfigModuleUnloadable extends S.TaggedError()('ConfigModuleUnloadable', { @@ -68,13 +79,17 @@ export class ConfigModuleUnloadable extends S.TaggedError()('ConfigError', { message: S.String, }) { - readonly exitClass = 'ConfigError' as const + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'ConfigInvalid', stage: 'config', detail: this.message } + } } export type ConfigReadError = diff --git a/packages/stryker-js/src/Engine/mod.ts b/packages/stryker-js/src/Engine/mod.ts index f9a9aca9..ce25b245 100644 --- a/packages/stryker-js/src/Engine/mod.ts +++ b/packages/stryker-js/src/Engine/mod.ts @@ -1,7 +1,7 @@ export { nodePlatformLayer } from '../drivers/node.js' export { IncrementalReportSchema } from '../IncrementalReport.schema.js' export type { Project, ProjectFile } from '../Project.schema.js' -export { StageError } from '../Run.schema.js' +export { RunFailure } from '../Run.schema.js' export type { DryRunDone } from '../run/dry-run.cell.js' export type { StrykerRun } from '../run/host.service.js' export type { InstrumentDone } from '../run/instrument.cell.js' diff --git a/packages/stryker-js/src/PluginsError.schema.ts b/packages/stryker-js/src/PluginsError.schema.ts index aed28d19..80b68c32 100644 --- a/packages/stryker-js/src/PluginsError.schema.ts +++ b/packages/stryker-js/src/PluginsError.schema.ts @@ -1,12 +1,14 @@ import { Schema as S } from 'effect' +import * as Predicate from 'effect/Predicate' -import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' -import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' +import { FailureRecord, RunEvent } from '@systemfsoftware/stryker-js-cli-contract' export class PluginNotFoundError extends S.TaggedError()('PluginNotFoundError', { descriptor: S.String, }) { - readonly exitClass = 'ConfigError' as const + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'PluginNotFound', stage: 'config', descriptor: this.descriptor } + } override get message(): string { return `Plugin "${this.descriptor}" was not found` @@ -16,14 +18,6 @@ export class PluginNotFoundError extends S.TaggedError()('P export const PeerFailureTag = S.Literals(['PeerMissing', 'PeerVersionUnsupported', 'PeerUnrecognized']) export type PeerFailureTag = typeof PeerFailureTag.Type -const FAILURE_EXIT_CLASS: Record = { - PeerMissing: 'ConfigError', - PeerVersionUnsupported: 'ConfigError', - PeerUnrecognized: 'ConfigError', - InvalidContribution: 'ConfigError', - ImportFailed: 'InternalError', -} - export class PluginLoadRefusedError extends S.TaggedError()( 'PluginLoadRefusedError', { @@ -31,8 +25,10 @@ export class PluginLoadRefusedError extends S.TaggedError Effect.flatMap(S.decodeEffect(RerunRefused)(refused), (decoded) => Effect.fail(decoded)), - CommandRejected: ({ issue }) => - Effect.fail(RerunRefused.make({ exitClass: 'ConfigError', unknownIds: [], reason: issue })), + CommandRejected: ({ issue }) => Effect.fail(RerunRefused.make({ unknownIds: [], reason: issue })), }) diff --git a/packages/stryker-js/src/Rerun/admit-mutant-rerun.workflow.ts b/packages/stryker-js/src/Rerun/admit-mutant-rerun.workflow.ts index 1fcefd92..2833c35f 100644 --- a/packages/stryker-js/src/Rerun/admit-mutant-rerun.workflow.ts +++ b/packages/stryker-js/src/Rerun/admit-mutant-rerun.workflow.ts @@ -1,4 +1,5 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' import * as Arr from 'effect/Array' import * as Option from 'effect/Option' @@ -29,11 +30,14 @@ export class RerunAdmitted extends S.TaggedClass()('RerunAdmitted } export class RerunRefused extends S.TaggedClass()('RerunRefused', { - exitClass: S.Literal('ConfigError'), unknownIds: S.Array(Mutant.MutantId), reason: S.String, }) { readonly [MutantRerunTypeId] = MutantRerunTypeId + + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'ConfigInvalid', stage: 'config', detail: this.reason } + } } export const MutantRerunAdmission = S.Union([RerunAdmitted, RerunRefused]) @@ -57,7 +61,6 @@ const mutateSpansOf = (command: AdmitMutantRerunCommand): ReadonlyArray const refusalOf = (command: AdmitMutantRerunCommand, unknownIds: ReadonlyArray): RerunRefused => RerunRefused.make({ - exitClass: 'ConfigError', unknownIds, reason: `Unknown mutant id(s): ${ Arr.join(unknownIds, ', ') diff --git a/packages/stryker-js/src/Run.schema.ts b/packages/stryker-js/src/Run.schema.ts index 11bf395e..b1aadbb5 100644 --- a/packages/stryker-js/src/Run.schema.ts +++ b/packages/stryker-js/src/Run.schema.ts @@ -1,46 +1,12 @@ -import type { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' -import * as Match from 'effect/Match' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import * as S from 'effect/Schema' -const TypeId = '~stryker/mutation-run/StageError' as const - -export class StageError extends S.TaggedError(TypeId)('StageError', { - stage: S.Literals(['prepare', 'instrument', 'dryRun', 'dryRunNoTests', 'mutationTest']), - reason: S.String, +export class RunFailure extends S.TaggedError()('RunFailure', { + evidence: FailureRecord.FailureEvidence, + detail: S.String, cause: S.optional(S.Defect()), - command: S.optional(S.String), -}) { - readonly [TypeId] = TypeId - - get exitClass(): Plugin.ExitClass { - return STAGE_PRESENTATION[this.stage].exitClass - } - - override get message(): string { - const failure = `${STAGE_PRESENTATION[this.stage].label} failed: ${this.reason}` - return Match.value(this.command).pipe( - Match.when(Match.nonEmptyString, (present) => `${failure} (command: ${present})`), - Match.orElse(() => failure), - ) - } -} - -const STAGE_PRESENTATION: Record< - StageError['stage'], - { readonly label: string; readonly exitClass: Plugin.ExitClass } -> = { - prepare: { label: 'Prepare', exitClass: 'ConfigError' }, - instrument: { label: 'Instrument', exitClass: 'RuntimeError' }, - dryRun: { label: 'Dry run', exitClass: 'RuntimeError' }, - dryRunNoTests: { label: 'Dry run', exitClass: 'ConfigError' }, - mutationTest: { label: 'Mutation testing', exitClass: 'RuntimeError' }, -} - -export class PrepareError extends S.TaggedError()('PrepareError', { - stage: S.Literal('prepare'), - reason: S.String, }) { override get message(): string { - return this.reason + return this.detail } } diff --git a/packages/stryker-js/src/RunOutcomeCommand.schema.ts b/packages/stryker-js/src/RunOutcomeCommand.schema.ts index bae1c739..c3f6bf61 100644 --- a/packages/stryker-js/src/RunOutcomeCommand.schema.ts +++ b/packages/stryker-js/src/RunOutcomeCommand.schema.ts @@ -1,64 +1,36 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' -import { Plugin, Report } from '@systemfsoftware/stryker-js-plugin-interface' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' +import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as S from 'effect/Schema' export const RunSucceededClean = S.TaggedStruct('RunSucceededClean', {}) export const RunSucceededVerdict = S.TaggedStruct('RunSucceededVerdict', { exitClass: Plugin.ExitClass, - diagnostic: S.NullOr(S.String), }) -export const RunInterruptedObservation = S.TaggedStruct('RunInterruptedObservation', {}) +export const RunHelpObservation = S.TaggedStruct('RunHelpObservation', {}) -export const RunHelpObservation = S.TaggedStruct('RunHelpObservation', { - errorCount: Report.NonNegativeInt, - unrecognized: S.NullOr(S.String), +export const ObservedFailure = S.Struct({ + record: FailureRecord.FailureRecord, + exitCode: Plugin.ExitCode, }) +export type ObservedFailure = typeof ObservedFailure.Type -export const RunCliErrorObservation = S.TaggedStruct('RunCliErrorObservation', { - unrecognized: S.NullOr(S.String), -}) - -export const RunSurvivorsRejectedObservation = S.TaggedStruct('RunSurvivorsRejectedObservation', { - reason: S.Literals(['no-report', 'mismatch']), - diagnostic: S.NullOr(S.String), -}) - -export const RunSchemaErrorObservation = S.TaggedStruct('RunSchemaErrorObservation', { - configDetail: S.NullOr(S.String), -}) - -export const RunClassedObservation = S.TaggedStruct('RunClassedObservation', { - exitClass: Plugin.ExitClass, - configDetail: S.NullOr(S.String), - diagnostic: S.NullOr(S.String), -}) - -export const RunGenericFailureObservation = S.TaggedStruct('RunGenericFailureObservation', { - diagnostic: S.NullOr(S.String), +export const RunFailedObservation = S.TaggedStruct('RunFailedObservation', { + failures: S.NonEmptyArray(ObservedFailure), }) export const RunOutcomeObservation = S.Union([ RunSucceededClean, RunSucceededVerdict, - RunInterruptedObservation, RunHelpObservation, - RunCliErrorObservation, - RunSurvivorsRejectedObservation, - RunSchemaErrorObservation, - RunClassedObservation, - RunGenericFailureObservation, + RunFailedObservation, ]) export type RunOutcomeObservation = typeof RunOutcomeObservation.Type export type RunSucceededVerdict = typeof RunSucceededVerdict.Type -export type RunHelpObservation = typeof RunHelpObservation.Type -export type RunCliErrorObservation = typeof RunCliErrorObservation.Type -export type RunSurvivorsRejectedObservation = typeof RunSurvivorsRejectedObservation.Type -export type RunSchemaErrorObservation = typeof RunSchemaErrorObservation.Type -export type RunClassedObservation = typeof RunClassedObservation.Type -export type RunGenericFailureObservation = typeof RunGenericFailureObservation.Type +export type RunFailedObservation = typeof RunFailedObservation.Type export class RunOutcomeCommand extends S.TaggedClass()('RunOutcomeCommand', { observation: RunOutcomeObservation, diff --git a/packages/stryker-js/src/TestRunner.blueprint.ts b/packages/stryker-js/src/TestRunner.blueprint.ts index bdd4b4d7..29339d75 100644 --- a/packages/stryker-js/src/TestRunner.blueprint.ts +++ b/packages/stryker-js/src/TestRunner.blueprint.ts @@ -107,6 +107,7 @@ const scopedOf: ( workingDirectory: params.sandboxWorkingDirectory, execArgv: [...execArgv], tempDirPrefix: 'stryker-test-runner-', + workerKind: 'testRunner', env: { NODE_ENV: 'test', VITEST: '1', diff --git a/packages/stryker-js/src/Worker.schema.ts b/packages/stryker-js/src/Worker.schema.ts index a7bbdf2a..6e217ff8 100644 --- a/packages/stryker-js/src/Worker.schema.ts +++ b/packages/stryker-js/src/Worker.schema.ts @@ -1,4 +1,5 @@ /// +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { Match, Schema as S } from 'effect' // --------------------------------------------------------------------------- @@ -46,11 +47,11 @@ export class ChildProcessCrashedError extends S.TaggedError ({ exitCode: code, signal: null }), + Signal: ({ signal }) => ({ exitCode: null, signal }), + }) + return { + _tag: 'WorkerCrashed', + stage: 'mutationTest', + workerKind: this.workerKind, + pid: this.pid, + exitCode, + signal, + } + } } export class OutOfMemoryError extends S.TaggedError()('OutOfMemoryError', { pid: ProcessId, exitCode: ChildExitCode, + workerKind: FailureRecord.WorkerKind, }) { readonly [WorkerExitTypeId] = WorkerExitTypeId - readonly exitClass = 'RuntimeError' as const override get message(): string { return `Worker process ${this.pid} ran out of memory (exit code ${this.exitCode})` } + + get evidence(): FailureRecord.FailureEvidence { + return { + _tag: 'WorkerOutOfMemory', + stage: 'mutationTest', + workerKind: this.workerKind, + pid: this.pid, + exitCode: this.exitCode, + } + } } export type WorkerExit = ChildProcessCrashedError | OutOfMemoryError @@ -81,13 +107,21 @@ export class WorkerBootTimeoutError extends S.TaggedError> | undefined } diff --git a/packages/stryker-js/src/__tests__/admit-mutation-test.workflow.property.test.ts b/packages/stryker-js/src/__tests__/admit-mutation-test.workflow.property.test.ts index cce6d6e8..1870f18e 100644 --- a/packages/stryker-js/src/__tests__/admit-mutation-test.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/admit-mutation-test.workflow.property.test.ts @@ -1,15 +1,16 @@ import { describe, it } from '@systemfsoftware/vitest' +import * as Predicate from 'effect/Predicate' import * as Result from 'effect/Result' import * as S from 'effect/Schema' import { admitMutationTest, MutationTestDryRunOnly, - MutationTestError, MutationTestNoTests, MutationTestProceed, } from '../admit-mutation-test.workflow.js' import { MutationTestCommand } from '../MutationTest.schema.js' +import { RunFailure } from '../Run.schema.js' describe('admitMutationTest', () => { it.prop( @@ -18,7 +19,10 @@ describe('admitMutationTest', () => { (subject, [command]) => { const result = subject(command) if (command.testCount < 0) { - return Result.isFailure(result) && S.is(MutationTestError)(result.failure) + return Result.isFailure(result) && + S.is(RunFailure)(result.failure) && + Predicate.isTagged(result.failure.evidence, 'InvariantBroken') && + result.failure.evidence.stage === 'mutationTest' } if (command.dryRunOnly) { return Result.isSuccess(result) && S.is(MutationTestDryRunOnly)(result.success) diff --git a/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts b/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts index c72e5913..9bc8899d 100644 --- a/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts @@ -38,7 +38,7 @@ import { partitionCheckedPlans, PartitionCheckedPlansCommand, } from '../Checker/partition-checked-plans.workflow.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { ChildProcessCrashedError, OutOfMemoryError } from '../Worker.schema.js' type CheckedPlans = readonly (readonly [Mutant.MutantRunPlan, Checker.CheckResult])[] @@ -113,34 +113,34 @@ const checkerSlotOf = (checkerName: string, checker: CheckerResourceService): Ch const checkerSlotPoolOf = ( size: number, - acquire: Effect.Effect, + acquire: Effect.Effect, ): Effect.Effect => - Pool.make({ + Pool.make({ acquire, size, }) -const causeTagOf = (stageError: StageError): string => +const causeTagOf = (runFailure: RunFailure): string => Option.match( - Option.flatMap(Option.fromNullishOr(stageError.cause), S.decodeUnknownOption(S.Struct({ _tag: S.String }))), + Option.flatMap(Option.fromNullishOr(runFailure.cause), S.decodeUnknownOption(S.Struct({ _tag: S.String }))), { onNone: () => 'none', onSome: (decoded) => decoded._tag }, ) -const stageErrorShapeOf = (error: StageError | CheckerCrash) => +const runFailureShapeOf = (error: RunFailure | CheckerCrash) => Match.value(error).pipe( - Match.tag('StageError', (stageError) => ({ - tag: stageError._tag, - stage: stageError.stage, - cause: causeTagOf(stageError), + Match.tag('RunFailure', (runFailure) => ({ + tag: runFailure._tag, + stage: runFailure.evidence.stage, + cause: causeTagOf(runFailure), })), Match.orElse((crash) => ({ tag: crash._tag, stage: 'none', cause: 'none' })), ) -const crashTagOf = (error: StageError | CheckerCrash): string => +const crashTagOf = (error: RunFailure | CheckerCrash): string => Match.value(error).pipe( Match.tag('OutOfMemoryError', () => 'OutOfMemoryError'), Match.tag('ChildProcessCrashedError', () => 'ChildProcessCrashedError'), - Match.orElse(() => 'StageError'), + Match.orElse(() => 'RunFailure'), ) const poolBoundVerdictOf = (observed: { @@ -167,7 +167,7 @@ const crashVerdictOf = (observed: { const breachVerdictOf = ( observed: { readonly tag: string; readonly stage: string; readonly cause: string }, breachTag: string, -) => holds([observed.tag === 'StageError', observed.stage === 'mutationTest', observed.cause === breachTag]) +) => holds([observed.tag === 'RunFailure', observed.stage === 'check', observed.cause === breachTag]) const backgroundReleaseVerdictOf = (observed: { readonly beforeRelease: number @@ -185,8 +185,13 @@ const groupPlansOf = (prefix: string, seed: number) => { const crashOf = (tag: string) => tag === 'OutOfMemoryError' - ? OutOfMemoryError.make({ pid: 1, exitCode: 137 }) - : ChildProcessCrashedError.make({ pid: 2, exit: { _tag: 'Code', code: 1 }, cause: 'the checker died' }) + ? OutOfMemoryError.make({ pid: 1, exitCode: 137, workerKind: 'checker' }) + : ChildProcessCrashedError.make({ + pid: 2, + exit: { _tag: 'Code', code: 1 }, + workerKind: 'checker', + cause: 'the checker died', + }) const resultOf = (outcome: CheckedHistoryEntry['outcome']): Checker.CheckResult => outcome === 'passed' ? { status: 'passed' } : { status: 'compileError', reason: outcome } @@ -294,7 +299,7 @@ describe('checker pool', () => { ) it.effect.prop( - '∀breach_CheckerBreach_≡StageError', + '∀breach_CheckerBreach_≡RunFailure', { of: [S.Literals(['CheckerFailed', 'CheckerAnsweredUnrequested', 'CheckerSkippedRequested'])], subject: checkPlans, @@ -324,7 +329,7 @@ describe('checker pool', () => { }) const pool = yield* checkerSlotPoolOf(1, Effect.succeed(checkerSlotOf('c', checker))) const error = yield* subject(makeCheckerPoolHandle(pool), plans).pipe(Effect.flip) - return breachVerdictOf(stageErrorShapeOf(error), breachTag) + return breachVerdictOf(runFailureShapeOf(error), breachTag) }), ) @@ -353,7 +358,12 @@ describe('checker pool', () => { Effect.gen(function*() { const failed = Effect.andThen( subject(acquire), - Effect.fail(StageError.make({ stage: 'mutationTest', reason: 'the checks failed' })), + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'TestRunnerFailed', stage: 'mutationTest' }, + detail: 'the checks failed', + }), + ), ) const outcome = yield* Effect.scoped(failed).pipe(Effect.exit) const releasedAfter = yield* Ref.get(released) diff --git a/packages/stryker-js/src/__tests__/classify-run-outcome.workflow.property.test.ts b/packages/stryker-js/src/__tests__/classify-run-outcome.workflow.property.test.ts index 08007a47..70fafdc8 100644 --- a/packages/stryker-js/src/__tests__/classify-run-outcome.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/classify-run-outcome.workflow.property.test.ts @@ -1,103 +1,103 @@ +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { describe, it } from '@systemfsoftware/vitest' -import * as Match from 'effect/Match' +import * as Arr from 'effect/Array' +import * as Cause from 'effect/Cause' +import * as Exit from 'effect/Exit' +import * as Predicate from 'effect/Predicate' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -import { - classifyRunOutcome, - RunConfigFailed, - RunFailed, - RunInterrupted, - RunOk, - type RunOutcomeDecision, - type RunOutcomeError, - RunParseFailed, - RunSurvivorsRejected, -} from '../classify-run-outcome.workflow.js' -import { RunOutcomeCommand } from '../RunOutcomeCommand.schema.js' +import { classifyRunOutcome, RunFailed, type RunOutcomeDecision } from '../classify-run-outcome.workflow.js' +import { runOutcomeCommandOf } from '../conclude-run.js' -const classCode = (exitClass: 'VerdictFail' | 'ConfigError' | 'RuntimeError' | 'InternalError'): number => { - if (exitClass === 'VerdictFail') { - return 1 - } - if (exitClass === 'ConfigError') { - return 2 - } - if (exitClass === 'RuntimeError') { - return 3 - } - return 4 +const CWD = '/project' +const ARGS = ['run', '--dryRunOnly'] + +const outcomeOf = (cause: Cause.Cause): RunOutcomeDecision => + Result.getOrElse( + classifyRunOutcome(runOutcomeCommandOf({ exit: Exit.failCause(cause), argv: ARGS, cwd: CWD, traceId: null })), + (impossible) => impossible, + ) + +const failedOf = (outcome: RunOutcomeDecision): RunFailed | undefined => S.is(RunFailed)(outcome) ? outcome : undefined + +const errorChainOf = (messages: Arr.NonEmptyReadonlyArray, innermost: object): object => + Arr.reduce( + [...messages].reverse(), + innermost, + (inner, message) => Object.assign(new Error(message), { cause: inner }), + ) + +const plainErrorChainOf = (messages: Arr.NonEmptyReadonlyArray): Error => { + const [innermost, ...outer] = [...messages].reverse() + return Arr.reduce( + outer, + new Error(innermost), + (inner, message) => Object.assign(new Error(message), { cause: inner }), + ) } -const isRunFailed = ( - result: Result.Result, - code: number, - diagnostic: string | undefined, -): boolean => - Result.isSuccess(result) && - S.is(RunFailed)(result.success) && - result.success.code === code && - result.success.diagnostic === diagnostic +const PRECEDENCE_LOW_TO_HIGH: ReadonlyArray = [130, 1, 2, 5, 3, 4] + +const rankOf = (code: FailureRecord.FailureCode): number => + PRECEDENCE_LOW_TO_HIGH.indexOf(FailureRecord.FailureCatalog[code].exitCode ?? 4) + +const shortChain = () => S.NonEmptyArray(S.String).check(S.isMaxLength(8)) describe('classifyRunOutcome', () => { it.prop( - '∀c_Command_≡TaggedOutcome', - { of: [RunOutcomeCommand], subject: classifyRunOutcome }, - (subject, [command]) => { - const result = subject(command) - return Match.value(command.observation).pipe( - Match.tag( - 'RunSucceededClean', - () => Result.isSuccess(result) && S.is(RunOk)(result.success) && result.success.help === false, - ), - Match.tag( - 'RunSucceededVerdict', - (observation) => isRunFailed(result, classCode(observation.exitClass), observation.diagnostic ?? undefined), - ), - Match.tag( - 'RunInterruptedObservation', - () => Result.isFailure(result) && S.is(RunInterrupted)(result.failure) && result.failure.code === 130, - ), - Match.tag('RunHelpObservation', (observation) => - observation.errorCount > 0 - ? Result.isSuccess(result) && - S.is(RunParseFailed)(result.success) && - result.success.unrecognized === (observation.unrecognized ?? undefined) - : Result.isSuccess(result) && S.is(RunOk)(result.success) && result.success.help === true), - Match.tag( - 'RunCliErrorObservation', - (observation) => - Result.isSuccess(result) && - S.is(RunParseFailed)(result.success) && - result.success.unrecognized === (observation.unrecognized ?? undefined), - ), - Match.tag( - 'RunSurvivorsRejectedObservation', - (observation) => - Result.isSuccess(result) && - S.is(RunSurvivorsRejected)(result.success) && - result.success.reason === observation.reason && - result.success.diagnostic === (observation.diagnostic ?? undefined), - ), - Match.tag( - 'RunSchemaErrorObservation', - (observation) => - Result.isSuccess(result) && - S.is(RunConfigFailed)(result.success) && - result.success.detail === (observation.configDetail ?? undefined), - ), - Match.tag('RunClassedObservation', (observation) => - observation.exitClass === 'ConfigError' - ? Result.isSuccess(result) && - S.is(RunConfigFailed)(result.success) && - result.success.detail === (observation.configDetail ?? undefined) - : isRunFailed(result, classCode(observation.exitClass), observation.diagnostic ?? undefined)), - Match.tag( - 'RunGenericFailureObservation', - (observation) => isRunFailed(result, 1, observation.diagnostic ?? undefined), - ), - Match.exhaustive, - ) + '∀evidence_RunOutcome_≡RecordOfTheDeclaredEvidenceWithItsCatalogExitCode', + { of: [FailureRecord.FailureEvidence, shortChain()], subject: outcomeOf }, + (subject, [evidence, wrappers]) => { + const failed = failedOf(subject(Cause.fail(errorChainOf(wrappers, { evidence })))) + const facts = FailureRecord.FailureCatalog[evidence._tag] + return failed !== undefined && Predicate.isTagged(failed.record, evidence._tag) && + failed.record.stage === evidence.stage && failed.code === (facts.exitCode ?? 4) && + failed.record.nextAction.primary === facts.nextAction.primary && + failed.record.nextAction.otherwise === facts.nextAction.otherwise && + failed.record.cause.length === wrappers.length + 1 + }, + ) + + it.prop( + '∀messages_RunOutcome_≡UnclassifiedFailureIsACatalogGapCarryingEveryMessage', + { of: [shortChain()], subject: outcomeOf }, + (subject, [messages]) => { + const failed = failedOf(subject(Cause.fail(plainErrorChainOf(messages)))) + return failed !== undefined && failed.code === 4 && Predicate.isTagged(failed.record, 'CatalogGap') && + failed.record.cause.map((link) => link.message).join('\u0000') === messages.join('\u0000') + }, + ) + + it.prop( + '∀first,second_RunOutcome_≡TheMoreSevereFailureDecidesTheRun', + { of: [FailureRecord.FailureEvidence, FailureRecord.FailureEvidence], subject: outcomeOf }, + (subject, [first, second]) => { + const failed = failedOf(subject(Cause.combine(Cause.fail({ evidence: first }), Cause.fail({ evidence: second })))) + const expected = rankOf(second._tag) > rankOf(first._tag) ? second : first + return failed !== undefined && Predicate.isTagged(failed.record, expected._tag) + }, + ) + + it.prop( + '∀evidence_RunOutcome_≡ReplayableRecordsReplayTheSameCommandAndOthersSayWhyNot', + { of: [FailureRecord.FailureEvidence], subject: outcomeOf }, + (subject, [evidence]) => { + const capsule = failedOf(subject(Cause.fail({ evidence })))?.record.capsule + const rule = FailureRecord.FailureCatalog[evidence._tag].capsule + return rule === 'replays' + ? Predicate.isTagged(capsule, 'Replays') && capsule.cwd === CWD && + capsule.argv.join(' ') === ['stryker', ...ARGS].join(' ') + : Predicate.isTagged(capsule, 'DoesNotReplay') && capsule.why === rule + }, + ) + + it.prop( + '∀fiber_RunOutcome_≡AnInterruptOnlyRunExits130WithARunInterruptedRecord', + { of: [S.Int.check(S.isGreaterThanOrEqualTo(0))], subject: outcomeOf }, + (subject, [fiberId]) => { + const failed = failedOf(subject(Cause.interrupt(fiberId))) + return failed?.code === 130 && Predicate.isTagged(failed.record, 'RunInterrupted') }, ) }) diff --git a/packages/stryker-js/src/__tests__/dry-run.workflow.property.test.ts b/packages/stryker-js/src/__tests__/dry-run.workflow.property.test.ts index 13e870a5..68dddb7b 100644 --- a/packages/stryker-js/src/__tests__/dry-run.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/dry-run.workflow.property.test.ts @@ -1,9 +1,11 @@ import { describe, it } from '@systemfsoftware/vitest' import * as Equal from 'effect/Equal' +import * as Predicate from 'effect/Predicate' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -import { dryRun, DryRunCommand, DryRunError, DryRunFailed, DryRunPassed } from '../dry-run.workflow.js' +import { dryRun, DryRunCommand, DryRunFailed, DryRunPassed } from '../dry-run.workflow.js' +import { RunFailure } from '../Run.schema.js' describe('dryRun', () => { it.prop( @@ -14,22 +16,25 @@ describe('dryRun', () => { if (command.status === 'Error') { return ( Result.isFailure(result) && - S.is(DryRunError)(result.failure) && - result.failure.stage === 'dryRun' + S.is(RunFailure)(result.failure) && + Predicate.isTagged(result.failure.evidence, 'BaselineErrored') && + result.failure.evidence.stage === 'dryRun' ) } if (command.status === 'Timeout') { return ( Result.isFailure(result) && - S.is(DryRunError)(result.failure) && - result.failure.stage === 'dryRun' + S.is(RunFailure)(result.failure) && + Predicate.isTagged(result.failure.evidence, 'BaselineTimedOut') && + result.failure.evidence.stage === 'dryRun' ) } if (command.testCount === 0 && command.allowEmpty === false) { return ( Result.isFailure(result) && - S.is(DryRunError)(result.failure) && - result.failure.stage === 'dryRunNoTests' + S.is(RunFailure)(result.failure) && + Predicate.isTagged(result.failure.evidence, 'BaselineFoundNoTests') && + result.failure.evidence.stage === 'dryRun' ) } if (command.failedTestCount > 0) { diff --git a/packages/stryker-js/src/__tests__/interpret-dry-run-observation.workflow.property.test.ts b/packages/stryker-js/src/__tests__/interpret-dry-run-observation.workflow.property.test.ts index bfe8d9c7..504273fd 100644 --- a/packages/stryker-js/src/__tests__/interpret-dry-run-observation.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/interpret-dry-run-observation.workflow.property.test.ts @@ -1,3 +1,4 @@ +import { TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' import { describe, it } from '@systemfsoftware/vitest' import * as Arbitrary from 'effect/Arbitrary' import * as Result from 'effect/Result' @@ -15,7 +16,7 @@ const observationArb = Arbitrary.schema(DryRunObservation) describe('interpretDryRunObservation', () => { it.prop( - '∀o_Complete_≡CountsTestsAndFailures', + '∀o_Complete_≡CarriesFailedTestEvidenceInOrder', { of: [observationArb], subject: interpretDryRunObservation }, (subject, [observation]) => Result.match(subject(observation), { @@ -24,16 +25,30 @@ describe('interpretDryRunObservation', () => { if (observation.dryRunResult.status !== 'complete') { return true } - const failed = observation.dryRunResult.tests.filter((test) => test.status === 'failed') + const failed = observation.dryRunResult.tests.filter( + (test): test is TestRunner.FailedTestResult => test.status === 'failed', + ) return S.is(DryRunObservedComplete)(decision) && decision.testCount === observation.dryRunResult.tests.length && decision.failedTestCount === failed.length && decision.failedTests.length === failed.length && - decision.failedTests.every((summary, index) => { + decision.failedTests.every((evidence, index) => { const expected = failed[index] - return expected !== undefined && - summary.name === expected.name && - summary.failureMessage === expected.failureMessage + if (expected === undefined) { + return false + } + const location = expected.location + return evidence.id === expected.id && + evidence.name === expected.name && + evidence.file === (expected.fileName ?? null) && + (location === undefined + ? evidence.location === null + : evidence.location !== null && + evidence.location.file === location.file && + evidence.location.line === location.line && + evidence.location.column === location.column) && + evidence.message === expected.failureMessage && + evidence.stack === (expected.stack ?? null) }) }, }), diff --git a/packages/stryker-js/src/__tests__/plan-run-conclusion.workflow.property.test.ts b/packages/stryker-js/src/__tests__/plan-run-conclusion.workflow.property.test.ts index 0905586e..7277cdaf 100644 --- a/packages/stryker-js/src/__tests__/plan-run-conclusion.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/plan-run-conclusion.workflow.property.test.ts @@ -1,5 +1,5 @@ +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { describe, it } from '@systemfsoftware/vitest' -import * as Equal from 'effect/Equal' import * as Match from 'effect/Match' import * as Result from 'effect/Result' import * as S from 'effect/Schema' @@ -8,28 +8,12 @@ import { planRunConclusion, PlanRunConclusionCommand, type PlanRunConclusionDecision, - RunConclusionEmittedFailed, RunConclusionEmittedOk, - RunConclusionQuietFailed, + RunConclusionFailed, RunConclusionQuietOk, + RunConclusionVerdictFailed, } from '../plan-run-conclusion.workflow.js' -type ConclusionVariant = 'emitted-ok' | 'emitted-failed' | 'quiet-ok' | 'quiet-failed' - -const variantOf = (machine: boolean, exitCode: number): ConclusionVariant => - machine - ? (exitCode === 0 ? 'emitted-ok' : 'emitted-failed') - : (exitCode === 0 ? 'quiet-ok' : 'quiet-failed') - -const isVariant = (variant: ConclusionVariant, decision: PlanRunConclusionDecision): boolean => - Match.value(variant).pipe( - Match.when('emitted-ok', () => S.is(RunConclusionEmittedOk)(decision)), - Match.when('emitted-failed', () => S.is(RunConclusionEmittedFailed)(decision)), - Match.when('quiet-ok', () => S.is(RunConclusionQuietOk)(decision)), - Match.when('quiet-failed', () => S.is(RunConclusionQuietFailed)(decision)), - Match.exhaustive, - ) - const decisionOf = ( subject: typeof planRunConclusion, command: PlanRunConclusionCommand, @@ -39,33 +23,32 @@ const decisionOf = ( onSuccess: (decision) => decision, }) -const commandCarried = (command: PlanRunConclusionCommand, decision: PlanRunConclusionDecision): boolean => - Match.value(variantOf(command.machine, command.exitCode)).pipe( - Match.when( - 'emitted-ok', - () => S.is(RunConclusionEmittedOk)(decision) && Equal.equals(decision.command, command.command), +const sameRecord = (left: FailureRecord.FailureRecord, right: FailureRecord.FailureRecord): boolean => + S.toEquivalence(FailureRecord.FailureRecord)(left, right) + +const concludesAsPlanned = (command: PlanRunConclusionCommand, decision: PlanRunConclusionDecision): boolean => + Match.value(command.decision).pipe( + Match.tag( + 'RunOk', + () => command.machine ? S.is(RunConclusionEmittedOk)(decision) : S.is(RunConclusionQuietOk)(decision), ), - Match.when('emitted-failed', () => - S.is(RunConclusionEmittedFailed)(decision) && - Equal.equals(decision.command, command.command) && - decision.exitCode === command.exitCode), - Match.when('quiet-ok', () => S.is(RunConclusionQuietOk)(decision)), - Match.when( - 'quiet-failed', - () => S.is(RunConclusionQuietFailed)(decision) && decision.exitCode === command.exitCode, + Match.tag( + 'RunVerdictFailed', + (failed) => S.is(RunConclusionVerdictFailed)(decision) && decision.exitCode === failed.code, ), + Match.tag('RunFailed', (failed) => + S.is(RunConclusionFailed)(decision) && decision.exitCode === failed.code && + sameRecord(decision.record, failed.record)), Match.exhaustive, ) describe('planRunConclusion', () => { it.prop( - '∀command_Plan_≡VariantAndEffectFollowMachineAndExitCode', + '∀command_Plan_≡AFailedRunConcludesWithItsRecordInEveryModeAndOnlyMachineModeEmitsSuccess', { of: [PlanRunConclusionCommand], subject: planRunConclusion }, (subject, [command]) => { const decision = decisionOf(subject, command) - return decision !== undefined && - isVariant(variantOf(command.machine, command.exitCode), decision) && - commandCarried(command, decision) + return decision !== undefined && concludesAsPlanned(command, decision) }, ) }) diff --git a/packages/stryker-js/src/__tests__/run-failure.workflow.property.test.ts b/packages/stryker-js/src/__tests__/run-failure.workflow.property.test.ts deleted file mode 100644 index 7240afe2..00000000 --- a/packages/stryker-js/src/__tests__/run-failure.workflow.property.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it } from '@systemfsoftware/vitest' -import * as Match from 'effect/Match' -import * as S from 'effect/Schema' - -import { - RunConfigFailed, - RunFailed, - RunInterrupted, - RunOk, - RunParseFailed, - RunSurvivorsRejected, -} from '../classify-run-outcome.workflow.js' -import { exitCodeOf } from '../reporting/run-failure.js' - -const FROZEN_CONFIG_CODE = 2 - -describe('exitCodeOf', () => { - it.prop( - '∀outcome_ExitCode_≡FrozenCodes', - { - of: [ - S.Union([ - RunOk, - RunInterrupted, - RunParseFailed, - RunSurvivorsRejected, - RunConfigFailed, - RunFailed, - ]), - ], - subject: exitCodeOf, - }, - (subject, [outcome]) => - Match.value(outcome).pipe( - Match.tag('RunOk', () => subject(outcome) === 0), - Match.tag('RunInterrupted', (interrupted) => subject(outcome) === interrupted.code), - Match.tag('RunFailed', (failed) => subject(outcome) === failed.code), - Match.orElse(() => subject(outcome) === FROZEN_CONFIG_CODE), - ), - ) -}) diff --git a/packages/stryker-js/src/admit-mutation-test.workflow.ts b/packages/stryker-js/src/admit-mutation-test.workflow.ts index e887506d..4f101f98 100644 --- a/packages/stryker-js/src/admit-mutation-test.workflow.ts +++ b/packages/stryker-js/src/admit-mutation-test.workflow.ts @@ -4,15 +4,7 @@ import * as Result from 'effect/Result' import * as S from 'effect/Schema' import { MutationTestCommand } from './MutationTest.schema.js' - -export class MutationTestError extends S.TaggedError()('MutationTestError', { - stage: S.Literal('mutationTest'), - reason: S.String, -}) { - override get message(): string { - return this.reason - } -} +import { RunFailure } from './Run.schema.js' const MutationTestDecisionTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-js/MutationTestDecision') type MutationTestDecisionTypeId = typeof MutationTestDecisionTypeId @@ -31,7 +23,7 @@ export class MutationTestNoTests extends S.TaggedClass()('M export type MutationTestDecision = MutationTestProceed | MutationTestDryRunOnly | MutationTestNoTests -const decide = (command: MutationTestCommand): Result.Result => +const decide = (command: MutationTestCommand): Result.Result => Match.value({ invalid: command.testCount < 0, dryRunOnly: command.dryRunOnly, @@ -39,9 +31,13 @@ const decide = (command: MutationTestCommand): Result.Result - Result.fail(MutationTestError.make({ stage: 'mutationTest', reason: 'Invalid test count' }))), - Match.when({ dryRunOnly: true }, () => - Result.succeed(MutationTestDryRunOnly.make({}))), + Result.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, + detail: 'Invalid test count', + }), + )), + Match.when({ dryRunOnly: true }, () => Result.succeed(MutationTestDryRunOnly.make({}))), Match.when({ isZero: true, allowEmpty: true }, () => Result.succeed(MutationTestNoTests.make({}))), Match.orElse(() => Result.succeed(MutationTestProceed.make({}))), ) @@ -49,6 +45,6 @@ const decide = (command: MutationTestCommand): Result.Result()('Sur }) { readonly [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'SurvivorsUnavailable', stage: 'config', reason: this.reason } + } + override get message(): string { return this.remediation } diff --git a/packages/stryker-js/src/bin/main.ts b/packages/stryker-js/src/bin/main.ts index e34a71f2..ee5bb4c0 100755 --- a/packages/stryker-js/src/bin/main.ts +++ b/packages/stryker-js/src/bin/main.ts @@ -7,7 +7,7 @@ import { AggregationTemporalityPreference, OTLPMetricExporter } from '@opentelem import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' -import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import { HtmlReporter } from '@systemfsoftware/stryker-js-html-reporter' import cliPkgJson from '@systemfsoftware/stryker-js/package.json' with { type: 'json' } import * as Boolean from 'effect/Boolean' @@ -21,6 +21,7 @@ import * as Console from 'effect/Console' import * as EffectDuration from 'effect/Duration' import * as Effect from 'effect/Effect' import * as Exit from 'effect/Exit' +import * as FileSystem from 'effect/FileSystem' import * as Layer from 'effect/Layer' import * as Logger from 'effect/Logger' import * as Match from 'effect/Match' @@ -33,16 +34,15 @@ import * as Scope from 'effect/Scope' import * as Stdio from 'effect/Stdio' import { inheritableCompileCacheDirectory } from './enable-compile-cache.js' -import { classifyRunOutcome, RunExit, RunParseFailed } from '../classify-run-outcome.workflow.js' +import { classifyRunOutcome, RunExit, type RunOutcomeDecision } from '../classify-run-outcome.workflow.js' import { concludeRunCell } from '../conclude-run.cell.js' import { runOutcomeCommandOf } from '../conclude-run.js' import { makeNodePlatformLayer } from '../drivers/node.js' import { OutputModeProbe, OutputModeProbeLive } from '../output-mode-probe.service.js' import type { ResolvedMode } from '../output-mode.schema.js' -import { FailedRunOutcomeSchema } from '../plan-run-conclusion.workflow.js' import { environmentParentContext } from '../reporter-stream.service.js' import { MachineConsole } from '../reporting/machine-console.service.js' -import { errorEnvelopeFromOutcome, runExitCodeFromOutcome } from '../reporting/run-failure.js' +import { runExitCodeFromOutcome } from '../reporting/run-failure.js' import { RunEventDrain, RunEventStreamPort, RunEventStreamPortTag } from '../run-event-stream.service.js' import { type CliAnswer, type CliEnvironment } from '../run-request.cell.js' import { RunEnvironment } from '../run/RunEnvironment.service.js' @@ -118,9 +118,11 @@ const otlpTelemetryLayer = (options: { ) } +const tracingEnabled = Config.Boolean('OTEL_ENABLED').pipe(Config.withDefault(false)) + const telemetryLayer: Layer.Layer = Layer.unwrap( Effect.all([ - Config.Boolean('OTEL_ENABLED').pipe(Config.withDefault(false)), + tracingEnabled, Config.String('OTEL_SERVICE_NAME').pipe(Config.withDefault('stryker-js')), Config.String('OTEL_EXPORTER_OTLP_ENDPOINT').pipe(Config.withDefault('http://127.0.0.1:4318')), Config.Number('OTEL_METRIC_EXPORT_INTERVAL').pipe(Config.withDefault(DEFAULT_METRIC_EXPORT_INTERVAL_MILLIS)), @@ -168,7 +170,7 @@ const cliLayer = Layer.mergeAll( NodeTerminal.layer, ).pipe(Layer.provideMerge(nodePlatform)) -const USAGE_EXIT_CODE = runExitCodeFromOutcome(RunParseFailed.make({})).code +const USAGE_EXIT_CODE = FailureRecord.FailureCatalog.ArgumentsInvalid.exitCode const PROTOCOL_SERVER_SUBCOMMANDS: ReadonlyArray = ['serve', 'mcp'] @@ -180,11 +182,23 @@ const servedInvocation = (argv: ReadonlyArray): boolean => (subcommand) => PROTOCOL_SERVER_SUBCOMMANDS.some((served) => served === subcommand), ) -const SPAN_ERROR_LIMIT = 1024 -const TRUNCATION_SUFFIX = '…[truncated]' +const exportedTraceId = Effect.gen(function*() { + const enabled = yield* Effect.orElseSucceed(tracingEnabled, () => false) + const span = yield* Effect.option(Effect.currentSpan) + return Option.getOrNull( + Option.flatMap( + Option.filter(span, () => enabled), + (active) => S.decodeOption(FailureRecord.TraceId)(active.traceId), + ), + ) +}) -const boundedErrorText = (text: string): string => - text.length > SPAN_ERROR_LIMIT ? text.slice(0, SPAN_ERROR_LIMIT) + TRUNCATION_SUFFIX : text +const failureCodeAttributeOf = (decision: RunOutcomeDecision): Record => + Match.valueTags(decision, { + RunOk: () => ({}), + RunVerdictFailed: () => ({}), + RunFailed: ({ record }) => ({ 'stryker.failure.code': record._tag }), + }) const strykerProgram = Effect.gen(function*() { const stdio = yield* Stdio.Stdio @@ -240,35 +254,26 @@ const strykerProgram = Effect.gen(function*() { ), ), ) - const conclusionCommand = runOutcomeCommandOf({ exit, argv: args }) - const outcome = classifyRunOutcome(conclusionCommand) - const classified = Result.getOrElse( - outcome, - (interrupted) => interrupted, - ) - const machineConsoleService = yield* MachineConsole - const errorText = Option.getOrElse( - Option.map( - Option.liftPredicate(S.is(FailedRunOutcomeSchema))(classified), - (failure) => - boundedErrorText( - errorEnvelopeFromOutcome({ error: failure, captured: machineConsoleService.read() }).error, - ), - ), - () => '', - ) + const conclusionCommand = runOutcomeCommandOf({ + exit, + argv: args, + cwd: host.basePath, + traceId: yield* exportedTraceId, + }) + const decision = Result.getOrElse(classifyRunOutcome(conclusionCommand), (impossible) => impossible) yield* Effect.annotateCurrentSpan({ - 'stryker.run.outcome': classified._tag, - 'stryker.run.exit_code': runExitCodeFromOutcome(classified).code, - 'stryker.run.error': errorText, + 'stryker.run.outcome': decision._tag, + 'stryker.run.exit_code': runExitCodeFromOutcome(decision).code, + ...failureCodeAttributeOf(decision), }) return yield* concludeRunCell.run({ mode, stream, basePath: host.basePath, pathService, + fileSystem: yield* FileSystem.FileSystem, runEvents, - concluded: { command: conclusionCommand, outcome, error: errorText }, + decision, }) }), ) diff --git a/packages/stryker-js/src/classify-run-outcome.workflow.ts b/packages/stryker-js/src/classify-run-outcome.workflow.ts index 319e809e..f3a046f6 100644 --- a/packages/stryker-js/src/classify-run-outcome.workflow.ts +++ b/packages/stryker-js/src/classify-run-outcome.workflow.ts @@ -1,21 +1,18 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' -import * as Boolean from 'effect/Boolean' +import * as Arr from 'effect/Array' import * as Match from 'effect/Match' -import * as Option from 'effect/Option' +import * as Order from 'effect/Order' import * as Result from 'effect/Result' import * as Runtime from 'effect/Runtime' import * as S from 'effect/Schema' import { - RunClassedObservation, - RunCliErrorObservation, - RunGenericFailureObservation, - RunHelpObservation, + type ObservedFailure, + type RunFailedObservation, RunOutcomeCommand, - RunSchemaErrorObservation, - RunSucceededVerdict, - RunSurvivorsRejectedObservation, + type RunSucceededVerdict, } from './RunOutcomeCommand.schema.js' export class RunExit extends S.TaggedError()('RunExit', { code: Plugin.ExitCode }) { @@ -28,17 +25,6 @@ export class RunExit extends S.TaggedError()('RunExit', { code: Plugin. } } -const CONFIG_CODE = 2 - -const classCode = (exitClass: Plugin.ExitClass): number => - Match.value(exitClass).pipe( - Match.when('VerdictFail', () => 1), - Match.when('ConfigError', () => CONFIG_CODE), - Match.when('RuntimeError', () => 3), - Match.when('InternalError', () => 4), - Match.exhaustive, - ) - const RunOutcomeTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-js/RunOutcome') type RunOutcomeTypeId = typeof RunOutcomeTypeId @@ -48,117 +34,63 @@ export class RunOk extends S.TaggedClass()('RunOk', { readonly [RunOutcomeTypeId] = RunOutcomeTypeId } -export class RunInterrupted extends S.TaggedError()('RunInterrupted', { +export class RunVerdictFailed extends S.TaggedClass()('RunVerdictFailed', { code: Plugin.ExitCode, -}) { - readonly [RunOutcomeTypeId] = RunOutcomeTypeId - - override get message(): string { - return `Run interrupted by a signal (exit code ${this.code})` - } -} - -export class RunParseFailed extends S.TaggedClass()('RunParseFailed', { - unrecognized: S.optional(S.String), -}) { - readonly [RunOutcomeTypeId] = RunOutcomeTypeId -} - -export class RunSurvivorsRejected extends S.TaggedClass()('RunSurvivorsRejected', { - reason: S.Literals(['no-report', 'mismatch']), - diagnostic: S.optional(S.String), -}) { - readonly [RunOutcomeTypeId] = RunOutcomeTypeId -} - -export class RunConfigFailed extends S.TaggedClass()('RunConfigFailed', { - detail: S.optional(S.String), }) { readonly [RunOutcomeTypeId] = RunOutcomeTypeId } export class RunFailed extends S.TaggedClass()('RunFailed', { code: Plugin.ExitCode, - diagnostic: S.optional(S.String), + record: FailureRecord.FailureRecord, }) { readonly [RunOutcomeTypeId] = RunOutcomeTypeId } -export type RunOutcomeDecision = - | RunOk - | RunParseFailed - | RunSurvivorsRejected - | RunConfigFailed - | RunFailed - -export type RunOutcomeError = RunInterrupted - -export type FailedRunOutcome = Exclude | RunOutcomeError - -const succeededCleanOutcome = (): RunOutcomeDecision => RunOk.make({ help: false }) - -const succeededVerdictOutcome = (observation: RunSucceededVerdict): RunOutcomeDecision => - RunFailed.make({ - code: classCode(observation.exitClass), - diagnostic: Option.getOrUndefined(Option.fromNullishOr(observation.diagnostic)), - }) - -const helpOutcome = (observation: RunHelpObservation): RunOutcomeDecision => - Boolean.match(observation.errorCount > 0, { - onTrue: () => - RunParseFailed.make({ unrecognized: Option.getOrUndefined(Option.fromNullishOr(observation.unrecognized)) }), - onFalse: () => RunOk.make({ help: true }), - }) - -const cliErrorOutcome = (observation: RunCliErrorObservation): RunOutcomeDecision => - RunParseFailed.make({ unrecognized: Option.getOrUndefined(Option.fromNullishOr(observation.unrecognized)) }) - -const survivorsRejectedOutcome = (observation: RunSurvivorsRejectedObservation): RunOutcomeDecision => - RunSurvivorsRejected.make({ - reason: observation.reason, - diagnostic: Option.getOrUndefined(Option.fromNullishOr(observation.diagnostic)), - }) - -const schemaErrorOutcome = (observation: RunSchemaErrorObservation): RunOutcomeDecision => - RunConfigFailed.make({ detail: Option.getOrUndefined(Option.fromNullishOr(observation.configDetail)) }) - -const classedOutcome = (observation: RunClassedObservation): RunOutcomeDecision => - Match.value(observation.exitClass).pipe( - Match.when( - 'ConfigError', - () => RunConfigFailed.make({ detail: Option.getOrUndefined(Option.fromNullishOr(observation.configDetail)) }), - ), - Match.orElse((exitClass) => - RunFailed.make({ - code: classCode(exitClass), - diagnostic: Option.getOrUndefined(Option.fromNullishOr(observation.diagnostic)), - }) - ), +export const RunOutcomeDecision = S.Union([RunOk, RunVerdictFailed, RunFailed]) +export type RunOutcomeDecision = typeof RunOutcomeDecision.Type + +const verdictCode = (exitClass: Plugin.ExitClass): Plugin.ExitCode => + Match.value(exitClass).pipe( + Match.when('VerdictFail', () => 1), + Match.when('ConfigError', () => 2), + Match.when('RuntimeError', () => 3), + Match.when('InternalError', () => 4), + Match.exhaustive, ) -const genericFailureOutcome = (observation: RunGenericFailureObservation): RunOutcomeDecision => - RunFailed.make({ code: 1, diagnostic: Option.getOrUndefined(Option.fromNullishOr(observation.diagnostic)) }) - -const decide = (command: RunOutcomeCommand): Result.Result => - Match.value(command.observation).pipe( - Match.tag('RunSucceededClean', () => Result.succeed(succeededCleanOutcome())), - Match.tag('RunSucceededVerdict', (observation) => Result.succeed(succeededVerdictOutcome(observation))), - Match.tag('RunInterruptedObservation', () => Result.fail(RunInterrupted.make({ code: 130 }))), - Match.tag('RunHelpObservation', (observation) => Result.succeed(helpOutcome(observation))), - Match.tag('RunCliErrorObservation', (observation) => Result.succeed(cliErrorOutcome(observation))), - Match.tag( - 'RunSurvivorsRejectedObservation', - (observation) => Result.succeed(survivorsRejectedOutcome(observation)), +const PRECEDENCE_LOW_TO_HIGH: ReadonlyArray = [130, 1, 2, 5, 3, 4] + +const bySeverity: Order.Order = Order.mapInput( + Order.Number, + (failure: ObservedFailure) => PRECEDENCE_LOW_TO_HIGH.indexOf(failure.exitCode), +) + +const decidingFailureOf = (observation: RunFailedObservation): ObservedFailure => + Arr.headNonEmpty(Arr.sort(observation.failures, Order.flip(bySeverity))) + +const failedOutcome = (observation: RunFailedObservation): RunOutcomeDecision => { + const deciding = decidingFailureOf(observation) + return RunFailed.make({ code: deciding.exitCode, record: deciding.record }) +} + +const verdictOutcome = (observation: RunSucceededVerdict): RunOutcomeDecision => + RunVerdictFailed.make({ code: verdictCode(observation.exitClass) }) + +const decide = (command: RunOutcomeCommand): Result.Result => + Result.succeed( + Match.value(command.observation).pipe( + Match.tag('RunSucceededClean', () => RunOk.make({ help: false })), + Match.tag('RunHelpObservation', () => RunOk.make({ help: true })), + Match.tag('RunSucceededVerdict', verdictOutcome), + Match.tag('RunFailedObservation', failedOutcome), + Match.exhaustive, ), - Match.tag('RunSchemaErrorObservation', (observation) => Result.succeed(schemaErrorOutcome(observation))), - Match.tag('RunClassedObservation', (observation) => Result.succeed(classedOutcome(observation))), - Match.tag('RunGenericFailureObservation', (observation) => Result.succeed(genericFailureOutcome(observation))), - Match.exhaustive, ) export const classifyRunOutcome = Workflow.make({ command: RunOutcomeCommand, - decision: S.Union([RunOk, RunParseFailed, RunSurvivorsRejected, RunConfigFailed, RunFailed]), - error: RunInterrupted, + decision: RunOutcomeDecision, + error: S.Never, decide, }) diff --git a/packages/stryker-js/src/conclude-run.cell.ts b/packages/stryker-js/src/conclude-run.cell.ts index 43fb8e38..f5c45cf8 100644 --- a/packages/stryker-js/src/conclude-run.cell.ts +++ b/packages/stryker-js/src/conclude-run.cell.ts @@ -1,92 +1,98 @@ import { Sandwich } from '@systemfsoftware/effect-cell-types' -import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import * as Effect from 'effect/Effect' +import type * as FileSystem from 'effect/FileSystem' import type * as Path from 'effect/Path' -import * as Result from 'effect/Result' +import * as S from 'effect/Schema' -import { RunExit, type RunOutcomeDecision, type RunOutcomeError } from './classify-run-outcome.workflow.js' +import { RunExit, RunOutcomeDecision } from './classify-run-outcome.workflow.js' import type { ResolvedMode } from './output-mode.schema.js' import { planRunConclusion, type PlanRunConclusionCommand } from './plan-run-conclusion.workflow.js' -import { runExitCodeFromOutcome } from './reporting/run-failure.js' import type { RunEventDrain, RunEventStream, RunEventStreamPort } from './run-event-stream.service.js' -import { RunOutcomeCommand } from './RunOutcomeCommand.schema.js' import { StrykerError } from './stryker-error.schema.js' - -export interface RunConclusion { - readonly command: RunOutcomeCommand - readonly outcome: Result.Result - readonly error: string -} +import { FAILURE_RECORD_FILE } from './stryker-outputs.js' export interface RunConclusionInput { readonly mode: ResolvedMode readonly stream: RunEventStream readonly basePath: string readonly pathService: Path.Path + readonly fileSystem: FileSystem.FileSystem readonly runEvents: RunEventStreamPort - readonly concluded: RunConclusion + readonly decision: RunOutcomeDecision } export type RunConclusionRaw = (typeof PlanRunConclusionCommand)['Encoded'] & { readonly conclusion: RunConclusionInput } -const encodedCommandOf = ( - command: RunOutcomeCommand, -): (typeof RunOutcomeCommand)['Encoded'] => ({ - _tag: 'RunOutcomeCommand', - observation: command.observation, -}) - const readConclusion = Effect.fn(SpanTaxonomy.Spans.runConclusionRead.name)(function*( input: RunConclusionInput, ): Effect.fn.Return { - const classified = Result.getOrElse(input.concluded.outcome, (interrupted) => interrupted) yield* input.stream.open return { _tag: 'PlanRunConclusionCommand' as const, - command: encodedCommandOf(input.concluded.command), + decision: yield* Effect.orDie(S.encodeEffect(RunOutcomeDecision)(input.decision)), machine: input.mode.mode === 'machine', - exitCode: runExitCodeFromOutcome(classified).code, - outcome: classified._tag, - error: input.concluded.error, conclusion: input, } }) +const failureRecordPathOf = (conclusion: RunConclusionInput): string => + conclusion.pathService.resolve(conclusion.basePath, FAILURE_RECORD_FILE) + +const writeFailureRecord = (conclusion: RunConclusionInput, record: FailureRecord.FailureRecord) => + Effect.gen(function*() { + const file = failureRecordPathOf(conclusion) + const text = yield* S.encodeEffect(FailureRecord.FailureRecordFile)(record) + yield* conclusion.fileSystem.makeDirectory(conclusion.pathService.dirname(file), { recursive: true }) + yield* conclusion.fileSystem.writeFileString(file, `${text}\n`) + }).pipe( + Effect.tapError((error) => Effect.logWarning(`could not write ${FAILURE_RECORD_FILE}: ${error.message}`)), + Effect.ignore, + ) + +const removeStaleFailureRecord = (conclusion: RunConclusionInput) => + conclusion.fileSystem.remove(failureRecordPathOf(conclusion), { force: true }).pipe( + Effect.tapError((error) => Effect.logWarning(`could not remove a stale ${FAILURE_RECORD_FILE}: ${error.message}`)), + Effect.ignore, + ) + +const closeAndExit = (conclusion: RunConclusionInput, exitCode: number) => + Effect.andThen(conclusion.stream.closeAndDrain, Effect.fail(RunExit.make({ code: exitCode }))) + export const concludeRunCell = Sandwich.named(SpanTaxonomy.Spans.runConclude.name)(readConclusion) .decide(planRunConclusion) .write({ - RunConclusionEmittedOk: (_decision, raw) => - Effect.andThen( - raw.conclusion.runEvents.emitMachineModeOutput({ - stream: raw.conclusion.stream, - mode: raw.conclusion.mode, - outcome: raw.conclusion.concluded.outcome, - basePath: raw.conclusion.basePath, - pathService: raw.conclusion.pathService, - }), - Effect.andThen(raw.conclusion.stream.closeAndDrain, Effect.void), - ), - RunConclusionEmittedFailed: (decision, raw) => - Effect.andThen( - raw.conclusion.runEvents.emitMachineModeOutput({ - stream: raw.conclusion.stream, - mode: raw.conclusion.mode, - outcome: raw.conclusion.concluded.outcome, - basePath: raw.conclusion.basePath, - pathService: raw.conclusion.pathService, - }), + RunConclusionEmittedOk: (decision, raw) => + removeStaleFailureRecord(raw.conclusion).pipe( Effect.andThen( - raw.conclusion.stream.closeAndDrain, - Effect.fail(RunExit.make({ code: decision.exitCode })), + raw.conclusion.runEvents.emitMachineModeOutput({ + stream: raw.conclusion.stream, + mode: raw.conclusion.mode, + ok: decision.ok, + basePath: raw.conclusion.basePath, + pathService: raw.conclusion.pathService, + }), ), + Effect.andThen(raw.conclusion.stream.closeAndDrain), + Effect.asVoid, + ), + RunConclusionQuietOk: (_decision, raw) => + removeStaleFailureRecord(raw.conclusion).pipe( + Effect.andThen(raw.conclusion.stream.closeAndDrain), + Effect.asVoid, ), - RunConclusionQuietOk: (_decision, raw) => Effect.andThen(raw.conclusion.stream.closeAndDrain, Effect.void), - RunConclusionQuietFailed: (decision, raw) => - Effect.andThen( - raw.conclusion.stream.closeAndDrain, - Effect.fail(RunExit.make({ code: decision.exitCode })), + RunConclusionVerdictFailed: (decision, raw) => + removeStaleFailureRecord(raw.conclusion).pipe( + Effect.andThen(closeAndExit(raw.conclusion, decision.exitCode)), + ), + RunConclusionFailed: (decision, raw) => + writeFailureRecord(raw.conclusion, decision.record).pipe( + Effect.andThen( + raw.conclusion.runEvents.emitFailureRecord(raw.conclusion.stream, decision.exitCode, decision.record), + ), + Effect.andThen(closeAndExit(raw.conclusion, decision.exitCode)), ), CommandRejected: ({ issue }) => Effect.fail(StrykerError.make({ message: `the run conclusion command was rejected: ${issue}` })), diff --git a/packages/stryker-js/src/conclude-run.ts b/packages/stryker-js/src/conclude-run.ts index ae2b3114..b4fcddbb 100644 --- a/packages/stryker-js/src/conclude-run.ts +++ b/packages/stryker-js/src/conclude-run.ts @@ -1,240 +1,88 @@ -/// -import { ErrorText } from '@systemfsoftware/stryker-js-instrumenter' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as Arr from 'effect/Array' -import * as Boolean from 'effect/Boolean' import * as Cause from 'effect/Cause' import * as CliError from 'effect/cli/CliError' import * as Exit from 'effect/Exit' +import * as Formatter from 'effect/Formatter' +import { pipe } from 'effect/Function' import * as Match from 'effect/Match' import * as Option from 'effect/Option' -import * as Order from 'effect/Order' import * as Predicate from 'effect/Predicate' import * as S from 'effect/Schema' -import { RunOutcomeCommand } from './RunOutcomeCommand.schema.js' import { - RunClassedObservation, - RunCliErrorObservation, - RunGenericFailureObservation, + type ObservedFailure, + RunFailedObservation, RunHelpObservation, - RunInterruptedObservation, + RunOutcomeCommand, type RunOutcomeObservation, - RunSchemaErrorObservation, RunSucceededClean, RunSucceededVerdict, - RunSurvivorsRejectedObservation, } from './RunOutcomeCommand.schema.js' -import { SurvivorsRejection } from './Survivors/mod.js' -const UNKNOWN_FAILURE = 'Unknown failure' -const MAX_TRAVERSAL_DEPTH = 10 +const CLI_BIN = 'stryker' +const MAX_CAUSE_DEPTH = 10 +const asEvidence = Option.liftPredicate(S.is(FailureRecord.FailureEvidence)) const asExitClass = Option.liftPredicate(S.is(Plugin.ExitClass)) -const nonEmptyText = Option.liftPredicate(S.is(S.NonEmptyString)) - -const failedExit = Option.liftPredicate(Exit.isFailure) - -const hasExitClass = Predicate.hasProperty('exitClass') - -const hasVerdict = Predicate.hasProperty('verdict') - -const hasCause = Predicate.hasProperty('cause') - -const hasReason = Predicate.hasProperty('reason') - -const hasMessageField = Predicate.hasProperty('message') - -const isUnseenObject = (value: A, seen: WeakSet): value is A & object => - Predicate.isObjectOrArray(value) && !seen.has(value) -const isReachableValue = (value: A, depth: number, seen: WeakSet): value is A & object => - depth <= MAX_TRAVERSAL_DEPTH && isUnseenObject(value, seen) - -const causeChildrenOf = (value: object): ReadonlyArray => - Option.match(Option.liftPredicate(value, hasCause), { - onNone: () => [], - onSome: (carrier) => Arr.filter(Arr.ensure(carrier.cause), Predicate.isObjectOrArray), - }) - -const visitReachableValue = ( - value: A, - depth: number, - seen: WeakSet, - visit: (node: object) => void, -): void => - Option.match( - Option.liftPredicate( - value, - (candidate): candidate is A & object => isReachableValue(candidate, depth, seen), - ), - { - onSome: (reachable) => { - seen.add(reachable) - visit(reachable) - causeChildrenOf(reachable).forEach((child) => visitReachableValue(child, depth + 1, seen, visit)) - }, - onNone: () => undefined, - }, +const textFieldOf = (node: A, key: K): Option.Option => + Option.flatMap( + Option.liftPredicate(node, Predicate.hasProperty(key)), + (carrier) => Option.liftPredicate(carrier[key], Predicate.isString), ) -const findReachableValue = ( - value: A, - depth: number, - seen: WeakSet, - read: (node: object) => Option.Option, -): Option.Option => - Option.match( - Option.liftPredicate( - value, - (candidate): candidate is A & object => isReachableValue(candidate, depth, seen), - ), - { - onSome: (reachable) => { - seen.add(reachable) - return Option.orElse( - read(reachable), - () => Arr.findFirst(causeChildrenOf(reachable), (child) => findReachableValue(child, depth + 1, seen, read)), - ) - }, - onNone: () => Option.none(), - }, +const declaredEvidenceOf = (node: A): Option.Option => + Option.flatMap( + Option.liftPredicate(node, Predicate.hasProperty('evidence')), + (carrier) => asEvidence(carrier.evidence), ) -const causePayloadOf = (reason: Cause.Reason): E | object | undefined => - Cause.isFailReason(reason) ? reason.error : objectPayloadOf(reason) - -const objectPayloadOf = (reason: Cause.Reason): object | undefined => - Option.getOrUndefined(Option.filter(dieDefectOf(reason), Predicate.isObject)) +const causeOf = (node: A, depth: number) => + Option.flatMap( + Option.filter(Option.liftPredicate(node, Predicate.hasProperty('cause')), () => depth < MAX_CAUSE_DEPTH), + (carrier) => Option.fromUndefinedOr(carrier.cause), + ) -const dieDefectOf = (reason: Cause.Reason) => - Cause.isDieReason(reason) ? Option.some(reason.defect) : Option.none() +const kindOf = (node: A): string => + Option.getOrElse(Option.orElse(textFieldOf(node, '_tag'), () => textFieldOf(node, 'name')), () => typeof node) -const failurePayloads = (exit: Exit.Exit): ReadonlyArray => +const messageOf = (node: A): string => Option.getOrElse( - Option.map(failedExit(exit), (failure) => failure.cause.reasons.map(causePayloadOf)), - (): ReadonlyArray => [], - ) - -const exitClassOf = (value: A): Plugin.ExitClass | undefined => - Option.getOrUndefined( - Option.flatMap( - Option.liftPredicate(value, hasExitClass), - (carrier) => asExitClass(carrier.exitClass), - ), + Option.orElse(textFieldOf(node, 'message'), () => Option.liftPredicate(node, Predicate.isString)), + () => Formatter.format(node, { ignoreToString: true }), ) -const appendExitClass = (value: object, out: Array): void => - Option.match(Option.fromUndefinedOr(exitClassOf(value)), { - onSome: (declared) => { - out.push(declared) - }, - onNone: () => undefined, - }) - -const collectExitClasses = (exit: Exit.Exit): Array => { - const out: Array = [] - const seen = new WeakSet() - failurePayloads(exit).forEach((payload) => - visitReachableValue(payload, 0, seen, (node) => appendExitClass(node, out)) - ) - return out -} +const STACK_FRAME = /^\s+at / -const causeTextOf = (value: A): Option.Option => - Option.map(ErrorText.causeTextOf(hasCause(value) ? value.cause : undefined), (decoded) => decoded.text) - -const reasonOf = (value: A): string | undefined => { - const declared = hasReason(value) ? value.reason : undefined - return Option.getOrUndefined( - Option.map(nonEmptyText(declared), (reason) => - Option.match(causeTextOf(value), { - onNone: () => reason, - onSome: (detail) => `${reason}: ${detail}`, - })), +const framesOf = (stack: string): Option.Option => + Option.liftPredicate( + Arr.dropWhile(stack.split('\n'), (line) => !STACK_FRAME.test(line)).join('\n'), + (frames) => frames.length > 0, ) -} - -const reasonTextFieldOf = (value: A): Option.Option => - Option.flatMap(Option.liftPredicate(value, hasReason), (carrier) => nonEmptyText(carrier.reason)) -const messageTextFieldOf = (value: A): Option.Option => - Option.flatMap(Option.liftPredicate(value, hasMessageField), (carrier) => nonEmptyText(carrier.message)) +const linkOf = (node: A): FailureRecord.CauseLink => ({ + kind: kindOf(node), + message: messageOf(node), + stack: Option.getOrNull(Option.flatMap(textFieldOf(node, 'stack'), framesOf)), +}) -const firstConfiguredText = (value: A): Option.Option => - Option.orElse(reasonTextFieldOf(value), () => messageTextFieldOf(value)) - -const configDetailAt = (value: object): Option.Option => - exitClassOf(value) === 'ConfigError' ? firstConfiguredText(value) : Option.none() - -const firstConfigErrorDetail = (exit: Exit.Exit): string | undefined => { - const seen = new WeakSet() - const roots = [...failurePayloads(exit)].reverse() - return Option.getOrUndefined( - Arr.findFirst(roots, (root) => findReachableValue(root, 0, seen, configDetailAt)), +const causeLinksOf = (node: A, depth: number): ReadonlyArray => + Arr.prepend( + Option.match(causeOf(node, depth), { + onNone: (): ReadonlyArray => [], + onSome: (child) => causeLinksOf(child, depth + 1), + }), + linkOf(node), ) -} - -const PRIMITIVE_REFINEMENTS = [ - Predicate.isString, - Predicate.isNumber, - Predicate.isBoolean, - Predicate.isBigInt, - Predicate.isSymbol, -] - -const isPrimitiveText = Predicate.some(PRIMITIVE_REFINEMENTS) - -const declaresReasonText = (value: A): boolean => hasReason(value) && Option.isSome(nonEmptyText(value.reason)) - -const remediationTextOf = (value: A): Option.Option => - S.is(SurvivorsRejection)(value) ? Option.some(value.remediation) : Option.none() -const reasonTextOf = (value: A): Option.Option => - declaresReasonText(value) ? Option.fromNullishOr(reasonOf(value)) : Option.none() - -const errorMessageTextOf = (value: A): Option.Option => - Option.flatMap(Option.filter(Option.some(value), Predicate.isError), (error) => nonEmptyText(error.message)) - -const primitiveTextOf = (value: A): Option.Option => - isPrimitiveText(value) ? Option.some(String(value)) : Option.none() - -const failureValueDescription = (value: A): Option.Option => - remediationTextOf(value).pipe( - Option.orElse(() => reasonTextOf(value)), - Option.orElse(() => errorMessageTextOf(value)), - Option.orElse(() => primitiveTextOf(value)), - ) - -const failureValueOf = (exit: Exit.Exit): E | undefined => - exit.pipe(failedExit, Option.flatMap((failure) => Cause.findErrorOption(failure.cause)), Option.getOrUndefined) - -const failureDescriptionOf = (exit: Exit.Exit): Option.Option => - exit.pipe( - failureValueOf, - failureValueDescription, - Option.orElse(() => exit.pipe(firstConfigErrorDetail, Option.fromNullishOr)), - Option.orElse(() => - exit.pipe(failedExit, Option.flatMap((failure) => failure.cause.pipe(Cause.pretty, nonEmptyText))) - ), +const evidenceWithin = (node: A, depth: number): Option.Option => + Option.orElse( + declaredEvidenceOf(node), + () => Option.flatMap(causeOf(node, depth), (child) => evidenceWithin(child, depth + 1)), ) -const describeFailureOf = (exit: Exit.Exit): string => - Option.getOrElse(failureDescriptionOf(exit), () => UNKNOWN_FAILURE) - -const showHelpErrors = (help: CliError.ShowHelp): ReadonlyArray => help.errors - -const showHelpErrorsOf = (value: A): Option.Option> => - Option.map(Option.liftPredicate(value, S.is(CliError.ShowHelp)), showHelpErrors) - -const cliErrorListOf = (exit: Exit.Exit): Option.Option> => { - const value = failureValueOf(exit) - return Option.match(showHelpErrorsOf(value), { - onSome: (errors) => Option.some(errors), - onNone: () => (CliError.isCliError(value) ? Option.some([value]) : Option.none()), - }) -} - const followingArgumentOf = (argv: readonly string[], option: string): Option.Option => Match.value(argv.indexOf(option)).pipe( Match.when((at) => at >= 0, (at) => Option.fromNullishOr(argv[at + 1])), @@ -255,160 +103,109 @@ const argumentHintOf = (error: CliError.CliError, argv: readonly string[]): Opti Match.orElse(() => Option.none()), ) -const unrecognizedHintOf = (exit: Exit.Exit, argv: readonly string[]): string | undefined => - exit.pipe( - cliErrorListOf, - Option.flatMap((errors) => Arr.findFirst(errors, (error) => argumentHintOf(error, argv))), - Option.getOrUndefined, +const cliErrorsOf = (error: CliError.CliError): ReadonlyArray => + S.is(CliError.ShowHelp)(error) ? error.errors : [error] + +const argumentsInvalidOf = (error: CliError.CliError, argv: readonly string[]): FailureRecord.FailureEvidence => ({ + _tag: 'ArgumentsInvalid', + stage: 'cli', + argument: pipe( + cliErrorsOf(error), + Arr.findFirst((each: CliError.CliError) => argumentHintOf(each, argv)), + Option.getOrNull, + ), +}) + +const configInvalidOf = (error: A): FailureRecord.FailureEvidence => ({ + _tag: 'ConfigInvalid', + stage: 'config', + detail: messageOf(error), +}) + +const CATALOG_GAP: FailureRecord.FailureEvidence = { _tag: 'CatalogGap', stage: 'run' } +const INTERRUPTED: FailureRecord.FailureEvidence = { _tag: 'RunInterrupted', stage: 'run' } +const CI_ONLY_EXIT_CODE = 4 + +interface RunContext { + readonly argv: readonly string[] + readonly cwd: string + readonly traceId: FailureRecord.TraceId | null +} + +const observedOf = ( + evidence: FailureRecord.FailureEvidence, + cause: ReadonlyArray, + context: RunContext, +): ObservedFailure => ({ + record: FailureRecord.recordOf(evidence, { + cause, + cwd: context.cwd, + argv: [CLI_BIN, ...context.argv], + env: [], + traceId: context.traceId, + }), + exitCode: FailureRecord.FailureCatalog[evidence._tag].exitCode ?? CI_ONLY_EXIT_CODE, +}) + +const undeclaredEvidenceOf = (payload: A, argv: readonly string[]): FailureRecord.FailureEvidence => + Option.getOrElse( + Option.orElse( + Option.map(Option.liftPredicate(payload, CliError.isCliError), (error) => argumentsInvalidOf(error, argv)), + () => Option.map(Option.liftPredicate(payload, S.isSchemaError), configInvalidOf), + ), + () => CATALOG_GAP, ) -const survivorsRejectionOf = (value: A): SurvivorsRejection | undefined => - S.is(SurvivorsRejection)(value) ? value : undefined - -const survivorsReasonOf = ( - survivors: SurvivorsRejection | undefined, -): 'no-report' | 'mismatch' | undefined => survivors?.reason - -const survivorsDiagnosticOf = (survivors: SurvivorsRejection | undefined): string | undefined => survivors?.remediation - -const omitUnknownFailure = (diagnostic: string): string | undefined => - diagnostic === UNKNOWN_FAILURE ? undefined : diagnostic - -const hasOnlyInterruptsOf = (exit: Exit.Exit): boolean => - Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - -const carriesCliError = (value: A): boolean => value !== undefined && CliError.isCliError(value) - -const carriesSchemaError = (value: A): boolean => value !== undefined && S.isSchemaError(value) - -const helpErrorCountOf = (value: A): number | undefined => - S.is(CliError.ShowHelp)(value) ? value.errors.length : undefined - -const verdictExitClassOf = (value: A): Plugin.ExitClass | undefined => - hasVerdict(value) ? Option.getOrUndefined(asExitClass(value.verdict)) : undefined - -const successExitClassOf = (exit: Exit.Exit): Plugin.ExitClass | undefined => - Exit.isSuccess(exit) ? verdictExitClassOf(exit.value) : undefined - -const bySeverity: Order.Order = Order.mapInput( - Order.Number, - (exitClass: Plugin.ExitClass) => Plugin.ExitClass.literals.indexOf(exitClass), -) - -const highestExitClassOf = (pending: ReadonlyArray): Plugin.ExitClass | undefined => - Option.getOrUndefined(Arr.last(Arr.sort(pending, bySeverity))) - -const observationOf = ( - { exit, value, survivors, argv }: { - readonly exit: Exit.Exit - readonly value: E | object | undefined - readonly survivors: SurvivorsRejection | undefined - readonly argv: readonly string[] - }, -): RunOutcomeObservation => { - const unrecognized = Option.getOrNull(Option.fromUndefinedOr(unrecognizedHintOf(exit, argv))) - const configDetail = exit.pipe(firstConfigErrorDetail, Option.fromUndefinedOr, Option.getOrNull) - const diagnostic = exit.pipe(describeFailureOf, omitUnknownFailure, Option.fromUndefinedOr, Option.getOrNull) - - return Boolean.match(Exit.isSuccess(exit), { - onTrue: () => - exit.pipe( - successExitClassOf, - Option.fromUndefinedOr, - Option.match({ - onNone: () => RunSucceededClean.make({}), - onSome: (exitClass) => RunSucceededVerdict.make({ exitClass, diagnostic }), - }), - ), - onFalse: () => - Boolean.match(hasOnlyInterruptsOf(exit), { - onTrue: () => RunInterruptedObservation.make({}), - onFalse: () => - Option.match(Option.fromUndefinedOr(helpErrorCountOf(value)), { - onSome: (errorCount) => RunHelpObservation.make({ errorCount, unrecognized }), - onNone: () => - Boolean.match(carriesCliError(value), { - onTrue: () => RunCliErrorObservation.make({ unrecognized }), - onFalse: () => - Option.match(Option.fromUndefinedOr(survivorsReasonOf(survivors)), { - onSome: (reason) => - RunSurvivorsRejectedObservation.make({ - reason, - diagnostic: Option.getOrNull(Option.fromUndefinedOr(survivorsDiagnosticOf(survivors))), - }), - onNone: () => - Boolean.match(carriesSchemaError(value), { - onTrue: () => RunSchemaErrorObservation.make({ configDetail }), - onFalse: () => - exit.pipe( - collectExitClasses, - highestExitClassOf, - Option.fromUndefinedOr, - Option.match({ - onSome: (exitClass) => - RunClassedObservation.make({ exitClass, configDetail, diagnostic }), - onNone: () => RunGenericFailureObservation.make({ diagnostic }), - }), - ), - }), - }), - }), - }), - }), - }) -} +const observedFailureOf = (payload: A, context: RunContext): ObservedFailure => + observedOf( + Option.getOrElse(evidenceWithin(payload, 0), () => undeclaredEvidenceOf(payload, context.argv)), + causeLinksOf(payload, 0), + context, + ) -export const runOutcomeCommandOf = ( - { argv, exit }: { readonly exit: Exit.Exit; readonly argv: readonly string[] }, -): RunOutcomeCommand => { - const value = failureValueOf(exit) - return RunOutcomeCommand.make({ - observation: observationOf({ exit, value, survivors: survivorsRejectionOf(value), argv }), - }) -} +const payloadOf = (reason: Cause.Reason) => + Cause.isFailReason(reason) + ? Option.some(reason.error) + : Option.map(Option.liftPredicate(reason, Cause.isDieReason), (die) => die.defect) -if (import.meta.vitest !== void 0) { - const { it } = await import('@systemfsoftware/vitest') +const unpayloadedFailureOf = (cause: Cause.Cause, context: RunContext): ObservedFailure => + Cause.hasInterruptsOnly(cause) + ? observedOf(INTERRUPTED, [], context) + : observedOf(CATALOG_GAP, [cause.pipe(Cause.pretty, linkOf)], context) - const severityOf = (exitClass: Plugin.ExitClass): number => Plugin.ExitClass.literals.indexOf(exitClass) +const failuresOf = (cause: Cause.Cause, context: RunContext): Arr.NonEmptyReadonlyArray => + Option.getOrElse( + Arr.match(Arr.flatMap(cause.reasons, (reason) => Option.toArray(payloadOf(reason))), { + onEmpty: () => Option.none(), + onNonEmpty: (payloads) => Option.some(Arr.map(payloads, (payload) => observedFailureOf(payload, context))), + }), + () => Arr.of(unpayloadedFailureOf(cause, context)), + ) - const classedKeyOf = (command: RunOutcomeCommand): string => - S.is(RunClassedObservation)(command.observation) - ? `${command.observation.exitClass}|${command.observation.configDetail}` - : 'not-classed' +const isHelpRequest = (cause: Cause.Cause): boolean => + Option.exists(Cause.findErrorOption(cause), (error) => S.is(CliError.ShowHelp)(error) && error.errors.length === 0) - const genericDiagnosticOf = (command: RunOutcomeCommand): string | null => - S.is(RunGenericFailureObservation)(command.observation) ? command.observation.diagnostic : null +const failureObservationOf = (cause: Cause.Cause, context: RunContext): RunOutcomeObservation => + isHelpRequest(cause) + ? RunHelpObservation.make({}) + : RunFailedObservation.make({ failures: failuresOf(cause, context) }) - it.prop( - '∀text_RunOutcomeCommand_≡PrimitiveFailureDiagnostic', - { of: [S.String], subject: runOutcomeCommandOf }, - (subject, [text]) => { - const command = subject({ exit: Exit.fail(text), argv: [] }) - return genericDiagnosticOf(command) === (text === UNKNOWN_FAILURE ? null : text) - }, +const verdictExitClassOf = (value: A): Option.Option => + Option.flatMap( + Option.liftPredicate(value, Predicate.hasProperty('verdict')), + (carrier) => asExitClass(carrier.verdict), ) - it.prop( - '∀detail_RunOutcomeCommand_≡ConfigDetail', - { of: [S.NonEmptyString], subject: runOutcomeCommandOf }, - (subject, [detail]) => { - const command = subject({ exit: Exit.fail({ exitClass: 'ConfigError', reason: detail }), argv: [] }) - return classedKeyOf(command) === `ConfigError|${detail}` - }, - ) +const successObservationOf = (value: A): RunOutcomeObservation => + Option.match(verdictExitClassOf(value), { + onNone: () => RunSucceededClean.make({}), + onSome: (exitClass) => RunSucceededVerdict.make({ exitClass }), + }) - it.prop( - '∀ec_RunOutcomeCommand_≡HighestExitClass', - { of: [Plugin.ExitClass, Plugin.ExitClass, Plugin.ExitClass], subject: runOutcomeCommandOf }, - (subject, [left, middle, right]) => { - const exit = Exit.fail({ exitClass: left, cause: { exitClass: middle, cause: { exitClass: right } } }) - const command = subject({ exit, argv: [] }) - return ( - S.is(RunClassedObservation)(command.observation) && - severityOf(command.observation.exitClass) === - Math.max(severityOf(left), severityOf(middle), severityOf(right)) - ) - }, - ) -} +export const runOutcomeCommandOf = (input: RunContext & { readonly exit: Exit.Exit }): RunOutcomeCommand => + RunOutcomeCommand.make({ + observation: Exit.match(input.exit, { + onSuccess: successObservationOf, + onFailure: (cause) => failureObservationOf(cause, input), + }), + }) diff --git a/packages/stryker-js/src/drivers/node.ts b/packages/stryker-js/src/drivers/node.ts index 6b3c8ca4..efd9fd3f 100644 --- a/packages/stryker-js/src/drivers/node.ts +++ b/packages/stryker-js/src/drivers/node.ts @@ -1,7 +1,7 @@ import { NodeFileSystem, NodePath, NodeSocket, NodeStdio } from '@effect/platform-node' import * as NodeChildProcessSpawner from '@effect/platform-node-shared/NodeChildProcessSpawner' import * as NodeCrypto from '@effect/platform-node-shared/NodeCrypto' -import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import * as Crypto from 'effect/Crypto' import * as Duration from 'effect/Duration' import * as Effect from 'effect/Effect' @@ -14,16 +14,42 @@ import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -import { classifyWorkerExit, ClassifyWorkerExitCommand } from '../classify-worker-exit.workflow.js' +import { + classifyWorkerExit, + ClassifyWorkerExitCommand, + type ClassifyWorkerExitDecision, +} from '../classify-worker-exit.workflow.js' import { GitDiff } from '../git-diff.service.js' import type { EnginePorts } from '../run/StageServices.service.js' import { make as makeSpawnedSocketWorker } from '../spawned-socket-worker.handle.js' import { layerWorkerProtocol } from '../worker-protocol.blueprint.js' -import { ChildProcessCrashedError, OutOfMemoryError } from '../Worker.schema.js' +import { ChildProcessCrashedError, OutOfMemoryError, type WorkerExit } from '../Worker.schema.js' import { WorkerLauncher, type WorkerSpawnParams } from '../WorkerLauncher.service.js' const WORKER_TERMINATION_GRACE = Duration.seconds(5) +const workerExitError = ( + decision: ClassifyWorkerExitDecision, + workerKind: FailureRecord.WorkerKind, +): WorkerExit => + Match.value(decision).pipe( + Match.tag( + 'WorkerOutOfMemory', + ({ pid, exitCode }): WorkerExit => OutOfMemoryError.make({ pid, exitCode, workerKind }), + ), + Match.tag( + 'WorkerCrashed', + ({ pid, exitCode }): WorkerExit => + ChildProcessCrashedError.make({ + pid, + exit: { _tag: 'Code', code: exitCode }, + workerKind, + cause: 'worker exited before it accepted the RPC connection', + }), + ), + Match.exhaustive, + ) + const restrictToOwnerOrWarn = (fs: FileSystem.FileSystem, file: string) => fs.chmod(file, 0o600).pipe( Effect.tapError((cause) => @@ -43,82 +69,61 @@ const nodeWorkerLauncherLayer = (childEnv: Readonly>) => const path = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - return { - spawn: Effect.fn(SpanTaxonomy.Spans.workerSpawn.name)( - function*(params: WorkerSpawnParams) { - const workerDir = yield* fs.makeTempDirectoryScoped({ prefix: params.tempDirPrefix }) - const workerId = yield* crypto.randomUUIDv4 - const socketPath = Match.value(globalThis.process.platform).pipe( - Match.when('win32', () => `\\\\.\\pipe\\stryker-worker-${workerId}`), - Match.orElse(() => path.join(workerDir, 'worker.sock')), - ) - const optionsFile = path.join(workerDir, 'options.json') - yield* fs.writeFileString(optionsFile, params.optionsJson) - yield* restrictToOwnerOrWarn(fs, optionsFile) - - const entrypointPath = yield* path.fromFileUrl(new URL(params.entrypoint)) - const handle = yield* ChildProcess.make( - globalThis.process.execPath, - [...params.execArgv, entrypointPath], + const spawnWorker = Effect.fn(SpanTaxonomy.Spans.workerSpawn.name)(function*(params: WorkerSpawnParams) { + const workerDir = yield* fs.makeTempDirectoryScoped({ prefix: params.tempDirPrefix }) + const workerId = yield* crypto.randomUUIDv4 + const socketPath = Match.value(globalThis.process.platform).pipe( + Match.when('win32', () => `\\\\.\\pipe\\stryker-worker-${workerId}`), + Match.orElse(() => path.join(workerDir, 'worker.sock')), + ) + const optionsFile = path.join(workerDir, 'options.json') + yield* fs.writeFileString(optionsFile, params.optionsJson) + yield* restrictToOwnerOrWarn(fs, optionsFile) + + const entrypointPath = yield* path.fromFileUrl(new URL(params.entrypoint)) + const handle = yield* ChildProcess.make( + globalThis.process.execPath, + [...params.execArgv, entrypointPath], + { + cwd: params.workingDirectory, + extendEnv: true, + env: { STRYKER_WORKER_DIR: workerDir, STRYKER_SOCKET: socketPath, ...childEnv, ...params.env }, + stderr: 'inherit', + forceKillAfter: WORKER_TERMINATION_GRACE, + }, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) + + const clientLayer = layerWorkerProtocol(NodeSocket.layerNet({ path: socketPath })) + + const exited = handle.exitCode.pipe( + Effect.orDie, + Effect.flatMap((exitCode) => + Result.match( + classifyWorkerExit(ClassifyWorkerExitCommand.make({ pid: Number(handle.pid), exitCode })), { - cwd: params.workingDirectory, - extendEnv: true, - env: { STRYKER_WORKER_DIR: workerDir, STRYKER_SOCKET: socketPath, ...childEnv, ...params.env }, - stderr: 'inherit', - forceKillAfter: WORKER_TERMINATION_GRACE, + onFailure: (refused) => Effect.fail(refused), + onSuccess: (decision) => Effect.fail(workerExitError(decision, params.workerKind)), }, - ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) - - const clientLayer = layerWorkerProtocol(NodeSocket.layerNet({ path: socketPath })) - - const exited = handle.exitCode.pipe( - Effect.orDie, - Effect.flatMap((exitCode) => - Result.match( - classifyWorkerExit(ClassifyWorkerExitCommand.make({ pid: Number(handle.pid), exitCode })), - { - onFailure: (refused) => Effect.fail(refused), - onSuccess: (decision) => - Match.value(decision).pipe( - Match.tag( - 'WorkerOutOfMemory', - (outOfMemory) => - Effect.fail( - OutOfMemoryError.make({ pid: outOfMemory.pid, exitCode: outOfMemory.exitCode }), - ), - ), - Match.tag( - 'WorkerCrashed', - (crashed) => - Effect.fail( - ChildProcessCrashedError.make({ - pid: crashed.pid, - exit: { _tag: 'Code', code: crashed.exitCode }, - cause: 'worker exited before it accepted the RPC connection', - }), - ), - ), - Match.exhaustive, - ), - }, - ) - ), ) + ), + ) - return makeSpawnedSocketWorker({ pid: Number(handle.pid), clientLayer, exited }) - }, - (spawned) => - spawned.pipe( - Effect.catchIf(S.is(ChildProcessCrashedError), (error) => Effect.fail(error), () => - Effect.fail( - ChildProcessCrashedError.make({ - pid: 0, - exit: { _tag: 'Code', code: 1 }, - cause: 'worker spawn failed', - }), - )), - ), - ), + return makeSpawnedSocketWorker({ pid: Number(handle.pid), clientLayer, exited }) + }) + + return { + spawn: (params: WorkerSpawnParams) => + spawnWorker(params).pipe( + Effect.catchIf(S.is(ChildProcessCrashedError), (error) => Effect.fail(error), () => + Effect.fail( + ChildProcessCrashedError.make({ + pid: 0, + workerKind: params.workerKind, + exit: { _tag: 'Code', code: 1 }, + cause: 'worker spawn failed', + }), + )), + ), } }), ) diff --git a/packages/stryker-js/src/dry-run.workflow.ts b/packages/stryker-js/src/dry-run.workflow.ts index 7e810cab..67e7e1c0 100644 --- a/packages/stryker-js/src/dry-run.workflow.ts +++ b/packages/stryker-js/src/dry-run.workflow.ts @@ -1,27 +1,16 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import * as Match from 'effect/Match' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -export class DryRunError extends S.TaggedError()('DryRunError', { - stage: S.Literals(['dryRun', 'dryRunNoTests']), - reason: S.String, -}) { - override get message(): string { - return this.reason - } -} - -export class FailedTestSummary extends S.Class('FailedTestSummary')({ - name: S.String, - failureMessage: S.String, -}) {} +import { RunFailure } from './Run.schema.js' export class DryRunCommand extends S.TaggedClass()('DryRunCommand', { status: S.Literals(['Complete', 'Error', 'Timeout']), testCount: S.Finite, failedTestCount: S.Finite, - failedTests: S.Array(FailedTestSummary), + failedTests: S.Array(FailureRecord.FailedTestEvidence), allowEmpty: S.Boolean, errorMessage: S.optional(S.String), reason: S.optional(S.String), @@ -46,7 +35,7 @@ export class DryRunPassed extends S.TaggedClass()('DryRunPassed', export class DryRunFailed extends S.TaggedClass()('DryRunFailed', { testCount: S.Finite, failedTestCount: S.Finite, - failedTests: S.Array(FailedTestSummary), + failedTests: S.Array(FailureRecord.FailedTestEvidence), }) { readonly [DryRunDecisionTypeId] = DryRunDecisionTypeId } @@ -62,7 +51,7 @@ const completeOutcomeOf = (command: DryRunCommand): CompleteOutcome => Match.orElse((): CompleteOutcome => 'passed'), ) -const decideComplete = (command: DryRunCommand): Result.Result => +const decideComplete = (command: DryRunCommand): Result.Result => Match.value({ outcome: completeOutcomeOf(command), testCount: command.testCount, @@ -71,9 +60,9 @@ const decideComplete = (command: DryRunCommand): Result.Result Result.fail( - DryRunError.make({ - stage: 'dryRunNoTests', - reason: 'No tests were executed. Stryker will exit prematurely. Please check your configuration.', + RunFailure.make({ + evidence: { _tag: 'BaselineFoundNoTests', stage: 'dryRun' }, + detail: 'No tests were executed. Stryker will exit prematurely. Please check your configuration.', }), )), Match.when({ outcome: 'failed' }, ({ testCount, failedTestCount, failedTests }) => @@ -99,28 +88,29 @@ const reasonOrDefault = (reason: string | undefined, fallback: string): string = Match.orElse((present) => present), ) -const decide = (command: DryRunCommand): Result.Result => +const decide = (command: DryRunCommand): Result.Result => Match.value(command.status).pipe( Match.when('Error', () => Result.fail( - DryRunError.make({ - stage: 'dryRun', - reason: reasonOrDefault(command.errorMessage, 'Dry run error'), + RunFailure.make({ + evidence: { _tag: 'BaselineErrored', stage: 'dryRun' }, + detail: reasonOrDefault(command.errorMessage, 'Dry run error'), }), )), Match.when('Timeout', () => Result.fail( - DryRunError.make({ - stage: 'dryRun', - reason: reasonOrDefault(command.reason, 'Initial test run timed out'), + RunFailure.make({ + evidence: { _tag: 'BaselineTimedOut', stage: 'dryRun' }, + detail: reasonOrDefault(command.reason, 'Initial test run timed out'), }), )), Match.when('Complete', () => decideComplete(command)), Match.exhaustive, ) + export const dryRun = Workflow.make({ command: DryRunCommand, decision: S.Union([DryRunPassed, DryRunFailed]), - error: DryRunError, + error: RunFailure, decide, }) diff --git a/packages/stryker-js/src/events/mod.ts b/packages/stryker-js/src/events/mod.ts index 6010a038..4765707e 100644 --- a/packages/stryker-js/src/events/mod.ts +++ b/packages/stryker-js/src/events/mod.ts @@ -1 +1 @@ -export { PrepareError, StageError } from '../Run.schema.js' +export { RunFailure } from '../Run.schema.js' diff --git a/packages/stryker-js/src/frame-run-event.workflow.ts b/packages/stryker-js/src/frame-run-event.workflow.ts index c35933f4..a69675a5 100644 --- a/packages/stryker-js/src/frame-run-event.workflow.ts +++ b/packages/stryker-js/src/frame-run-event.workflow.ts @@ -1,5 +1,5 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' -import { OutputMode, RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, OutputMode, RunEvent } from '@systemfsoftware/stryker-js-cli-contract' import { Report } from '@systemfsoftware/stryker-js-plugin-interface' import * as Boolean from 'effect/Boolean' import * as Match from 'effect/Match' @@ -32,10 +32,16 @@ export class FrameRunEventCommand extends S.TaggedClass()( static readonly [Workflow.InstrumentationBrand] = {} as const } +const StderrOutput = S.Union([ + S.TaggedStruct('StderrText', { text: S.String }), + S.TaggedStruct('StderrRecord', { record: FailureRecord.FailureRecord }), +]) +type StderrOutput = typeof StderrOutput.Type + export class EventFramed extends S.TaggedClass()('EventFramed', { state: FramingState, event: RunEvent.RunEvent, - stderrLine: S.NullOr(S.String), + stderr: S.NullOr(StderrOutput), }) { readonly [FrameRunEventTypeId] = FrameRunEventTypeId } @@ -44,7 +50,7 @@ export class EventSuppressed extends S.TaggedClass()( 'EventSuppressed', { state: FramingState, - stderrLine: S.NullOr(S.String), + stderr: S.NullOr(StderrOutput), }, ) { readonly [FrameRunEventTypeId] = FrameRunEventTypeId @@ -111,19 +117,21 @@ const formatTotal = (total: number | null): string => onSome: (val) => String(val), }) -const formatStderrEvent = (event: RunEvent.RunEvent): string | null => +const text = (line: string): StderrOutput => ({ _tag: 'StderrText', text: line }) + +const formatStderrEvent = (event: RunEvent.RunEvent): StderrOutput | null => Match.value(event).pipe( - Match.tag('plan', (e) => `plan ${e.total} mutants`), - Match.tag('phase', (e) => `phase ${e.phase}`), + Match.tag('plan', (e) => text(`plan ${e.total} mutants`)), + Match.tag('phase', (e) => text(`phase ${e.phase}`)), Match.tag( 'tick', - (e) => `${e.completed}/${formatTotal(e.total)} elapsed ${e.elapsedMs}ms`, + (e) => text(`${e.completed}/${formatTotal(e.total)} elapsed ${e.elapsedMs}ms`), ), Match.tag( 'verdict', - (e) => `score ${formatScore(e.score)} killed ${e.counts.killed} survived ${e.counts.survived}`, + (e) => text(`score ${formatScore(e.score)} killed ${e.counts.killed} survived ${e.counts.survived}`), ), - Match.tag('error', (e) => `error ${e.error}`), + Match.tag('error', (e): StderrOutput => ({ _tag: 'StderrRecord', record: e.record })), Match.tag('stream', () => null), Match.tag('mutantTested', () => null), Match.tag('help', () => null), @@ -134,7 +142,7 @@ const formatStderrEvent = (event: RunEvent.RunEvent): string | null => Match.exhaustive, ) -const stderrLineFor = (state: FramingState, event: RunEvent.RunEvent): string | null => +const stderrFor = (state: FramingState, event: RunEvent.RunEvent): StderrOutput | null => Boolean.match(Boolean.and(state.mode === 'human', !state.terminalSeen), { onTrue: () => formatStderrEvent(event), onFalse: () => null, @@ -146,7 +154,7 @@ const decideFrame = ( command: FrameRunEventCommand, ): Result.Result => { const nextState = noteState(command.state, command.event) - const stderrLine = stderrLineFor(command.state, command.event) + const stderr = stderrFor(command.state, command.event) const framed = shouldFrame(command.state) return Match.value(framed).pipe( @@ -155,14 +163,14 @@ const decideFrame = ( EventFramed.make({ state: nextState, event: command.event, - stderrLine, + stderr, }), )), Match.when(false, () => Result.succeed( EventSuppressed.make({ state: nextState, - stderrLine, + stderr, }), )), Match.exhaustive, diff --git a/packages/stryker-js/src/gate-new-survivors.workflow.ts b/packages/stryker-js/src/gate-new-survivors.workflow.ts index b402569d..741fc463 100644 --- a/packages/stryker-js/src/gate-new-survivors.workflow.ts +++ b/packages/stryker-js/src/gate-new-survivors.workflow.ts @@ -1,5 +1,6 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' -import { Mutant, Plugin } from '@systemfsoftware/stryker-js-plugin-interface' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' +import { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' import * as Arr from 'effect/Array' import * as Boolean from 'effect/Boolean' import * as HashSet from 'effect/HashSet' @@ -19,9 +20,6 @@ export const GateEntry = S.Struct({ export type GateEntry = typeof GateEntry.Type -const GATE_EXIT_CLASS = 'VerdictFail' satisfies Plugin.ExitClass -const GATE_REMEDIATION = 'kill the new survivors, or accept them with `stryker gate --update-baseline`' - /** * R28: `Survived` and `NoCoverage` are survivors; a `Timeout` or a runtime * error is not. Written out here rather than read off `SurvivedStatusSchema` @@ -55,28 +53,33 @@ export class GateNewSurvivorsCommand extends S.TaggedClass()('GateRejected', { - newSurvivors: S.Array(GateEntry), + newSurvivors: S.NonEmptyArray(GateEntry), unchecked: Mutant.MutantId.pipe(S.Array), }) { - readonly exitClass = GATE_EXIT_CLASS + get evidence(): FailureRecord.FailureEvidence { + return { + _tag: 'NewSurvivors', + stage: 'gate', + survivors: Arr.map(this.newSurvivors, (entry) => ({ + mutantId: entry.id, + file: entry.fileName, + line: entry.line, + })), + unchecked: this.unchecked.length, + } + } override get message(): string { - return [ - `stryker gate: ${this.newSurvivors.length} new survivor(s) absent from the committed baseline:`, - ...this.newSurvivors.map((entry) => ` ${entry.fileName}:${entry.line} ${entry.id}`), - ...Option.match(Option.liftPredicate(this.unchecked, (unchecked) => unchecked.length > 0), { - onNone: (): ReadonlyArray => [], - onSome: (unchecked) => [`${unchecked.length} mutant(s) unchecked`], - }), - GATE_REMEDIATION, - ].join('\n') + return `stryker gate: ${this.newSurvivors.length} new survivor(s) absent from the committed baseline` } } export class GateInputUnusable extends S.TaggedError()('GateInputUnusable', { reason: S.String, }) { - readonly exitClass = 'ConfigError' satisfies Plugin.ExitClass + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'ConfigInvalid', stage: 'gate', detail: this.reason } + } override get message(): string { return `stryker gate: ${this.reason}` @@ -130,9 +133,9 @@ const clearedOrRejected = ( ): Result.Result => { const unchecked = uncheckedIdsOf(command.entries) const newSurvivors = newSurvivorsOf(command.entries, committed) - return Option.match(Arr.head(newSurvivors), { - onNone: () => Result.succeed(GateCleared.make({ baseline: null, unchecked })), - onSome: () => Result.fail(GateRejected.make({ newSurvivors, unchecked })), + return Arr.match(newSurvivors, { + onEmpty: () => Result.succeed(GateCleared.make({ baseline: null, unchecked })), + onNonEmpty: (survivors) => Result.fail(GateRejected.make({ newSurvivors: survivors, unchecked })), }) } diff --git a/packages/stryker-js/src/interpret-dry-run-observation.workflow.ts b/packages/stryker-js/src/interpret-dry-run-observation.workflow.ts index 283fdcd6..fe7b742e 100644 --- a/packages/stryker-js/src/interpret-dry-run-observation.workflow.ts +++ b/packages/stryker-js/src/interpret-dry-run-observation.workflow.ts @@ -1,4 +1,5 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' import * as Match from 'effect/Match' import * as Option from 'effect/Option' @@ -12,8 +13,6 @@ export class DryRunObservation extends S.TaggedClass()('DryRu static readonly [Workflow.InstrumentationBrand] = {} as const } -const FailureSummarySchema = S.Struct({ name: S.String, failureMessage: S.String }) - const DryRunObservationDecisionTypeId: unique symbol = Symbol.for( '@systemfsoftware/stryker-js/DryRunObservationDecision', ) @@ -22,7 +21,7 @@ type DryRunObservationDecisionTypeId = typeof DryRunObservationDecisionTypeId export class DryRunObservedComplete extends S.TaggedClass()('DryRunObservedComplete', { testCount: S.Finite, failedTestCount: S.Finite, - failedTests: S.Array(FailureSummarySchema), + failedTests: S.Array(FailureRecord.FailedTestEvidence), }) { readonly [DryRunObservationDecisionTypeId] = DryRunObservationDecisionTypeId } @@ -41,12 +40,21 @@ export class DryRunObservedTimedOut extends S.TaggedClass(value: A | undefined): A | null => Option.fromUndefinedOr(value).pipe(Option.getOrNull) + +const failedTestEvidenceOf = (test: TestRunner.FailedTestResult): FailureRecord.FailedTestEvidence => ({ + id: test.id, + name: test.name, + file: orNullOf(test.fileName), + location: orNullOf(test.location), + message: test.failureMessage, + stack: orNullOf(test.stack), +}) + +const failedTestEvidencesOf = ( tests: readonly TestRunner.TestResult[], -): readonly (typeof FailureSummarySchema.Type)[] => - tests - .filter((test): test is TestRunner.FailedTestResult => test.status === 'failed') - .map((test) => ({ name: test.name, failureMessage: test.failureMessage })) +): readonly FailureRecord.FailedTestEvidence[] => + tests.filter((test): test is TestRunner.FailedTestResult => test.status === 'failed').map(failedTestEvidenceOf) const decide = (command: DryRunObservation): Result.Result => Match.value(command.dryRunResult).pipe( @@ -55,7 +63,7 @@ const decide = (command: DryRunObservation): Result.Result test.status === 'failed').length, - failedTests: failedTestSummariesOf(complete.tests), + failedTests: failedTestEvidencesOf(complete.tests), }), )), Match.discriminator('status')( diff --git a/packages/stryker-js/src/mutation-reporting.service.ts b/packages/stryker-js/src/mutation-reporting.service.ts index c36ab4e1..a0d1180f 100644 --- a/packages/stryker-js/src/mutation-reporting.service.ts +++ b/packages/stryker-js/src/mutation-reporting.service.ts @@ -415,11 +415,10 @@ const reportMutantOf = ( ...timeoutFieldsOf(mutant, evidence), }) -const reportTestOf = (test: TestRunner.TestResult, remap: TestIdRemap) => - Option.match(Option.fromUndefinedOr(test.startPosition), { - onNone: () => ({ id: remap.testId(test.id), name: test.name }), - onSome: (start) => ({ id: remap.testId(test.id), name: test.name, location: { start } }), - }) +const reportTestOf = (test: TestRunner.TestResult, remap: TestIdRemap) => ({ + id: remap.testId(test.id), + name: test.name, +}) const groupMutants = (input: FileResultsInput): Effect.Effect> => Stream.fromIterable(input.mutants).pipe( diff --git a/packages/stryker-js/src/plan-run-conclusion.workflow.ts b/packages/stryker-js/src/plan-run-conclusion.workflow.ts index 5f9e2652..137aa805 100644 --- a/packages/stryker-js/src/plan-run-conclusion.workflow.ts +++ b/packages/stryker-js/src/plan-run-conclusion.workflow.ts @@ -1,106 +1,77 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as Boolean from 'effect/Boolean' +import * as Match from 'effect/Match' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -import { - RunConfigFailed, - RunFailed, - RunInterrupted, - RunParseFailed, - RunSurvivorsRejected, -} from './classify-run-outcome.workflow.js' -import { RunOutcomeCommand } from './RunOutcomeCommand.schema.js' +import { type RunFailed, RunOk, RunOutcomeDecision, type RunVerdictFailed } from './classify-run-outcome.workflow.js' const PlanRunConclusionTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-js/PlanRunConclusion') type PlanRunConclusionTypeId = typeof PlanRunConclusionTypeId -export const FailedRunOutcomeSchema = S.Union([ - RunParseFailed, - RunSurvivorsRejected, - RunConfigFailed, - RunFailed, - RunInterrupted, -]) - -export const RunOutcomeTag = S.Literals([ - 'RunOk', - 'RunParseFailed', - 'RunSurvivorsRejected', - 'RunConfigFailed', - 'RunFailed', - 'RunInterrupted', -]) -export type RunOutcomeTag = typeof RunOutcomeTag.Type - export class PlanRunConclusionCommand extends S.TaggedClass()('PlanRunConclusionCommand', { - command: RunOutcomeCommand, + decision: RunOutcomeDecision, machine: S.Boolean, - exitCode: Plugin.ExitCode, - outcome: RunOutcomeTag, - error: S.String, }) { static readonly [Workflow.InstrumentationBrand] = { machine: 'stryker.run_conclusion.machine', - exitCode: 'stryker.run.exit_code', - outcome: 'stryker.run.outcome', - error: 'stryker.run.error', } as const } export class RunConclusionEmittedOk extends S.TaggedClass()('RunConclusionEmittedOk', { - command: RunOutcomeCommand, + ok: RunOk, }) { readonly [PlanRunConclusionTypeId] = PlanRunConclusionTypeId } -export class RunConclusionEmittedFailed extends S.TaggedClass()( - 'RunConclusionEmittedFailed', - { - command: RunOutcomeCommand, - exitCode: Plugin.ExitCode, - }, -) { +export class RunConclusionQuietOk extends S.TaggedClass()('RunConclusionQuietOk', {}) { readonly [PlanRunConclusionTypeId] = PlanRunConclusionTypeId } -export class RunConclusionQuietOk extends S.TaggedClass()('RunConclusionQuietOk', {}) { +export class RunConclusionVerdictFailed extends S.TaggedClass()( + 'RunConclusionVerdictFailed', + { exitCode: Plugin.ExitCode }, +) { readonly [PlanRunConclusionTypeId] = PlanRunConclusionTypeId } -export class RunConclusionQuietFailed extends S.TaggedClass()('RunConclusionQuietFailed', { +export class RunConclusionFailed extends S.TaggedClass()('RunConclusionFailed', { exitCode: Plugin.ExitCode, + record: FailureRecord.FailureRecord, }) { readonly [PlanRunConclusionTypeId] = PlanRunConclusionTypeId } export const PlanRunConclusionDecision = S.Union([ RunConclusionEmittedOk, - RunConclusionEmittedFailed, RunConclusionQuietOk, - RunConclusionQuietFailed, + RunConclusionVerdictFailed, + RunConclusionFailed, ]) export type PlanRunConclusionDecision = typeof PlanRunConclusionDecision.Type -const emittedOf = (command: PlanRunConclusionCommand): PlanRunConclusionDecision => - Boolean.match(command.exitCode === 0, { - onTrue: () => RunConclusionEmittedOk.make({ command: command.command }), - onFalse: () => RunConclusionEmittedFailed.make({ command: command.command, exitCode: command.exitCode }), +const okConclusionOf = (ok: RunOk, machine: boolean): PlanRunConclusionDecision => + Boolean.match(machine, { + onTrue: () => RunConclusionEmittedOk.make({ ok }), + onFalse: () => RunConclusionQuietOk.make({}), }) -const quietOf = (command: PlanRunConclusionCommand): PlanRunConclusionDecision => - Boolean.match(command.exitCode === 0, { - onTrue: () => RunConclusionQuietOk.make({}), - onFalse: () => RunConclusionQuietFailed.make({ exitCode: command.exitCode }), - }) +const verdictConclusionOf = (failed: RunVerdictFailed): PlanRunConclusionDecision => + RunConclusionVerdictFailed.make({ exitCode: failed.code }) + +const failedConclusionOf = (failed: RunFailed): PlanRunConclusionDecision => + RunConclusionFailed.make({ exitCode: failed.code, record: failed.record }) const decide = (command: PlanRunConclusionCommand): Result.Result => Result.succeed( - Boolean.match(command.machine, { - onTrue: () => emittedOf(command), - onFalse: () => quietOf(command), - }), + Match.value(command.decision).pipe( + Match.tag('RunOk', (ok) => okConclusionOf(ok, command.machine)), + Match.tag('RunVerdictFailed', verdictConclusionOf), + Match.tag('RunFailed', failedConclusionOf), + Match.exhaustive, + ), ) export const planRunConclusion = Workflow.make({ diff --git a/packages/stryker-js/src/plugin-load-report.service.ts b/packages/stryker-js/src/plugin-load-report.service.ts index 51efa5d5..f0e71707 100644 --- a/packages/stryker-js/src/plugin-load-report.service.ts +++ b/packages/stryker-js/src/plugin-load-report.service.ts @@ -1,6 +1,5 @@ import { RunEvent, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import { Format } from '@systemfsoftware/stryker-js-instrumenter' -import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as Array from 'effect/Array' import type * as Cause from 'effect/Cause' import * as Effect from 'effect/Effect' @@ -8,50 +7,11 @@ import { dual } from 'effect/Function' import * as MutableHashMap from 'effect/MutableHashMap' import * as Option from 'effect/Option' import * as Queue from 'effect/Queue' -import * as S from 'effect/Schema' import type { LoadedPlugins } from './Plugins.schema.js' -import { type PluginLoadRefusedError } from './PluginsError.schema.js' -const PLUGIN_FAILURE_REMEDIATION: Record = { - PeerMissing: 'install the peer dependency the plugin needs', - PeerVersionUnsupported: 'install a supported version of the peer dependency', - PeerUnrecognized: 'install a peer version the plugin recognizes, or a matching plugin version', - InvalidContribution: 'fix the contribution the plugin declares', - ImportFailed: 'fix the plugin so that it imports cleanly', -} - -const exitCodeOfClass = (exitClass: Plugin.ExitClass): Effect.Effect => - Effect.orDie(S.decodeEffect(Plugin.ExitCodeFromClass)(exitClass)) - -export const pluginLoadFailureEvents: { - ( - elapsedMs: number, - ): (error: PluginLoadRefusedError) => Effect.Effect - ( - error: PluginLoadRefusedError, - elapsedMs: number, - ): Effect.Effect -} = dual( - 2, - ( - error: PluginLoadRefusedError, - elapsedMs: number, - ): Effect.Effect => - Effect.map( - exitCodeOfClass(error.exitClass), - (code): readonly [RunEvent.PhaseEntered, RunEvent.RunFailed] => [ - RunEvent.PhaseEntered.make({ phase: 'prepare', elapsedMs }), - RunEvent.RunFailed.make({ - schemaVersion: RunEvent.StreamSchemaVersion.literal, - code, - error: error.message, - remediation: PLUGIN_FAILURE_REMEDIATION[error.reason._tag], - reason: error.reason, - }), - ], - ), -) +export const pluginLoadPhaseEvent = (elapsedMs: number): RunEvent.PhaseEntered => + RunEvent.PhaseEntered.make({ phase: 'prepare', elapsedMs }) type FrameworkContributionModule = LoadedPlugins['frameworks'][number] diff --git a/packages/stryker-js/src/render-annotations.workflow.ts b/packages/stryker-js/src/render-annotations.workflow.ts index c757e73e..cf2a199c 100644 --- a/packages/stryker-js/src/render-annotations.workflow.ts +++ b/packages/stryker-js/src/render-annotations.workflow.ts @@ -1,5 +1,6 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' -import { Mutant, Plugin, Report } from '@systemfsoftware/stryker-js-plugin-interface' +import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' +import { Mutant, Report } from '@systemfsoftware/stryker-js-plugin-interface' import * as Arr from 'effect/Array' import * as Boolean from 'effect/Boolean' import * as HashMap from 'effect/HashMap' @@ -27,7 +28,9 @@ const decodeMutantStatus = S.decodeOption(MUTANT_STATUSES) export class AnnotationsUnusable extends S.TaggedError()('AnnotationsUnusable', { reason: S.String, }) { - readonly exitClass = 'ConfigError' satisfies Plugin.ExitClass + get evidence(): FailureRecord.FailureEvidence { + return { _tag: 'ConfigInvalid', stage: 'config', detail: this.reason } + } override get message(): string { return `stryker annotate: ${this.reason}` diff --git a/packages/stryker-js/src/reporter-stream.service.ts b/packages/stryker-js/src/reporter-stream.service.ts index 787b6fc9..67c085ea 100644 --- a/packages/stryker-js/src/reporter-stream.service.ts +++ b/packages/stryker-js/src/reporter-stream.service.ts @@ -257,6 +257,7 @@ export const spawnReporterWorker = ( workingDirectory: params.projectBasePath, execArgv: [...params.execArgv], tempDirPrefix: params.tempDirPrefix, + workerKind: 'reporter', }) const warnEventDropped = (attachment: ReporterAttachment): Effect.Effect => diff --git a/packages/stryker-js/src/reporter-wiring.service.ts b/packages/stryker-js/src/reporter-wiring.service.ts index 57f085a8..29278071 100644 --- a/packages/stryker-js/src/reporter-wiring.service.ts +++ b/packages/stryker-js/src/reporter-wiring.service.ts @@ -12,7 +12,7 @@ import * as Scope from 'effect/Scope' import type { LoadedPlugins } from './Plugins.schema.js' import { PluginNotFoundError } from './PluginsError.schema.js' import { type AttachReporterInput, reporterWorkerFactory, spawnReporterWorker } from './reporter-stream.service.js' -import { StageError } from './Run.schema.js' +import { RunFailure } from './Run.schema.js' import { ConfiguredPluginName, resolveConfiguredPlugin, @@ -33,7 +33,7 @@ const spawnPluginReporterFactory = Effect.fn(SpanTaxonomy.Spans.reporterWiringSp options: Options.StrykerOptions, ): Effect.fn.Return< InterfaceReporter.ReporterFactory, - StageError, + RunFailure, Scope.Scope | WorkerLauncher | FileSystem.FileSystem | Path.Path > { const entry = yield* Effect.mapError( @@ -47,9 +47,9 @@ const spawnPluginReporterFactory = Effect.fn(SpanTaxonomy.Spans.reporterWiringSp ), ), (missing) => - StageError.make({ - stage: 'prepare', - reason: missing.reason, + RunFailure.make({ + evidence: { _tag: 'PluginNotFound', stage: 'config', descriptor: missing.descriptor }, + detail: missing.reason, cause: PluginNotFoundError.make({ descriptor: missing.descriptor }), }), ) @@ -61,7 +61,11 @@ const spawnPluginReporterFactory = Effect.fn(SpanTaxonomy.Spans.reporterWiringSp tempDirPrefix: 'stryker-reporter-', }).pipe( Effect.mapError((cause) => - StageError.make({ stage: 'prepare', reason: `Failed to start the reporter worker "${name}"`, cause }) + RunFailure.make({ + evidence: { _tag: 'ReporterFailed', stage: 'report', reporter: name }, + detail: `Failed to start the reporter worker "${name}"`, + cause, + }) ), ) return reporterWorkerFactory(client) @@ -96,7 +100,7 @@ export const reporterInputsOf: { names: readonly string[], ) => Effect.Effect< readonly AttachReporterInput[], - StageError, + RunFailure, Scope.Scope | WorkerLauncher | FileSystem.FileSystem | Path.Path > ( @@ -107,7 +111,7 @@ export const reporterInputsOf: { options: Options.StrykerOptions, ): Effect.Effect< readonly AttachReporterInput[], - StageError, + RunFailure, Scope.Scope | WorkerLauncher | FileSystem.FileSystem | Path.Path > } = dual( @@ -120,7 +124,7 @@ export const reporterInputsOf: { options: Options.StrykerOptions, ): Effect.Effect< readonly AttachReporterInput[], - StageError, + RunFailure, Scope.Scope | WorkerLauncher | FileSystem.FileSystem | Path.Path > => Effect.forEach( diff --git a/packages/stryker-js/src/reporting/run-failure.schema.ts b/packages/stryker-js/src/reporting/run-failure.schema.ts index 445f1f07..cfcd77ad 100644 --- a/packages/stryker-js/src/reporting/run-failure.schema.ts +++ b/packages/stryker-js/src/reporting/run-failure.schema.ts @@ -1,18 +1,6 @@ -import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as S from 'effect/Schema' -export class ErrorEnvelope extends S.Class('ErrorEnvelope')({ - schemaVersion: RunEvent.StreamSchemaVersion, - code: Plugin.ExitCode, - error: S.NonEmptyString, - remediation: S.NonEmptyString, -}) { - get envelopeText(): string { - return this.error - } -} - export class RunExitCode extends S.Class('RunExitCode')({ code: Plugin.ExitCode, }) {} diff --git a/packages/stryker-js/src/reporting/run-failure.ts b/packages/stryker-js/src/reporting/run-failure.ts index f9fa997d..2b6d3374 100644 --- a/packages/stryker-js/src/reporting/run-failure.ts +++ b/packages/stryker-js/src/reporting/run-failure.ts @@ -1,81 +1,16 @@ -import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' -import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' +import type { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import * as Match from 'effect/Match' -import * as Option from 'effect/Option' -import * as S from 'effect/Schema' -import type { FailedRunOutcome, RunOutcomeDecision, RunOutcomeError } from '../classify-run-outcome.workflow.js' -import { ErrorEnvelope, RunExitCode } from './run-failure.schema.js' +import type { RunOutcomeDecision } from '../classify-run-outcome.workflow.js' +import { RunExitCode } from './run-failure.schema.js' -const CONFIG_CODE = 2 -const UNKNOWN_FAILURE = 'Unknown failure' -const SIGNAL_REMEDIATION = 'the run was interrupted by a signal; re-run it to continue' -const PARSE_REMEDIATION = 're-run with --help to see the full usage' -const DEFAULT_REMEDIATION = 'see --reportFile or the verdict envelope on stdout' - -export const exitCodeOf = (outcome: RunOutcomeDecision | RunOutcomeError): Plugin.ExitCode => +export const exitCodeOf = (outcome: RunOutcomeDecision): Plugin.ExitCode => Match.value(outcome).pipe( Match.tag('RunOk', () => 0), - Match.tag('RunInterrupted', (error) => error.code), - Match.tag('RunParseFailed', () => CONFIG_CODE), - Match.tag('RunSurvivorsRejected', () => CONFIG_CODE), - Match.tag('RunConfigFailed', () => CONFIG_CODE), + Match.tag('RunVerdictFailed', (failed) => failed.code), Match.tag('RunFailed', (failed) => failed.code), Match.exhaustive, ) -const nonEmptyText = Option.liftPredicate(S.is(S.NonEmptyString)) - -const capturedOrUnknown = (captured: string) => Option.getOrElse(nonEmptyText(captured), () => UNKNOWN_FAILURE) - -const capturedThenRecorded = (captured: string, recorded: string | undefined) => - Option.getOrElse( - nonEmptyText(captured), - () => Option.getOrElse(Option.fromNullishOr(recorded), () => UNKNOWN_FAILURE), - ) - -const failureTextOf = (error: FailedRunOutcome, captured: string) => - Match.value(error).pipe( - Match.tag('RunParseFailed', (failed) => - Option.getOrElse( - Option.map(Option.fromNullishOr(failed.unrecognized), (value) => `Received unknown argument: '${value}'`), - () => capturedOrUnknown(captured), - )), - Match.tag('RunSurvivorsRejected', (failed) => - Option.getOrElse(Option.fromNullishOr(failed.diagnostic), () => UNKNOWN_FAILURE)), - Match.tag('RunInterrupted', () => - capturedOrUnknown(captured)), - Match.tag('RunConfigFailed', (failed) => capturedThenRecorded(captured, failed.detail)), - Match.tag('RunFailed', (failed) => capturedThenRecorded(captured, failed.diagnostic)), - Match.exhaustive, - ) - -const remediationTextOf = (error: FailedRunOutcome) => - Match.value(error).pipe( - Match.tag('RunInterrupted', () => SIGNAL_REMEDIATION), - Match.tag('RunParseFailed', () => PARSE_REMEDIATION), - Match.tag('RunSurvivorsRejected', (failed) => - Option.getOrElse(Option.fromNullishOr(failed.diagnostic), () => DEFAULT_REMEDIATION)), - Match.tag('RunConfigFailed', (failed) => - Option.match(Option.fromNullishOr(failed.detail), { - onSome: (detail) => - `check the config file: ${detail}`, - onNone: () => 'check the config file', - })), - Match.tag('RunFailed', () => DEFAULT_REMEDIATION), - Match.exhaustive, - ) - -export const errorEnvelopeFromOutcome = (input: { - readonly error: FailedRunOutcome - readonly captured: string -}): ErrorEnvelope => - ErrorEnvelope.make({ - schemaVersion: RunEvent.StreamSchemaVersion.literal, - code: exitCodeOf(input.error), - error: failureTextOf(input.error, input.captured), - remediation: remediationTextOf(input.error), - }) - -export const runExitCodeFromOutcome = (outcome: RunOutcomeDecision | RunOutcomeError): RunExitCode => +export const runExitCodeFromOutcome = (outcome: RunOutcomeDecision): RunExitCode => RunExitCode.make({ code: exitCodeOf(outcome) }) diff --git a/packages/stryker-js/src/run-event-stream.service.ts b/packages/stryker-js/src/run-event-stream.service.ts index 32976f62..3c433e30 100644 --- a/packages/stryker-js/src/run-event-stream.service.ts +++ b/packages/stryker-js/src/run-event-stream.service.ts @@ -18,9 +18,10 @@ import * as Stdio from 'effect/Stdio' import * as Stream from 'effect/Stream' import * as SynchronizedRef from 'effect/SynchronizedRef' -import { RunEvent, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, RunEvent, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import type { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import { Report } from '@systemfsoftware/stryker-js-plugin-interface' -import type { FailedRunOutcome, RunOk, RunOutcomeDecision, RunOutcomeError } from './classify-run-outcome.workflow.js' +import type { RunOk } from './classify-run-outcome.workflow.js' import { defaultOptions } from './config/default-options.js' import { frameRunEvent, @@ -31,12 +32,19 @@ import { } from './frame-run-event.workflow.js' import type { ResolvedMode } from './output-mode.schema.js' import { MachineConsole } from './reporting/machine-console.service.js' -import { errorEnvelopeFromOutcome } from './reporting/run-failure.js' import { buildVerdictEnvelope, generateRunId } from './reporting/verdict-envelope.js' import { StrykerPackage } from './stryker-package.schema.js' export type { ResolvedModeInput } from './frame-run-event.workflow.js' +type RunOkFacts = (typeof RunOk)['Encoded'] + +const stderrTextOf = (stderr: NonNullable): string => + Match.valueTags(stderr, { + StderrText: ({ text }) => text, + StderrRecord: ({ record }) => FailureRecord.terminalTextOf(record), + }) + export const TICK_INTERVAL_MS = 10_000 export const initialFramingState = (resolved: ResolvedModeInput) => @@ -222,7 +230,7 @@ export interface EmitNullScoreVerdictOptions { export interface EmitMachineModeOutputOptions { readonly stream: RunEventStream readonly mode: ResolvedMode - readonly outcome: Result.Result + readonly ok: RunOkFacts readonly basePath: string readonly pathService: Path.Path } @@ -266,23 +274,15 @@ const emitNullScoreVerdict = (params: EmitNullScoreVerdictOpti ) } -const offerFailureEnvelope = ( +const emitFailureRecord = ( stream: RunEventStream, - failed: FailedRunOutcome, - captured: string, -): Effect.Effect => { - const envelope = errorEnvelopeFromOutcome({ error: failed, captured }) - return Queue.offer( + code: Plugin.ExitCode, + record: FailureRecord.FailureRecord, +): Effect.Effect => + Queue.offer( stream.queue, - RunEvent.RunFailed.make({ - schemaVersion: envelope.schemaVersion, - code: envelope.code, - error: envelope.error, - remediation: envelope.remediation, - reason: null, - }), - ) -} + RunEvent.RunFailed.make({ schemaVersion: RunEvent.StreamSchemaVersion.literal, code, record }), + ).pipe(Effect.asVoid) const emitHelpEnvelope = (stream: RunEventStream, help: string): Effect.Effect => Queue.offer( @@ -294,7 +294,7 @@ const emitHelpEnvelope = (stream: RunEventStream, help: string): Effect.Effect => +const helpPayload = (ok: RunOkFacts, captured: string): Option.Option => Boolean.match(ok.help || captured.length > 0, { onTrue: () => Option.some(captured), onFalse: () => Option.none(), @@ -332,29 +332,11 @@ const emitNullScoreVerdictWhenOpen = ( const emitMachineModeOutput = Effect.fn(SpanTaxonomy.Spans.runEventStreamEmitMachineModeOutput.name)(function*( params: EmitMachineModeOutputOptions, ) { - const { stream, mode, outcome, basePath, pathService } = params + const { stream, mode, ok, basePath, pathService } = params const captured = (yield* MachineConsole).read() - return yield* Result.match(outcome, { - onSuccess: (decision) => - Match.value(decision).pipe( - Match.tag('RunOk', (ok): Effect.Effect => - Option.match(helpPayload(ok, captured), { - onSome: (help) => emitHelpEnvelope(stream, help), - onNone: () => - emitNullScoreVerdictWhenOpen( - stream, - mode, - basePath, - pathService, - ), - })), - Match.tag('RunParseFailed', (failed) => offerFailureEnvelope(stream, failed, captured)), - Match.tag('RunSurvivorsRejected', (failed) => offerFailureEnvelope(stream, failed, captured)), - Match.tag('RunConfigFailed', (failed) => offerFailureEnvelope(stream, failed, captured)), - Match.tag('RunFailed', (failed) => offerFailureEnvelope(stream, failed, captured)), - Match.exhaustive, - ), - onFailure: (failure) => offerFailureEnvelope(stream, failure, captured), + return yield* Option.match(helpPayload(ok, captured), { + onSome: (help) => emitHelpEnvelope(stream, help), + onNone: () => emitNullScoreVerdictWhenOpen(stream, mode, basePath, pathService), }) }) @@ -366,6 +348,11 @@ export interface RunEventStreamPort { params: EmitNullScoreVerdictOptions, ) => Effect.Effect readonly emitMachineModeOutput: (params: EmitMachineModeOutputOptions) => Effect.Effect + readonly emitFailureRecord: ( + stream: RunEventStream, + code: Plugin.ExitCode, + record: FailureRecord.FailureRecord, + ) => Effect.Effect } export class RunEventStreamPortTag extends Context.Service()( @@ -377,6 +364,7 @@ export class RunEventStreamPortTag extends Context.Service makeRunEventStream(resolved), emitNullScoreVerdict, emitMachineModeOutput, + emitFailureRecord, }), ) } @@ -473,9 +461,9 @@ export const makeRunEventStream = Effect.fn(SpanTaxonomy.Spans.runEventStreamMak Option.match(decision, { onNone: () => Effect.void, onSome: (d) => - Option.match(Option.fromNullishOr(d.stderrLine), { + Option.match(Option.fromNullishOr(d.stderr), { onNone: () => Effect.void, - onSome: (line) => writeStderr(stdio, line), + onSome: (stderr) => writeStderr(stdio, stderrTextOf(stderr)), }), }) ), diff --git a/packages/stryker-js/src/run-request.cell.ts b/packages/stryker-js/src/run-request.cell.ts index cef62d81..037e6910 100644 --- a/packages/stryker-js/src/run-request.cell.ts +++ b/packages/stryker-js/src/run-request.cell.ts @@ -344,12 +344,6 @@ const writeDecidedBaseline = ( const reportUnchecked = (unchecked: ReadonlyArray): Effect.Effect => Effect.logInfo(`stryker gate: ${unchecked.length} mutant(s) unchecked (in scope with no verdict)`) -const GATE_REMEDIATION_LINE = - 'accept the new survivors with `stryker gate --update-baseline`, or kill them before the next run' - -const explainGateRefusal = (failure: GateRejected | GateInputUnusable): Effect.Effect => - Effect.andThen(Effect.logError(failure.message), Effect.logInfo(GATE_REMEDIATION_LINE)) - const gateReport = ( gate: { readonly baseline: string; readonly updateBaseline: boolean }, channel: CliRead, @@ -499,7 +493,7 @@ export const runRequestCell = Sandwich.named(SpanTaxonomy.Spans.runRequest.name) mode: channel.environment.mode.mode, }), CliCompareRequested: (compare) => compareReports(compare), - CliGateRequested: (gate, channel) => gateReport(gate, channel).pipe(Effect.tapError(explainGateRefusal)), + CliGateRequested: (gate, channel) => gateReport(gate, channel), CliAnnotateRequested: (annotate, channel) => annotateReport(annotate, channel), CliFeedbackRequested: (feedback, channel) => feedbackRoute(feedback, channel), CliMcpRequested: (_, channel) => diff --git a/packages/stryker-js/src/run/dry-run.cell.ts b/packages/stryker-js/src/run/dry-run.cell.ts index 9031fdfd..a7cbe925 100644 --- a/packages/stryker-js/src/run/dry-run.cell.ts +++ b/packages/stryker-js/src/run/dry-run.cell.ts @@ -1,7 +1,7 @@ import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' import { type Cell, Sandwich } from '@systemfsoftware/effect-cell-types' -import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import type { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' import { Options, type Plugin, Reporter, type TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' import * as Arr from 'effect/Array' @@ -28,7 +28,7 @@ import { DryRunReuseCommand, type DryRunReuseDecision, } from '../dry-run-reuse.workflow.js' -import { dryRun, DryRunCommand, DryRunError, DryRunFailed, FailedTestSummary } from '../dry-run.workflow.js' +import { dryRun, DryRunCommand } from '../dry-run.workflow.js' import { analyzeImportClosure, type ImportClosureAnalysis } from '../import-closure.cell.js' import { DryRunObservation, @@ -39,7 +39,7 @@ import type { LoadedPlugins } from '../Plugins.schema.js' import { PluginNotFoundError } from '../PluginsError.schema.js' import { offerReporterEvent, withPhaseSpan } from '../reporter-stream.service.js' import type { RunEvents } from '../run-events.service.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { originalFileFor, sandboxFileFor, type SandboxHandle } from '../Sandbox.handle.js' import type { TestCoverage } from '../test-coverage.schema.js' import { buildTestRunner, makeChildProcessTestRunner } from '../TestRunner.blueprint.js' @@ -92,19 +92,18 @@ const configuredPluginOf = (configured: string | { readonly plugin: string }) => ) const workerSpawnOf = ( - stage: StageError['stage'], loaded: Pick, kind: Plugin.WorkerPluginKind, configured: ConfiguredPluginName | ConfiguredPluginModulePath, -): Effect.Effect => +): Effect.Effect => Effect.mapError( Effect.fromResult( resolveConfiguredPlugin(WorkerSpawnCommand.make({ sources: loaded.pluginSources, kind, configured })), ), (missing) => - StageError.make({ - stage, - reason: missing.reason, + RunFailure.make({ + evidence: { _tag: 'PluginNotFound', stage: 'config', descriptor: missing.descriptor }, + detail: missing.reason, cause: PluginNotFoundError.make({ descriptor: missing.descriptor }), }), ) @@ -129,7 +128,13 @@ const buildDryRunFiles = (command: InstrumentDone) => const resolveDryRunFiles = Effect.fn(SpanTaxonomy.Spans.dryRunResolveFiles.name)(function*(command: InstrumentDone) { return yield* Effect.fromResult(buildDryRunFiles(command)).pipe( - Effect.mapError((cause) => StageError.make({ stage: 'dryRun', reason: 'Failed to resolve sandbox files', cause })), + Effect.mapError((cause) => + RunFailure.make({ + evidence: { _tag: 'SandboxPreparationFailed', stage: 'dryRun' }, + detail: 'Failed to resolve sandbox files', + cause, + }) + ), ) }) @@ -548,9 +553,19 @@ const completeDryRunPassed = (raw: DryRunRaw) => Match.value(raw.rawResult).pipe( Match.discriminator('status')('complete', (rawResult) => completeDryRunResultOf(raw, rawResult)), Match.discriminator('status')('error', () => - Effect.fail(StageError.make({ stage: 'dryRun', reason: 'Unexpected dry-run status after decision' }))), + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'dryRun' }, + detail: 'Unexpected dry-run status after decision', + }), + )), Match.discriminator('status')('timeout', () => - Effect.fail(StageError.make({ stage: 'dryRun', reason: 'Unexpected dry-run status after decision' }))), + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'dryRun' }, + detail: 'Unexpected dry-run status after decision', + }), + )), Match.exhaustive, ) @@ -571,7 +586,7 @@ const runFreshDryRun = Effect.fnUntraced(function*( Effect.gen(function*() { const childRunnerEffect = Effect.suspend(() => { const runnerConfigured = command.options.testRunner - return workerSpawnOf('dryRun', command.loadedPlugins, 'TestRunner', configuredPluginOf(runnerConfigured)) + return workerSpawnOf(command.loadedPlugins, 'TestRunner', configuredPluginOf(runnerConfigured)) .pipe( Effect.flatMap((resolved) => makeChildProcessTestRunner({ @@ -598,14 +613,24 @@ const runFreshDryRun = Effect.fnUntraced(function*( ) const capabilities = yield* runner.capabilities.pipe( Effect.mapError((cause) => - StageError.make({ stage: 'dryRun', reason: 'Failed to get test runner capabilities', cause }) + RunFailure.make({ + evidence: { _tag: 'TestRunnerFailed', stage: 'dryRun' }, + detail: 'Failed to get test runner capabilities', + cause, + }) ), ) const timedPass = Effect.timed( runner .dryRun(options) .pipe( - Effect.mapError((cause) => StageError.make({ stage: 'dryRun', reason: 'Dry run failed', cause })), + Effect.mapError((cause) => + RunFailure.make({ + evidence: { _tag: 'TestRunnerFailed', stage: 'dryRun' }, + detail: 'Dry run failed', + cause, + }) + ), ), ) const firstPass = yield* timedPass @@ -624,11 +649,15 @@ const runFreshDryRun = Effect.fnUntraced(function*( Effect.mapError((cause) => Match.value({ cause }).pipe( Match.when( - { cause: (candidate: unknown): candidate is StageError => S.is(StageError)(candidate) }, + { cause: (candidate: unknown): candidate is RunFailure => S.is(RunFailure)(candidate) }, ({ cause }) => cause, ), Match.orElse(({ cause }) => - StageError.make({ stage: 'dryRun', reason: 'Dry run failed to start test runner', cause }) + RunFailure.make({ + evidence: { _tag: 'TestRunnerFailed', stage: 'dryRun' }, + detail: 'Dry run failed to start test runner', + cause, + }) ), ) ), @@ -651,7 +680,7 @@ const runFreshDryRun = Effect.fnUntraced(function*( const readDryRun: (command: InstrumentDone) => Effect.Effect< DryRunRaw, - StageError, + RunFailure, | Scope.Scope | IdGenerator | ChildProcessSpawner.ChildProcessSpawner @@ -718,7 +747,12 @@ const readDryRun: (command: InstrumentDone) => Effect.Effect< Match.tag('DryRunCoverageReused', () => Option.match(prior, { onNone: () => - Effect.die(StageError.make({ stage: 'dryRun', reason: 'Coverage reuse decided without prior coverage' })), + Effect.die( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'dryRun' }, + detail: 'Coverage reuse decided without prior coverage', + }), + ), onSome: (coverage) => Effect.succeed(reusedRawOf(command, coverage)), })), Match.tag( @@ -739,48 +773,68 @@ const writeDryRunPassed = Effect.fn(SpanTaxonomy.Spans.dryRunWritePassed.name)(f const FAILED_TESTS_REASON = 'There were failed tests in the initial test run.' -const failedTestsDetail = (failedTests: readonly FailedTestSummary[]): string => - failedTests - .map((test) => ` ${test.name}${test.failureMessage.length > 0 ? `: ${test.failureMessage}` : ''}`) - .join('\n') +const projectFileOf = (path: Path.Path, sandboxDirectory: string) => (file: string): string => + Option.getOrElse( + Option.filter(Option.some(path.relative(sandboxDirectory, file)), (relative) => !relative.startsWith('..')), + () => file, + ) -const reasonWithFailedTests = (detail: string): string => - detail.length > 0 ? `${FAILED_TESTS_REASON}\n${detail}` : FAILED_TESTS_REASON +const projectTestOf = + (path: Path.Path, sandboxDirectory: string) => + (test: FailureRecord.FailedTestEvidence): FailureRecord.FailedTestEvidence => ({ + ...test, + file: Option.getOrNull(Option.map(Option.fromNullishOr(test.file), projectFileOf(path, sandboxDirectory))), + stack: Option.getOrNull( + Option.map(Option.fromNullishOr(test.stack), (stack) => stack.replaceAll(`${sandboxDirectory}${path.sep}`, '')), + ), + }) const writeDryRunFailed = Effect.fn(SpanTaxonomy.Spans.dryRunWriteFailed.name)(function*({ testCount, - failedTestCount, failedTests, + sandboxDirectory, }: { readonly testCount: number - readonly failedTestCount: number - readonly failedTests: readonly FailedTestSummary[] + readonly failedTests: readonly FailureRecord.FailedTestEvidence[] + readonly sandboxDirectory: string }) { + const path = yield* Path.Path + const projectTests = failedTests.map(projectTestOf(path, sandboxDirectory)) return yield* withPhaseSpan( SpanTaxonomy.Spans.dryRunPhase, {}, () => - Effect.gen(function*() { - const detail = failedTestsDetail(failedTests) - yield* Effect.logError( - `Initial test run failed. ${failedTestCount} of ${testCount} test(s) failed:\n${detail}`, - ) - return yield* StageError.make({ - stage: 'dryRun', - reason: reasonWithFailedTests(detail), - cause: DryRunFailed.make({ testCount, failedTestCount, failedTests }), - }) + Arr.match(projectTests, { + onEmpty: () => + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'dryRun' }, + detail: 'The dry run reported failed tests but carried none', + }), + ), + onNonEmpty: (tests) => + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'BaselineTestsFailed', stage: 'dryRun', testCount, tests }, + detail: FAILED_TESTS_REASON, + }), + ), }), ) }) -export const dryRunCell: Cell.Cell = Sandwich.named( +export const dryRunCell: Cell.Cell = Sandwich.named( SpanTaxonomy.Spans.dryRun.name, )(readDryRun).decide(dryRun).write({ DryRunPassed: (_decision, raw) => writeDryRunPassed(raw), - DryRunFailed: ({ testCount, failedTestCount, failedTests }, _raw) => - writeDryRunFailed({ testCount, failedTestCount, failedTests }), - DryRunError: ({ stage, reason }) => - Effect.fail(StageError.make({ stage, reason, cause: DryRunError.make({ stage, reason }) })), - CommandRejected: ({ issue }) => Effect.fail(StageError.make({ stage: 'dryRun', reason: issue })), + DryRunFailed: ({ testCount, failedTests }, raw) => + writeDryRunFailed({ testCount, failedTests, sandboxDirectory: raw.prev.sandbox.workingDirectory }), + RunFailure: (failure) => Effect.fail(RunFailure.make(failure)), + CommandRejected: ({ issue }) => + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'dryRun' }, + detail: issue, + }), + ), }) diff --git a/packages/stryker-js/src/run/host.service.ts b/packages/stryker-js/src/run/host.service.ts index 9b56e0d4..afc8d461 100644 --- a/packages/stryker-js/src/run/host.service.ts +++ b/packages/stryker-js/src/run/host.service.ts @@ -4,7 +4,7 @@ import type * as Cause from 'effect/Cause' import type * as Effect from 'effect/Effect' import type * as Queue from 'effect/Queue' -import type { StageError } from '../Run.schema.js' +import type { RunFailure } from '../Run.schema.js' import type { MutationTestDone } from './mutation-test.cell.js' import type { RunEnvironmentShape } from './RunEnvironment.service.js' @@ -16,4 +16,4 @@ export interface HostServices { export type StrykerRun = ( options: Options.PartialStrykerOptions, targetMutatePatterns?: string[], -) => Effect.Effect +) => Effect.Effect diff --git a/packages/stryker-js/src/run/incremental-reuse.cell.ts b/packages/stryker-js/src/run/incremental-reuse.cell.ts index 3777db67..c2c49295 100644 --- a/packages/stryker-js/src/run/incremental-reuse.cell.ts +++ b/packages/stryker-js/src/run/incremental-reuse.cell.ts @@ -34,7 +34,7 @@ import { } from '../IncrementalDiff.schema.js' import type { Project } from '../Project.schema.js' import { reportTestIds, ResolveReportTestIds } from '../report-test-ids.workflow.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { originalFileFor, type SandboxHandle } from '../Sandbox.handle.js' import type { TestCoverage } from '../test-coverage.schema.js' import { runInputsDigestOf, VERDICT_SEMANTICS_VERSION } from '../verdict-semantics.js' @@ -477,7 +477,12 @@ const incrementalReuseCell = Sandwich.named(SpanTaxonomy.Spans.incrementalReuse. MutantToRun: (decision, command) => mutantToRunPart(decision, command), MutantRemembered: (decision, command) => rememberedMutantPart(decision, command), CommandRejected: ({ issue }) => - Effect.die(StageError.make({ stage: 'mutationTest', reason: `incremental reuse command rejected: ${issue}` })), + Effect.die( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, + detail: `incremental reuse command rejected: ${issue}`, + }), + ), }) export interface IncrementalReuseInput { diff --git a/packages/stryker-js/src/run/instrument.cell.ts b/packages/stryker-js/src/run/instrument.cell.ts index fdc5a22b..d1f6fe70 100644 --- a/packages/stryker-js/src/run/instrument.cell.ts +++ b/packages/stryker-js/src/run/instrument.cell.ts @@ -20,7 +20,7 @@ import { ProjectFiles } from '../project-files.service.js' import type { Project, ProjectFile } from '../Project.schema.js' import { withPhaseSpan } from '../reporter-stream.service.js' import { RunEvents } from '../run-events.service.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { makeSandbox } from '../Sandbox.blueprint.js' import type { SandboxHandle } from '../Sandbox.handle.js' import { explainFileSkip, ExplainFileSkipCommand, type FrameworkClaimant } from './explain-file-skip.workflow.js' @@ -162,7 +162,11 @@ const readInstrument = Effect.fn(SpanTaxonomy.Spans.instrumentGather.name)(funct (readFiles) => readFiles.map(([file, content]) => ({ content, mutate: file.mutate, name: file.name })), ).pipe( Effect.mapError((cause) => - StageError.make({ stage: 'instrument', reason: 'Failed to read files to mutate', cause }) + RunFailure.make({ + evidence: { _tag: 'InstrumentationFailed', stage: 'instrument' }, + detail: 'Failed to read files to mutate', + cause, + }) ), ) @@ -179,7 +183,13 @@ const readInstrument = Effect.fn(SpanTaxonomy.Spans.instrumentGather.name)(funct optInMutations, ), }, command.formatRegistry).pipe( - Effect.mapError((cause) => StageError.make({ stage: 'instrument', reason: 'Instrumenter failed', cause })), + Effect.mapError((cause) => + RunFailure.make({ + evidence: { _tag: 'InstrumentationFailed', stage: 'instrument' }, + detail: 'Instrumenter failed', + cause, + }) + ), ) const instrumentedProject = withInstrumentedFiles(command.project, instrumentResult.files) @@ -194,7 +204,11 @@ const readInstrument = Effect.fn(SpanTaxonomy.Spans.instrumentGather.name)(funct formatRegistry: command.formatRegistry, }).pipe( Effect.mapError((cause) => - StageError.make({ stage: 'instrument', reason: 'Sandbox initialization failed', cause }) + RunFailure.make({ + evidence: { _tag: 'SandboxPreparationFailed', stage: 'instrument' }, + detail: 'Sandbox initialization failed', + cause, + }) ), ) @@ -216,7 +230,7 @@ const readInstrument = Effect.fn(SpanTaxonomy.Spans.instrumentGather.name)(funct export const instrumentCell: Cell.Cell< PrepareDone & { readonly concurrency: { readonly testRunners: number; readonly checkers: number } }, InstrumentDone, - StageError, + RunFailure, | Scope.Scope | PhaseClock | RunEnvironment @@ -228,7 +242,13 @@ export const instrumentCell: Cell.Cell< > = Sandwich.named(SpanTaxonomy.Spans.instrument.name)(readInstrument).decide(planInstrumentation).write({ InPlaceInstrument: (_decision, raw) => writeInstrument(raw), EphemeralInstrument: (_decision, raw) => writeInstrument(raw), - CommandRejected: ({ issue }) => Effect.fail(StageError.make({ stage: 'instrument', reason: issue })), + CommandRejected: ({ issue }) => + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'instrument' }, + detail: issue, + }), + ), }) const projectOf = (seeds: readonly ProjectFile[]): Project => { diff --git a/packages/stryker-js/src/run/mutant-run.cell.ts b/packages/stryker-js/src/run/mutant-run.cell.ts index d9ffbc73..ad12e2fd 100644 --- a/packages/stryker-js/src/run/mutant-run.cell.ts +++ b/packages/stryker-js/src/run/mutant-run.cell.ts @@ -12,7 +12,7 @@ import type * as Scope from 'effect/Scope' import { interpretMutantRun, NoCoveringTestExecutedReason } from '../interpret-mutant-run.workflow.js' import { cicdAttributesOf, spanRunStatusOf, testSuiteNameOf } from '../mutant-run-span.js' import { invalidatesRunnerPool } from '../pooled-test-runner.handle.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import type { PooledTestRunnerError } from '../TestRunner.schema.js' import { recycleAndSettleMutantRun, type RunOnePlanArgs, settleMutantRun } from './mutant-run.js' @@ -110,7 +110,7 @@ const readMutantRunAttempt = Effect.fnUntraced(function*(input: MutantRunAttempt const attemptCell: Cell.Cell< MutantRunAttemptArgs, Mutant.RunMutantResult, - StageError | PooledTestRunnerError, + RunFailure | PooledTestRunnerError, Scope.Scope > = Cell.suspend(() => Sandwich.named(SpanTaxonomy.Spans.mutantRun.name)(readMutantRunAttempt) @@ -133,13 +133,18 @@ const attemptCell: Cell.Cell< }), ), CommandRejected: ({ issue }) => - Effect.fail(StageError.make({ stage: 'mutationTest', reason: `mutant run command rejected: ${issue}` })), + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, + detail: `mutant run command rejected: ${issue}`, + }), + ), }) ) export const mutantRunCell: Cell.Cell< RunOnePlanArgs, Mutant.RunMutantResult, - StageError | PooledTestRunnerError, + RunFailure | PooledTestRunnerError, Scope.Scope > = Cell.mapInput(attemptCell, (args: RunOnePlanArgs): MutantRunAttemptArgs => ({ ...args, attempt: 0 })) diff --git a/packages/stryker-js/src/run/mutant-run.ts b/packages/stryker-js/src/run/mutant-run.ts index 768763d2..1f4953c9 100644 --- a/packages/stryker-js/src/run/mutant-run.ts +++ b/packages/stryker-js/src/run/mutant-run.ts @@ -16,7 +16,7 @@ import { mutantCostOf, testBodyMsOf } from '../mutant-cost.js' import { type MutationReportingInput, type MutationReportingService } from '../mutation-reporting.service.js' import { type PooledTestRunner } from '../pooled-test-runner.handle.js' import { offerReporterEvent } from '../reporter-stream.service.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import type { PooledTestRunnerError } from '../TestRunner.schema.js' import type { DryRunDone } from './dry-run.cell.js' import { isMutantStatus, toReportedMutant, type ValidMutantStatus } from './mutation-test-plan.js' @@ -196,7 +196,7 @@ export const makeCheckpointWriter = Effect.fnUntraced(function*( export interface RunOnePlanArgs { readonly context: RunContext - readonly testRunnerPool: Pool.Pool + readonly testRunnerPool: Pool.Pool readonly checkpoint: CheckpointWriter readonly plan: Mutant.MutantRunPlan } diff --git a/packages/stryker-js/src/run/mutation-test-plan.cell.ts b/packages/stryker-js/src/run/mutation-test-plan.cell.ts index cca2a672..1dad8a77 100644 --- a/packages/stryker-js/src/run/mutation-test-plan.cell.ts +++ b/packages/stryker-js/src/run/mutation-test-plan.cell.ts @@ -23,7 +23,7 @@ import { import type { Project } from '../Project.schema.js' import { offerReporterEvent, type ReporterStage } from '../reporter-stream.service.js' import { RunEvents } from '../run-events.service.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import type { SandboxHandle } from '../Sandbox.handle.js' import { OrderedRunPlan, SortRunPlans, sortRunPlans } from '../sort-run-plans.workflow.js' import type { TestCoverage } from '../test-coverage.schema.js' @@ -125,7 +125,7 @@ const plannedPlanOf = ( const materializeDecision = Effect.fnUntraced(function*( decision: EncodedPlannedDecision, command: MutantTestPlanRaw, -): Effect.fn.Return { +): Effect.fn.Return { const mutant = yield* Option.match(Record.get(command.mutantsById, decision.mutantId), { onNone: () => Effect.die( @@ -180,21 +180,27 @@ const planMutantTestsCell = Sandwich.named(SpanTaxonomy.Spans.mutationTestPlanMu PlannedEarlyResultMutant: (decision, command) => materializeDecision(decision, command), CoveredMutantHitCountMissing: ({ missingIds }) => Effect.fail( - StageError.make({ - stage: 'mutationTest', - reason: `covered mutant missing dry-run hit count: ${missingIds.join(', ')}`, + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, + detail: `covered mutant missing dry-run hit count: ${missingIds.join(', ')}`, cause: CoveredMutantHitCountMissing.make({ missingIds }), }), ), MutantTimeoutNotFinite: ({ mutantId }) => Effect.fail( - StageError.make({ - stage: 'mutationTest', - reason: `mutant ${mutantId} has a non-finite timeout`, + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, + detail: `mutant ${mutantId} has a non-finite timeout`, cause: MutantTimeoutNotFinite.make({ mutantId: Mutant.MutantId.make(mutantId) }), }), ), - CommandRejected: ({ issue }) => Effect.fail(StageError.make({ stage: 'mutationTest', reason: issue })), + CommandRejected: ({ issue }) => + Effect.fail( + RunFailure.make({ + evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, + detail: issue, + }), + ), }) export interface MutationTestPlanInput { diff --git a/packages/stryker-js/src/run/mutation-test-plan.ts b/packages/stryker-js/src/run/mutation-test-plan.ts index a603a975..82e52b1f 100644 --- a/packages/stryker-js/src/run/mutation-test-plan.ts +++ b/packages/stryker-js/src/run/mutation-test-plan.ts @@ -10,7 +10,7 @@ import * as S from 'effect/Schema' import { checkerMutantsSkipped } from '../Checker/Checker.handle.js' import { CheckerMutantFromMutant } from '../Checker/Checker.schema.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { sandboxFileFor, type SandboxHandle } from '../Sandbox.handle.js' export const isMutantStatus = S.is(Mutant.MutantStatusSchema) @@ -48,12 +48,16 @@ export interface SandboxFilesInput { export const sandboxFilesOf: ( input: SandboxFilesInput, -) => Effect.Effect = Effect.fn( +) => Effect.Effect = Effect.fn( SpanTaxonomy.Spans.mutationTestSandboxFiles.name, )(function*(input: SandboxFilesInput) { return yield* Effect.fromResult(sandboxFilePairsOf(input.sandbox, input.fileNames)).pipe( Effect.mapError((cause) => - StageError.make({ stage: 'mutationTest', reason: 'Failed to resolve sandbox file', cause }) + RunFailure.make({ + evidence: { _tag: 'SandboxPreparationFailed', stage: 'mutationTest' }, + detail: 'Failed to resolve sandbox file', + cause, + }) ), ) }) diff --git a/packages/stryker-js/src/run/mutation-test.cell.ts b/packages/stryker-js/src/run/mutation-test.cell.ts index 2821f488..ceb53165 100644 --- a/packages/stryker-js/src/run/mutation-test.cell.ts +++ b/packages/stryker-js/src/run/mutation-test.cell.ts @@ -16,7 +16,7 @@ import * as S from 'effect/Schema' import * as Scope from 'effect/Scope' import * as Stream from 'effect/Stream' -import { admitMutationTest, MutationTestError } from '../admit-mutation-test.workflow.js' +import { admitMutationTest } from '../admit-mutation-test.workflow.js' import { scoped as checkerPoolsScoped } from '../Checker/checker-pool.blueprint.js' import { checkPlansStream as checkPlansWithConfiguredCheckers, @@ -29,8 +29,9 @@ import { MutationTestCommand } from '../MutationTest.schema.js' import { withPhaseSpan } from '../reporter-stream.service.js' import { mutantDetailEventsOf, requestedIdsOf, restrictedToRequestedIds } from '../Rerun/rerun-selection.js' import { RunEvents } from '../run-events.service.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import type { PooledTestRunnerError } from '../TestRunner.schema.js' +import { ChildProcessCrashedError, OutOfMemoryError } from '../Worker.schema.js' import { IdGenerator } from '../Worker.service.js' import type { DryRunDone } from './dry-run.cell.js' import { readIncrementalReuse } from './incremental-reuse.cell.js' @@ -212,18 +213,24 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { return outcomeResult }) -const mapMutationTestCause = ( - cause: PooledTestRunnerError | PlatformError | StageError, -): StageError => - Match.value({ cause }).pipe( - Match.when({ cause: (candidate: unknown): candidate is StageError => S.is(StageError)(candidate) }, ({ cause }) => - cause), - Match.orElse(({ cause }) => - StageError.make({ stage: 'mutationTest', reason: 'Mutation testing failed', cause }) +const workerFailureOf = (error: ChildProcessCrashedError | OutOfMemoryError): RunFailure => + RunFailure.make({ evidence: error.evidence, detail: error.message, cause: error }) + +const mapMutationTestCause = (cause: PooledTestRunnerError | PlatformError | RunFailure): RunFailure => + Match.value(cause).pipe( + Match.tag('RunFailure', (failure) => failure), + Match.tag('ChildProcessCrashedError', (crashed) => workerFailureOf(crashed)), + Match.tag('OutOfMemoryError', (outOfMemory) => workerFailureOf(outOfMemory)), + Match.orElse((cause) => + RunFailure.make({ + evidence: { _tag: 'TestRunnerFailed', stage: 'mutationTest' }, + detail: 'Mutation testing failed', + cause, + }) ), ) -const writeMutationTestProceed = (raw: MutationTestRaw): Effect.Effect => +const writeMutationTestProceed = (raw: MutationTestRaw): Effect.Effect => proceedPipeline(raw).pipe(Effect.mapError(mapMutationTestCause)) const writeMutationTestOutcome = ({ @@ -231,8 +238,8 @@ const writeMutationTestOutcome = ({ outcome, }: { readonly raw: MutationTestRaw - readonly outcome: Effect.Effect -}): Effect.Effect => + readonly outcome: Effect.Effect +}): Effect.Effect => withPhaseSpan( SpanTaxonomy.Spans.mutationTestPhase, { @@ -268,7 +275,7 @@ export const mutationTestCell = Sandwich.named( MutationTestDryRunOnly: (_decision, raw) => writeMutationTestOutcome({ raw, outcome: writeMutationTestDryRunOnly() }), MutationTestNoTests: (_decision, raw) => writeMutationTestOutcome({ raw, outcome: writeMutationTestNoTests() }), MutationTestProceed: (_decision, raw) => writeMutationTestOutcome({ raw, outcome: writeMutationTestProceed(raw) }), - MutationTestError: ({ stage, reason }) => - Effect.fail(StageError.make({ stage, reason, cause: MutationTestError.make({ stage, reason }) })), - CommandRejected: ({ issue }) => Effect.fail(StageError.make({ stage: 'mutationTest', reason: issue })), + RunFailure: (failure) => Effect.fail(RunFailure.make(failure)), + CommandRejected: ({ issue }) => + Effect.fail(RunFailure.make({ evidence: { _tag: 'InvariantBroken', stage: 'mutationTest' }, detail: issue })), }) diff --git a/packages/stryker-js/src/run/prepare.cell.ts b/packages/stryker-js/src/run/prepare.cell.ts index 3f7fbe7c..019d760f 100644 --- a/packages/stryker-js/src/run/prepare.cell.ts +++ b/packages/stryker-js/src/run/prepare.cell.ts @@ -36,7 +36,7 @@ import { planMutatorCatalogs, PlanMutatorCatalogsCommand, } from '../plan-mutator-catalogs.workflow.js' -import { pluginLoadFailureEvents, reportPluginLoad } from '../plugin-load-report.service.js' +import { pluginLoadPhaseEvent, reportPluginLoad } from '../plugin-load-report.service.js' import { loadPlugins, pluginUrlsFromOptions } from '../plugin-loader.service.js' import { type LoadedPlugins, type PluginDescriptor } from '../Plugins.schema.js' import type { Project } from '../Project.schema.js' @@ -53,7 +53,7 @@ import { type ReporterChoice, reporterInputsOf } from '../reporter-wiring.servic import { Reporter } from '../reporter.service.js' import { AnsiCode } from '../reporting/ansi.schema.js' import { RunEvents } from '../run-events.service.js' -import { PrepareError, StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { TemporaryDirectory } from '../Sandbox.service.js' import { WorkerLauncher } from '../WorkerLauncher.service.js' import { admitNonEmptyProject, NonEmptyProjectCommand, ProjectEmpty } from './admit-non-empty-project.workflow.js' @@ -134,7 +134,7 @@ const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(function*( command: ReadProjectDone, ): Effect.fn.Return< PrepareRaw, - StageError, + RunFailure, Scope.Scope | RunEnvironment | RunEvents | WorkerLauncher | FileSystem.FileSystem | Path.Path | Reporter > { yield* Scope.Scope @@ -160,13 +160,13 @@ const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(function*( } const descriptors: readonly string[] = pluginUrlsFromOptions(options) const loaded = yield* loadPlugins(descriptors, env.basePath).pipe( - Effect.tapError((error) => + Effect.tapError(() => Clock.currentTimeMillis.pipe( - Effect.flatMap((failedAt) => pluginLoadFailureEvents(error, failedAt - env.runStartedAt)), - Effect.flatMap((events) => Effect.forEach(events, (event) => Queue.offer(queue, event), { discard: true })), + Effect.map((failedAt) => pluginLoadPhaseEvent(failedAt - env.runStartedAt)), + Effect.flatMap((event) => Queue.offer(queue, event)), ) ), - Effect.mapError((cause) => StageError.make({ stage: 'prepare', reason: 'Failed to load plugins', cause })), + Effect.mapError((cause) => RunFailure.make({ evidence: cause.evidence, detail: 'Failed to load plugins', cause })), ) const stockCatalog = yield* Effect.orDie(S.decodeEffect(MutatorCatalog.Catalog)(StockCatalog.StockCatalog)) const providers = loaded.mutators.map((provider) => ({ @@ -183,7 +183,14 @@ const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(function*( const plannedCatalogs = yield* Effect.fromResult( Result.mapError( planMutatorCatalogs(PlanMutatorCatalogsCommand.make({ stock: stockCatalog, providers })), - (refused) => StageError.make({ stage: 'prepare', reason: refused.message, cause: refused }), + (refused) => { + const detail = refused.message + return RunFailure.make({ + evidence: { _tag: 'ConfigInvalid', stage: 'config', detail }, + detail, + cause: refused, + }) + }, ), ) const mutatorCatalogs = plannedCatalogs.catalogs @@ -196,7 +203,14 @@ const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(function*( optInMutations: [...options.mutator.optInMutations], }), ), - (refused) => StageError.make({ stage: 'prepare', reason: refused.message, cause: refused }), + (refused) => { + const detail = refused.message + return RunFailure.make({ + evidence: { _tag: 'ConfigInvalid', stage: 'config', detail }, + detail, + cause: refused, + }) + }, ), ) const registry = Format.registerEntries( @@ -208,12 +222,14 @@ const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(function*( const record = { ...options } yield* validateOptions(record, mergedSchema).pipe( Effect.mapError( - (cause) => - StageError.make({ - stage: 'prepare', - reason: 'Failed to revalidate options with plugin schema', + (cause) => { + const detail = 'Failed to revalidate options with plugin schema' + return RunFailure.make({ + evidence: { _tag: 'ConfigInvalid', stage: 'config', detail }, + detail, cause, - }), + }) + }, ), ) const ignorers: readonly Ignorer[] = loaded.ignorers @@ -264,7 +280,7 @@ const applyPrepare = Effect.fn(SpanTaxonomy.Spans.prepareApply.name)(function*( span: PhaseSpan, reporters: readonly string[], raw: PrepareRaw, -): Effect.fn.Return { +): Effect.fn.Return { const mutateCount = pipe(raw.project.filesToMutate, MutableHashMap.size) yield* announceSummary({ env: raw.env, @@ -274,25 +290,31 @@ const applyPrepare = Effect.fn(SpanTaxonomy.Spans.prepareApply.name)(function*( raw.options.reporters, [...HashMap.values(raw.reporterChoicesByName)].map((choice) => choice.name), ).pipe( - Effect.mapError((cause) => StageError.make({ stage: 'prepare', reason: cause.message, cause })), + Effect.mapError((cause) => { + const detail = cause.message + return RunFailure.make({ + evidence: { _tag: 'ConfigInvalid', stage: 'config', detail }, + detail, + cause, + }) + }), ) const admission = admitNonEmptyProject( NonEmptyProjectCommand.make({ fileCount: pipe(raw.project.files, MutableHashMap.size) }), ) const emptyAdmission = Option.filter(Result.getSuccess(admission), S.is(ProjectEmpty)) - const emptyProjectGuard: Result.Result = Option.getOrElse( + const emptyProjectGuard: Result.Result = Option.getOrElse( Option.map( emptyAdmission, - (): Result.Result => + (): Result.Result => Result.fail( - StageError.make({ - stage: 'prepare', - reason: 'No input files found.', - cause: PrepareError.make({ stage: 'prepare', reason: 'No input files found.' }), + RunFailure.make({ + evidence: { _tag: 'NoInputFiles', stage: 'prepare' }, + detail: 'No input files found.', }), ), ), - (): Result.Result => Result.succeed(undefined), + (): Result.Result => Result.succeed(undefined), ) yield* Effect.fromResult(emptyProjectGuard) const temporaryDirectoryPath = yield* Effect.map( @@ -300,7 +322,11 @@ const applyPrepare = Effect.fn(SpanTaxonomy.Spans.prepareApply.name)(function*( (temporaryDirectory) => Context.get(temporaryDirectory, TemporaryDirectory).path, ).pipe( Effect.mapError((cause) => - StageError.make({ stage: 'prepare', reason: 'Failed to create temporary directory', cause }) + RunFailure.make({ + evidence: { _tag: 'SandboxPreparationFailed', stage: 'prepare' }, + detail: 'Failed to create temporary directory', + cause, + }) ), ) const reporterInputs = yield* reporterInputsOf( @@ -331,18 +357,19 @@ const applyPrepare = Effect.fn(SpanTaxonomy.Spans.prepareApply.name)(function*( const writePrepare = ( reporters: readonly string[], raw: PrepareRaw, -): Effect.Effect => +): Effect.Effect => withPhaseSpan(SpanTaxonomy.Spans.preparePhase, {}, (span) => applyPrepare(span, reporters, raw)) export const prepareCell: Cell.Cell< ReadProjectDone, PrepareDone, - StageError, + RunFailure, Scope.Scope | RunEnvironment | RunEvents | WorkerLauncher | FileSystem.FileSystem | Path.Path | Reporter > = Sandwich.named(SpanTaxonomy.Spans.prepare.name)(readPrepare) .decide(planPrepare) .write({ HumanReporters: ({ reporters }, raw) => writePrepare(reporters, raw), MachineReporters: ({ reporters }, raw) => writePrepare(reporters, raw), - CommandRejected: ({ issue }) => Effect.fail(StageError.make({ stage: 'prepare', reason: issue })), + CommandRejected: ({ issue }) => + Effect.fail(RunFailure.make({ evidence: { _tag: 'InvariantBroken', stage: 'prepare' }, detail: issue })), }) diff --git a/packages/stryker-js/src/run/run-stages.cell.ts b/packages/stryker-js/src/run/run-stages.cell.ts index 0d3d7918..d509aed3 100644 --- a/packages/stryker-js/src/run/run-stages.cell.ts +++ b/packages/stryker-js/src/run/run-stages.cell.ts @@ -4,7 +4,7 @@ import * as Match from 'effect/Match' import { concurrencyCell } from '../concurrency.cell.js' import type { ConfigReadError } from '../ConfigError.schema.js' import { readProjectCell } from '../read-project.cell.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { dryRunCell } from './dry-run.cell.js' import { instrumentCell } from './instrument.cell.js' import { loadConfigCell } from './load-config.cell.js' @@ -25,11 +25,23 @@ const prepareStageCell = Cell.andThen( Cell.andThen( Cell.mapError( loadConfigCell, - (cause) => StageError.make({ stage: 'prepare', reason: configReadReasonOf(cause), cause }), + (cause) => { + const detail = configReadReasonOf(cause) + return RunFailure.make({ + evidence: { _tag: 'ConfigInvalid', stage: 'config', detail }, + detail, + cause, + }) + }, ), Cell.mapError( readProjectCell, - (cause) => StageError.make({ stage: 'prepare', reason: 'Failed to read project', cause }), + (cause) => + RunFailure.make({ + evidence: { _tag: 'SandboxPreparationFailed', stage: 'prepare' }, + detail: 'Failed to read project', + cause, + }), ), ), prepareCell, @@ -40,5 +52,5 @@ const prepareStageCell = Cell.andThen( ), ) -export const mutationTestCell: Cell.Cell = +export const mutationTestCell: Cell.Cell = prepareStageCell diff --git a/packages/stryker-js/src/run/run-stages.ts b/packages/stryker-js/src/run/run-stages.ts index 36c136ce..f7da375a 100644 --- a/packages/stryker-js/src/run/run-stages.ts +++ b/packages/stryker-js/src/run/run-stages.ts @@ -10,7 +10,7 @@ import * as Predicate from 'effect/Predicate' import type { ResolvedMode } from '../output-mode.schema.js' import { makeRunEventStream, RunEventDrainLive } from '../run-event-stream.service.js' -import { StageError } from '../Run.schema.js' +import type { RunFailure } from '../Run.schema.js' import type { MutationTestDone } from './mutation-test.cell.js' import { mutationTestCell } from './run-stages.cell.js' import { RunEnvironment } from './RunEnvironment.service.js' @@ -33,12 +33,12 @@ export const strykerCell: { ( options: Options.PartialStrykerOptions, targetMutatePatterns?: readonly string[], - ): Effect.Effect + ): Effect.Effect ( targetMutatePatterns?: readonly string[], ): ( options: Options.PartialStrykerOptions, - ) => Effect.Effect + ) => Effect.Effect } = dual( (args) => Predicate.isObject(args[0]), (options: Options.PartialStrykerOptions, targetMutatePatterns?: readonly string[]) => diff --git a/packages/stryker-js/src/run/test-runner-pool.blueprint.ts b/packages/stryker-js/src/run/test-runner-pool.blueprint.ts index 5528020d..6fcb0ae2 100644 --- a/packages/stryker-js/src/run/test-runner-pool.blueprint.ts +++ b/packages/stryker-js/src/run/test-runner-pool.blueprint.ts @@ -12,7 +12,7 @@ import type * as Scope from 'effect/Scope' import type { LoadedPlugins } from '../Plugins.schema.js' import { PluginNotFoundError } from '../PluginsError.schema.js' import type { PooledTestRunner } from '../pooled-test-runner.handle.js' -import { StageError } from '../Run.schema.js' +import { RunFailure } from '../Run.schema.js' import { ConfiguredPluginModulePath, ConfiguredPluginName, @@ -51,7 +51,7 @@ const configuredPluginOf = (configured: string | { readonly plugin: string }) => const testRunnerWorkerSpawnOf = ( loaded: Pick, configured: ConfiguredPluginName | ConfiguredPluginModulePath, -): Effect.Effect => +): Effect.Effect => Effect.mapError( Effect.fromResult( resolveConfiguredPlugin( @@ -59,9 +59,9 @@ const testRunnerWorkerSpawnOf = ( ), ), (missing) => - StageError.make({ - stage: 'mutationTest', - reason: missing.reason, + RunFailure.make({ + evidence: { _tag: 'PluginNotFound', stage: 'config', descriptor: missing.descriptor }, + detail: missing.reason, cause: PluginNotFoundError.make({ descriptor: missing.descriptor }), }), ) @@ -69,7 +69,7 @@ const testRunnerWorkerSpawnOf = ( const acquire: ( spec: TestRunnerPoolSpec, ) => Effect.Effect< - Pool.Pool, + Pool.Pool, never, Scope.Scope | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | WorkerLauncher > = Effect.fnUntraced(function*(spec: TestRunnerPoolSpec) { @@ -113,7 +113,7 @@ export type TestRunnerPoolBlueprint = Blueprint.Of export const scoped = ( spec: TestRunnerPoolSpec, ): Effect.Effect< - Pool.Pool, + Pool.Pool, never, Scope.Scope | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | WorkerLauncher > => TestRunnerPools.of(spec).scoped diff --git a/packages/stryker-js/src/stryker-outputs.ts b/packages/stryker-js/src/stryker-outputs.ts index 190b2537..289d5ec5 100644 --- a/packages/stryker-js/src/stryker-outputs.ts +++ b/packages/stryker-js/src/stryker-outputs.ts @@ -1,6 +1,7 @@ import type { Options } from '@systemfsoftware/stryker-js-plugin-interface' export const REPRODUCERS_FILE = 'reports/mutation/reproducers.json' +export const FAILURE_RECORD_FILE = 'reports/mutation/failure.json' export const sarifFileNameOf = (jsonReportFileName: string): string => `${jsonReportFileName.replace(/\.[^./\\]*$/, '')}.sarif` @@ -18,4 +19,5 @@ export const strykerOutputFilesOf = ( options.jsonReporter.fileName, sarifFileNameOf(options.jsonReporter.fileName), REPRODUCERS_FILE, + FAILURE_RECORD_FILE, ] diff --git a/packages/stryker-js/src/worker-client.blueprint.ts b/packages/stryker-js/src/worker-client.blueprint.ts index b55c7a98..b7f77de2 100644 --- a/packages/stryker-js/src/worker-client.blueprint.ts +++ b/packages/stryker-js/src/worker-client.blueprint.ts @@ -1,4 +1,5 @@ import { Blueprint } from '@systemfsoftware/effect-cell-types' +import type { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract' import type { Options } from '@systemfsoftware/stryker-js-plugin-interface' import { Trace, Worker } from '@systemfsoftware/stryker-js-plugin-runtime' import * as Context from 'effect/Context' @@ -29,6 +30,7 @@ export interface WorkerClientParams { readonly workingDirectory: string readonly execArgv: readonly string[] readonly tempDirPrefix: string + readonly workerKind: FailureRecord.WorkerKind readonly env?: Readonly> | undefined } @@ -45,6 +47,7 @@ const WorkerClients = () => execArgv: params.execArgv, optionsJson, tempDirPrefix: params.tempDirPrefix, + workerKind: params.workerKind, env: params.env, }) const protocol = yield* worker.pipe( @@ -52,7 +55,8 @@ const WorkerClients = () => Layer.build, Effect.retry(connectRetry), Effect.raceFirst(worker.exited), - Effect.catchTag('SocketError', () => Effect.fail(WorkerBootTimeoutError.make({ pid: worker.pid }))), + Effect.catchTag('SocketError', () => + Effect.fail(WorkerBootTimeoutError.make({ pid: worker.pid, workerKind: params.workerKind }))), ) const traceContext = yield* Layer.build(Trace.layerTraceContextClient) diff --git a/packages/stryker-js/tests/__fixtures__/dry-run-failure.schema.ts b/packages/stryker-js/tests/__fixtures__/dry-run-failure.schema.ts index 9785dfcc..36b2d760 100644 --- a/packages/stryker-js/tests/__fixtures__/dry-run-failure.schema.ts +++ b/packages/stryker-js/tests/__fixtures__/dry-run-failure.schema.ts @@ -1,9 +1,14 @@ import * as S from 'effect/Schema' -export const DryRunFailedCause = S.TaggedStruct('DryRunFailed', { +export const BaselineTestsFailedEvidence = S.TaggedStruct('BaselineTestsFailed', { + stage: S.Literal('dryRun'), testCount: S.Finite, - failedTestCount: S.Finite, - failedTests: S.Array(S.Struct({ name: S.String, failureMessage: S.String })), + tests: S.Array(S.Struct({ + id: S.String, + name: S.String, + file: S.NullOr(S.String), + location: S.NullOr(S.Struct({ file: S.String, line: S.Finite, column: S.Finite })), + message: S.String, + stack: S.NullOr(S.String), + })), }) - -export type DryRunFailedView = S.Schema.Type diff --git a/packages/stryker-js/tests/__fixtures__/rerun-by-id.schema.ts b/packages/stryker-js/tests/__fixtures__/rerun-by-id.schema.ts index fbbc3d08..b2db371f 100644 --- a/packages/stryker-js/tests/__fixtures__/rerun-by-id.schema.ts +++ b/packages/stryker-js/tests/__fixtures__/rerun-by-id.schema.ts @@ -13,5 +13,3 @@ export const ReportSurvivors = S.Struct({ S.Struct({ mutants: S.Array(S.Struct({ id: S.String, status: S.String })) }), ), }) - -export const ErrorEnvelope = S.TaggedStruct('error', { code: S.Finite, error: S.String }) diff --git a/packages/stryker-js/tests/__fixtures__/substituted-worker.fixture.ts b/packages/stryker-js/tests/__fixtures__/substituted-worker.fixture.ts index 67b20079..27b62df1 100644 --- a/packages/stryker-js/tests/__fixtures__/substituted-worker.fixture.ts +++ b/packages/stryker-js/tests/__fixtures__/substituted-worker.fixture.ts @@ -96,12 +96,15 @@ const exitOf = (behaviour: ChildBehaviour): Effect.Effect rpcs: Plugin.CheckerRpcs, tempDirPrefix: 'checker-', workingDirectory: '/project', + workerKind: 'checker', }).pipe(Effect.provide(launcherLayer)) return { client, receivedRef } satisfies CheckerHarness diff --git a/packages/stryker-js/tests/config-file.integration.test.ts b/packages/stryker-js/tests/config-file.integration.test.ts index 9fa54a22..cd860db4 100644 --- a/packages/stryker-js/tests/config-file.integration.test.ts +++ b/packages/stryker-js/tests/config-file.integration.test.ts @@ -228,14 +228,16 @@ Feature('Configuring a Stryker run from a module config file') const hint = hintOf(failure) return expect({ tag: failure['_tag'], - exitClass: failure['exitClass'], + evidenceTag: failure.evidence._tag, + evidenceStage: failure.evidence.stage, namesTheAbandonedFile: fileOf(failure).endsWith(row.file), hintNamesLegacyFormats: hint.includes('JSON or CommonJS'), hintNamesEveryModuleFormat: ['.ts', '.mts', '.js', '.mjs'].every((extension) => hint.includes(extension)), hintNamesModuleDefaultExport: hint.includes('export default'), }).toEqual({ tag: 'ConfigFileUnsupportedError', - exitClass: 'ConfigError', + evidenceTag: 'ConfigInvalid', + evidenceStage: 'config', namesTheAbandonedFile: true, hintNamesLegacyFormats: true, hintNamesEveryModuleFormat: true, diff --git a/packages/stryker-js/tests/diff-scope.integration.test.ts b/packages/stryker-js/tests/diff-scope.integration.test.ts index 3bdfba9d..a52a34f5 100644 --- a/packages/stryker-js/tests/diff-scope.integration.test.ts +++ b/packages/stryker-js/tests/diff-scope.integration.test.ts @@ -46,7 +46,7 @@ interface Observation { readonly scope: string | undefined readonly mutatedFiles: readonly string[] readonly mutantLines: readonly number[] - readonly failure: { readonly stage: string; readonly exitClass: string; readonly namesRef: boolean } | undefined + readonly failure: { readonly evidenceTag: string; readonly stage: string; readonly namesRef: boolean } | undefined readonly refsSeen: readonly string[] } @@ -137,10 +137,10 @@ const runProject = ( onFailure: (cause) => Option.match(Cause.findErrorOption(cause), { onNone: () => undefined, - onSome: (stageError) => ({ - stage: stageError.stage, - exitClass: stageError.exitClass, - namesRef: S.is(GitDiffSchema.GitRefUnresolved)(stageError.cause), + onSome: (failure) => ({ + evidenceTag: failure.evidence._tag, + stage: failure.evidence.stage, + namesRef: S.is(GitDiffSchema.GitRefUnresolved)(failure.cause), }), }), onSuccess: () => undefined, @@ -215,7 +215,7 @@ Feature('Scoping a mutation run to the changed lines since a git ref') ) scenario( - 'An unknown ref fails the run with a named preparation error and exit class 2', + 'An unknown ref fails the run while preparing, naming the ref', Gherkin.Do.pipe( Given('a project whose git ref cannot be resolved')( 'observation', @@ -226,8 +226,12 @@ Feature('Scoping a mutation run to the changed lines since a git ref') (ref) => Effect.fail(GitDiffSchema.GitRefUnresolved.make({ ref, detail: 'unknown revision' })), ), ), - Then('the run fails while preparing, naming the ref, with the configuration exit class')((s, expect) => - expect(s.observation.failure).toEqual({ stage: 'prepare', exitClass: 'ConfigError', namesRef: true }) + Then('the run fails while preparing, naming the unresolvable ref')((s, expect) => + expect(s.observation.failure).toEqual({ + evidenceTag: 'SandboxPreparationFailed', + stage: 'prepare', + namesRef: true, + }) ), ), ) diff --git a/packages/stryker-js/tests/dry-run-failure.integration.test.ts b/packages/stryker-js/tests/dry-run-failure.integration.test.ts index e9909937..83b38f41 100644 --- a/packages/stryker-js/tests/dry-run-failure.integration.test.ts +++ b/packages/stryker-js/tests/dry-run-failure.integration.test.ts @@ -4,7 +4,6 @@ import type { Options } from '@systemfsoftware/stryker-js-plugin-interface' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' import * as Layer from 'effect/Layer' -import * as Logger from 'effect/Logger' import * as Option from 'effect/Option' import * as Path from 'effect/Path' import type { PlatformError } from 'effect/PlatformError' @@ -12,7 +11,7 @@ import * as Result from 'effect/Result' import * as S from 'effect/Schema' import * as Stdio from 'effect/Stdio' -import { DryRunFailedCause, type DryRunFailedView } from './__fixtures__/dry-run-failure.schema.js' +import { BaselineTestsFailedEvidence } from './__fixtures__/dry-run-failure.schema.js' const Feature = makeFeature({ it }) @@ -42,14 +41,8 @@ const TEST_CONTENT = [ '})', ].join('\n') -interface LogEntry { - readonly level: string - readonly text: string -} - interface ProjectFixture { readonly root: string - readonly logs: Array } const writeProject = (): Effect.Effect => @@ -61,7 +54,7 @@ const writeProject = (): Effect.Effect => @@ -69,21 +62,11 @@ const removeProject = (root: string): Effect.Effect fs.remove(root, { recursive: true })), )) -const capturingLogger = (fixture: ProjectFixture): Layer.Layer => - Logger.layer([ - Logger.make((entry) => { - fixture.logs.push({ - level: entry.logLevel, - text: [entry.message].flat().map(String).join(' '), - }) - }), - ]) - const runFromProject = ( root: string, options: Options.PartialStrykerOptions, ): Effect.Effect< - Result.Result, + Result.Result, never, Engine.EnginePorts > => @@ -101,20 +84,17 @@ const runFromProject = ( ) const failureOf = ( - outcome: Result.Result, -): Engine.StageError => { + outcome: Result.Result, +): Engine.RunFailure => { if (Result.isSuccess(outcome)) { throw new Error('the run was expected to fail its dry run, but it completed') } - if (!S.is(Engine.StageError)(outcome.failure)) { - throw new Error(`the run was expected to fail as a stage, not a platform failure: ${String(outcome.failure)}`) + if (!S.is(Engine.RunFailure)(outcome.failure)) { + throw new Error(`the run was expected to fail as a run failure, not a platform failure: ${String(outcome.failure)}`) } return outcome.failure } -const dryRunFailedCauseOf = (cause: Engine.StageError['cause']): Option.Option => - S.decodeUnknownOption(DryRunFailedCause)(cause) - const OPTIONS: Options.PartialStrykerOptions = { testRunner: 'vm', testFiles: ['test/**/*.mjs'], @@ -126,7 +106,7 @@ const OPTIONS: Options.PartialStrykerOptions = { const FROZEN_RUNNER_PLUGIN = new URL('./__fixtures__/frozen-runner/index.mjs', import.meta.url).href interface FrozenRunnerOutcome { - readonly outcome: Result.Result + readonly outcome: Result.Result readonly workerPid: number } @@ -159,46 +139,35 @@ Feature('Reporting why a dry run failed') Given('a project whose test suite fails two of its three tests')('project', () => writeProject()), When('the mutation run performs its initial test run')( 'outcome', - (s) => - runFromProject(s.project.root, OPTIONS).pipe( - Effect.provide(capturingLogger(s.project)), - Effect.ensuring(removeProject(s.project.root)), - ), + (s) => runFromProject(s.project.root, OPTIONS).pipe(Effect.ensuring(removeProject(s.project.root))), ), Then( - 'the refusal names every failing test with its failure message, in the cause, the reason, and the log', + 'the refusal reports every failing test by id, name and file', )((s, expect) => { const failure = failureOf(s.outcome) - const cause = Option.getOrUndefined(dryRunFailedCauseOf(failure.cause)) - const messages = cause?.failedTests.map((test) => test.failureMessage) ?? [] - const errorLog = s.project.logs - .filter((entry) => entry.level === 'Error') - .map((entry) => entry.text) - .join('\n') + const evidence = Option.getOrUndefined( + S.decodeUnknownOption(BaselineTestsFailedEvidence)(failure.evidence), + ) + const tests = evidence?.tests ?? [] + const messages = tests.map((test) => test.message) return expect({ - stage: failure.stage, - causeTag: cause?._tag, - causeTestCount: cause?.testCount, - causeFailedTestCount: cause?.failedTestCount, - causeFailedNames: cause?.failedTests.map((test) => test.name), - reasonNamesEveryFailure: FAILING_TEST_NAMES.every((name) => failure.reason.includes(name)), - reasonCarriesEveryMessage: messages.every((message) => failure.reason.includes(message)), + evidenceTag: failure.evidence._tag, + stage: failure.evidence.stage, + testCount: evidence?.testCount, + failedTestCount: tests.length, + failedNames: tests.map((test) => test.name), + idsNameTheirFileAndTest: tests.every((test) => test.id === `${TEST_FILE}#${test.name}`), + filesNameTheirTestFile: tests.every((test) => test.file === TEST_FILE), messagesAreAssertions: messages.every((message) => message.includes('expected')), - logNamesEveryFailure: FAILING_TEST_NAMES.every((name) => errorLog.includes(name)), - logCarriesEveryMessage: messages.every((message) => errorLog.includes(message)), - logCountsTheFailures: errorLog.includes('2 of 3 test(s) failed'), }).toEqual({ + evidenceTag: 'BaselineTestsFailed', stage: 'dryRun', - causeTag: 'DryRunFailed', - causeTestCount: 3, - causeFailedTestCount: 2, - causeFailedNames: FAILING_TEST_NAMES, - reasonNamesEveryFailure: true, - reasonCarriesEveryMessage: true, + testCount: 3, + failedTestCount: 2, + failedNames: FAILING_TEST_NAMES, + idsNameTheirFileAndTest: true, + filesNameTheirTestFile: true, messagesAreAssertions: true, - logNamesEveryFailure: true, - logCarriesEveryMessage: true, - logCountsTheFailures: true, }) }), ), @@ -212,15 +181,11 @@ Feature('Reporting why a dry run failed') ), When('the mutation run performs its initial test run')( 'run', - (s) => - runWithFrozenRunner(s.project).pipe( - Effect.provide(capturingLogger(s.project)), - Effect.ensuring(removeProject(s.project.root)), - ), + (s) => runWithFrozenRunner(s.project).pipe(Effect.ensuring(removeProject(s.project.root))), ), Then('the dry run fails and the unresponsive test runner does not outlive the run')((s, expect) => expect({ - stage: failureOf(s.run.outcome).stage, + stage: failureOf(s.run.outcome).evidence.stage, workerAlive: isProcessAlive(s.run.workerPid), }).toEqual({ stage: 'dryRun', workerAlive: false }) ), diff --git a/packages/stryker-js/tests/empty-file-selection.integration.test.ts b/packages/stryker-js/tests/empty-file-selection.integration.test.ts index 4d98f32e..e2ebd6f1 100644 --- a/packages/stryker-js/tests/empty-file-selection.integration.test.ts +++ b/packages/stryker-js/tests/empty-file-selection.integration.test.ts @@ -1,12 +1,10 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { Engine } from '@systemfsoftware/stryker-js' import { type Options, Report } from '@systemfsoftware/stryker-js-plugin-interface' -import { PrepareError } from '@systemfsoftware/stryker-js/events' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' import * as Layer from 'effect/Layer' import * as Match from 'effect/Match' -import * as Option from 'effect/Option' import * as Path from 'effect/Path' import type { PlatformError } from 'effect/PlatformError' import * as Result from 'effect/Result' @@ -50,7 +48,7 @@ const runFromProject = ( root: string, options: Options.PartialStrykerOptions, ): Effect.Effect< - Result.Result, + Result.Result, never, Engine.EnginePorts > => @@ -78,26 +76,25 @@ const reportedFilesOf = ( return report.files }).pipe(Effect.orDie) -const verdictOf = (outcome: Result.Result) => +const verdictOf = (outcome: Result.Result) => Result.match(outcome, { onFailure: (failure) => `refused: ${failure.message}`, onSuccess: (done) => done.verdict, }) -const refusalOf = (outcome: Result.Result) => +const refusalOf = (outcome: Result.Result) => Result.match(outcome, { onFailure: (failure) => Match.value(failure).pipe( - Match.tag('StageError', (refused) => ({ - stage: refused.stage, - causeMessage: Option.getOrNull( - Option.map(S.decodeUnknownOption(PrepareError)(refused.cause), (cause) => cause.message), - ), + Match.tag('RunFailure', (refused) => ({ + evidenceTag: refused.evidence._tag, + stage: refused.evidence.stage, + detail: refused.detail, })), - Match.tag('PlatformError', (platform) => ({ stage: null, causeMessage: platform.message })), + Match.tag('PlatformError', (platform) => ({ evidenceTag: null, stage: null, detail: platform.message })), Match.exhaustive, ), - onSuccess: () => ({ stage: null, causeMessage: null }), + onSuccess: () => ({ evidenceTag: null, stage: null, detail: null }), }) const runLayer = Layer.mergeAll(Engine.nodePlatformLayer, Stdio.layerTest({})) @@ -119,7 +116,11 @@ Feature('Running mutation testing when no file is selected') runFromProject(s.root, { testRunner: 'vm', checkers: [] }).pipe(Effect.ensuring(removeProject(s.root))), ), Then('the run stops while preparing, naming the missing input files')((s, expect) => - expect(refusalOf(s.outcome)).toEqual({ stage: 'prepare', causeMessage: 'No input files found.' }) + expect(refusalOf(s.outcome)).toEqual({ + evidenceTag: 'NoInputFiles', + stage: 'prepare', + detail: 'No input files found.', + }) ), ), ) diff --git a/packages/stryker-js/tests/framework-run.integration.test.ts b/packages/stryker-js/tests/framework-run.integration.test.ts index 4fe60aac..46c56dfc 100644 --- a/packages/stryker-js/tests/framework-run.integration.test.ts +++ b/packages/stryker-js/tests/framework-run.integration.test.ts @@ -1,13 +1,15 @@ import { NodeFileSystem, NodePath } from '@effect/platform-node' import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { Engine } from '@systemfsoftware/stryker-js' -import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' -import type * as Cause from 'effect/Cause' +import { FailureRecord, RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import * as Cause from 'effect/Cause' import * as Effect from 'effect/Effect' import * as Exit from 'effect/Exit' import * as FileSystem from 'effect/FileSystem' import * as Layer from 'effect/Layer' +import * as Option from 'effect/Option' import * as Path from 'effect/Path' +import * as Predicate from 'effect/Predicate' import * as Queue from 'effect/Queue' import * as S from 'effect/Schema' @@ -175,11 +177,19 @@ const environmentFor = (directory: string): Engine.RunEnvironmentShape => ({ const incrementalFileOf = (directory: string): string => `${directory}/reports/stryker-incremental.json` interface RunObservation { - readonly exit: Exit.Exit + readonly exit: Exit.Exit readonly events: ReadonlyArray readonly incrementalState: string } +const failureEvidenceOf = (run: RunObservation): FailureRecord.FailureEvidence | undefined => + Option.getOrUndefined( + Exit.match(run.exit, { + onFailure: (cause) => Option.map(Cause.findErrorOption(cause), (failure) => failure.evidence), + onSuccess: () => Option.none(), + }), + ) + const incrementalStateOf = (directory: string): Effect.Effect => Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(incrementalFileOf(directory))).pipe( Effect.orElseSucceed(() => ''), @@ -254,6 +264,7 @@ Feature('Framework plugins joining a mutation run') const row = skipped?.files.find((file) => file.file.endsWith('widget.svelte')) return expect({ runSucceeded: Exit.isSuccess(s.observation.exit), + failure: failureEvidenceOf(s.observation), verdictCount: verdicts.length, skippedExtension: row?.extension, skippedReasonNamesFramework: row?.reason.includes('@systemfsoftware/stryker-js-svelte') ?? false, @@ -262,6 +273,7 @@ Feature('Framework plugins joining a mutation run') verdictHasSvelteMutant: verdicts[0]?.mutants.some((mutant) => mutant.file.endsWith('.svelte')) ?? true, }).toEqual({ runSucceeded: true, + failure: undefined, verdictCount: 1, skippedExtension: '.svelte', skippedReasonNamesFramework: true, @@ -292,27 +304,30 @@ Feature('Framework plugins joining a mutation run') 'the run refuses before touching any file, naming the missing peer, with no verdict and no files examined', )( (s, expect) => { - const failure = s.observation.events.find((event): event is RunEvent.RunFailed => - S.is(RunEvent.RunFailed)(event) - ) + const evidence = failureEvidenceOf(s.observation) + const load = evidence !== undefined && Predicate.isTagged(evidence, 'PluginLoadFailed') + ? evidence + : undefined const phases = s.observation.events .filter((event): event is RunEvent.PhaseEntered => S.is(RunEvent.PhaseEntered)(event)) .map((phase) => phase.phase) return expect({ runFailed: Exit.isFailure(s.observation.exit), - reason: failure?.reason?._tag, - code: failure?.code, - errorNamesPeer: failure?.error.includes('peer-missing') ?? false, - remediationNamesPeerDependency: failure?.remediation.includes('peer dependency') ?? false, + tag: evidence?._tag, + stage: evidence?.stage, + code: evidence === undefined ? undefined : FailureRecord.FailureCatalog[evidence._tag].exitCode, + descriptorNamesPeer: load?.descriptor.includes('peer-missing') ?? false, + reasonTag: load?.reason._tag, phases, reachedVerdict: s.observation.events.some((event) => S.is(RunEvent.VerdictReached)(event)), reportedSkippedFiles: s.observation.events.some((event) => S.is(RunEvent.SkippedReported)(event)), }).toEqual({ runFailed: true, - reason: 'PeerMissing', + tag: 'PluginLoadFailed', + stage: 'config', code: 2, - errorNamesPeer: true, - remediationNamesPeerDependency: true, + descriptorNamesPeer: true, + reasonTag: 'PeerMissing', phases: ['prepare'], reachedVerdict: false, reportedSkippedFiles: false, @@ -351,43 +366,44 @@ Feature('Framework plugins joining a mutation run') Then( 'the resolved-formats report names the claimed type, its mutant is exercised, and the run state records the claim', )( - (s, expect) => - Effect.map( - S.decodeEffect(S.fromJsonString(Engine.IncrementalReportSchema))(s.observation.incrementalState), - (state) => { - const formats = s.observation.events.find( - (event): event is RunEvent.FormatRegistryResolved => S.is(RunEvent.FormatRegistryResolved)(event), - ) - const formatRow = formats?.rows.find((candidate) => candidate.extension === '.fixture') - const tested = s.observation.events.filter( - (event): event is RunEvent.RunMutantTested => S.is(RunEvent.RunMutantTested)(event), - ) - const fromClaimed = tested.find((mutant) => mutant.fileName.endsWith('widget.fixture')) - return expect({ - runSucceeded: Exit.isSuccess(s.observation.exit), - formatOwner: formatRow?.ownerModule, - formatId: formatRow?.formatId, - formatLanguage: formatRow?.language, - claimedMutantStatus: fromClaimed?.status, - stateLanguage: state.files['src/widget.fixture']?.language, - stateFormatIdentity: state.files['src/widget.fixture']?.formatIdentity, - scriptLanguage: state.files['src/math.js']?.language, - }).toEqual({ - runSucceeded: true, - formatOwner: pluginUrlOf('valid-framework.fixture.mjs'), - formatId: 'fixture', - formatLanguage: 'fixture', - claimedMutantStatus: 'NoCoverage', - stateLanguage: 'fixture', - stateFormatIdentity: { - formatId: 'fixture', - ownerModule: pluginUrlOf('valid-framework.fixture.mjs'), - ownerVersion: '1.0.0', - }, - scriptLanguage: 'javascript', - }) + (s, expect) => { + const state = Option.getOrUndefined( + S.decodeOption(S.fromJsonString(Engine.IncrementalReportSchema))(s.observation.incrementalState), + ) + const formats = s.observation.events.find( + (event): event is RunEvent.FormatRegistryResolved => S.is(RunEvent.FormatRegistryResolved)(event), + ) + const formatRow = formats?.rows.find((candidate) => candidate.extension === '.fixture') + const tested = s.observation.events.filter( + (event): event is RunEvent.RunMutantTested => S.is(RunEvent.RunMutantTested)(event), + ) + const fromClaimed = tested.find((mutant) => mutant.fileName.endsWith('widget.fixture')) + return expect({ + runSucceeded: Exit.isSuccess(s.observation.exit), + failure: failureEvidenceOf(s.observation), + formatOwner: formatRow?.ownerModule, + formatId: formatRow?.formatId, + formatLanguage: formatRow?.language, + claimedMutantStatus: fromClaimed?.status, + stateLanguage: state?.files['src/widget.fixture']?.language, + stateFormatIdentity: state?.files['src/widget.fixture']?.formatIdentity, + scriptLanguage: state?.files['src/math.js']?.language, + }).toEqual({ + runSucceeded: true, + failure: undefined, + formatOwner: pluginUrlOf('valid-framework.fixture.mjs'), + formatId: 'fixture', + formatLanguage: 'fixture', + claimedMutantStatus: 'NoCoverage', + stateLanguage: 'fixture', + stateFormatIdentity: { + formatId: 'fixture', + ownerModule: pluginUrlOf('valid-framework.fixture.mjs'), + ownerVersion: '1.0.0', }, - ), + scriptLanguage: 'javascript', + }) + }, ), ), ) @@ -464,19 +480,22 @@ Feature('Framework plugins joining a mutation run') (s) => runOver(s.workspace), ), Then('the run refuses with an internal-error exit code naming the failing module')((s, expect) => { - const failure = s.observation.events.find((event): event is RunEvent.RunFailed => - S.is(RunEvent.RunFailed)(event) - ) + const evidence = failureEvidenceOf(s.observation) + const imported = evidence !== undefined && Predicate.isTagged(evidence, 'PluginImportFailed') + ? evidence + : undefined return expect({ runFailed: Exit.isFailure(s.observation.exit), - reason: failure?.reason?._tag, - code: failure?.code, - errorNamesModule: failure?.error.includes('throws-on-import') ?? false, + tag: evidence?._tag, + stage: evidence?.stage, + code: evidence === undefined ? undefined : FailureRecord.FailureCatalog[evidence._tag].exitCode, + descriptorNamesModule: imported?.descriptor.includes('throws-on-import') ?? false, }).toEqual({ runFailed: true, - reason: 'ImportFailed', + tag: 'PluginImportFailed', + stage: 'config', code: 4, - errorNamesModule: true, + descriptorNamesModule: true, }) }), ), @@ -498,19 +517,24 @@ Feature('Framework plugins joining a mutation run') (s) => runOver(s.workspace), ), Then('the run refuses with a configuration exit code naming the plugin module')((s, expect) => { - const failure = s.observation.events.find((event): event is RunEvent.RunFailed => - S.is(RunEvent.RunFailed)(event) - ) + const evidence = failureEvidenceOf(s.observation) + const load = evidence !== undefined && Predicate.isTagged(evidence, 'PluginLoadFailed') + ? evidence + : undefined return expect({ runFailed: Exit.isFailure(s.observation.exit), - reason: failure?.reason?._tag, - code: failure?.code, - errorNamesModule: failure?.error.includes('malformed') ?? false, + tag: evidence?._tag, + stage: evidence?.stage, + code: evidence === undefined ? undefined : FailureRecord.FailureCatalog[evidence._tag].exitCode, + descriptorNamesModule: load?.descriptor.includes('malformed') ?? false, + reasonTag: load?.reason._tag, }).toEqual({ runFailed: true, - reason: 'InvalidContribution', + tag: 'PluginLoadFailed', + stage: 'config', code: 2, - errorNamesModule: true, + descriptorNamesModule: true, + reasonTag: 'InvalidContribution', }) }), ), @@ -535,27 +559,30 @@ Feature('Framework plugins joining a mutation run') 'the run refuses before touching any file, naming the unrecognized peer, with no verdict and no files examined', )( (s, expect) => { - const failure = s.observation.events.find((event): event is RunEvent.RunFailed => - S.is(RunEvent.RunFailed)(event) - ) + const evidence = failureEvidenceOf(s.observation) + const load = evidence !== undefined && Predicate.isTagged(evidence, 'PluginLoadFailed') + ? evidence + : undefined const phases = s.observation.events .filter((event): event is RunEvent.PhaseEntered => S.is(RunEvent.PhaseEntered)(event)) .map((phase) => phase.phase) return expect({ runFailed: Exit.isFailure(s.observation.exit), - reason: failure?.reason?._tag, - code: failure?.code, - errorNamesPeer: failure?.error.includes('peer-unrecognized') ?? false, - remediationNamesRecognition: failure?.remediation.includes('recognizes') ?? false, + tag: evidence?._tag, + stage: evidence?.stage, + code: evidence === undefined ? undefined : FailureRecord.FailureCatalog[evidence._tag].exitCode, + descriptorNamesPeer: load?.descriptor.includes('peer-unrecognized') ?? false, + reasonTag: load?.reason._tag, phases, reachedVerdict: s.observation.events.some((event) => S.is(RunEvent.VerdictReached)(event)), reportedSkippedFiles: s.observation.events.some((event) => S.is(RunEvent.SkippedReported)(event)), }).toEqual({ runFailed: true, - reason: 'PeerUnrecognized', + tag: 'PluginLoadFailed', + stage: 'config', code: 2, - errorNamesPeer: true, - remediationNamesRecognition: true, + descriptorNamesPeer: true, + reasonTag: 'PeerUnrecognized', phases: ['prepare'], reachedVerdict: false, reportedSkippedFiles: false, diff --git a/packages/stryker-js/tests/framework-stream.integration.test.ts b/packages/stryker-js/tests/framework-stream.integration.test.ts index 7e61d9ed..b8ce43b8 100644 --- a/packages/stryker-js/tests/framework-stream.integration.test.ts +++ b/packages/stryker-js/tests/framework-stream.integration.test.ts @@ -1,7 +1,8 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' -import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, RunEvent } from '@systemfsoftware/stryker-js-cli-contract' import * as Effect from 'effect/Effect' import * as Layer from 'effect/Layer' +import * as Predicate from 'effect/Predicate' import * as S from 'effect/Schema' const Feature = makeFeature({ it }) @@ -43,11 +44,17 @@ const skippedReport = RunEvent.SkippedReported.make({ }) const refusal = RunEvent.RunFailed.make({ - schemaVersion: '2.0', + schemaVersion: '3.0', code: 2, - error: `Failed to load plugin "${frameworkModule}" (PeerMissing)`, - remediation: 'install the peer dependency the plugin needs', - reason: { _tag: 'PeerMissing', peer: frameworkModule }, + record: FailureRecord.recordOf( + { + _tag: 'PluginLoadFailed', + stage: 'config', + descriptor: frameworkModule, + reason: { _tag: 'PeerMissing', peer: frameworkModule }, + }, + { cause: [], cwd: '/project', argv: ['stryker', 'run'], env: [], traceId: null }, + ), }) type ReportEvent = @@ -113,13 +120,23 @@ Feature('Reporting framework plugins on the machine wire') 'seen', (s) => decodedOf(s.line), ), - Then('the failure names the reason, the configuration code, and the remedy')((s, expect) => - expect({ reason: s.seen.reason, code: s.seen.code, remediation: s.seen.remediation }).toEqual({ + Then('the failure names the reason, the configuration code, and the remedy')((s, expect) => { + const record = s.seen.record + const load = Predicate.isTagged(record, 'PluginLoadFailed') ? record : undefined + return expect({ + tag: record._tag, + descriptor: load?.descriptor, + reason: load?.reason, + code: s.seen.code, + next: record.nextAction.primary, + }).toEqual({ + tag: 'PluginLoadFailed', + descriptor: frameworkModule, reason: { _tag: 'PeerMissing', peer: frameworkModule }, code: 2, - remediation: 'install the peer dependency the plugin needs', + next: 'fixConfiguration', }) - ), + }), ), ) diff --git a/packages/stryker-js/tests/incremental-reuse.integration.test.ts b/packages/stryker-js/tests/incremental-reuse.integration.test.ts index 46b40fd3..c8cbf584 100644 --- a/packages/stryker-js/tests/incremental-reuse.integration.test.ts +++ b/packages/stryker-js/tests/incremental-reuse.integration.test.ts @@ -44,7 +44,7 @@ const mutantsOf = (text: string): readonly RecordedMutant[] => }) interface RunObservation { - readonly exit: Exit.Exit + readonly exit: Exit.Exit readonly events: ReadonlyArray readonly reuse: RunEvent.ReuseReported | undefined readonly verdict: RunEvent.VerdictReached | undefined diff --git a/packages/stryker-js/tests/instrument-stage.integration.test.ts b/packages/stryker-js/tests/instrument-stage.integration.test.ts index f06851b7..4479d8d3 100644 --- a/packages/stryker-js/tests/instrument-stage.integration.test.ts +++ b/packages/stryker-js/tests/instrument-stage.integration.test.ts @@ -40,7 +40,7 @@ const runFromProject = ( root: string, options: Options.PartialStrykerOptions, ): Effect.Effect< - Result.Result, + Result.Result, never, Engine.EnginePorts > => @@ -58,13 +58,15 @@ const runFromProject = ( ) const failureOf = ( - outcome: Result.Result, -): Engine.StageError => { + outcome: Result.Result, +): Engine.RunFailure => { if (Result.isSuccess(outcome)) { throw new Error('the run was expected to be refused, but it completed') } - if (!S.is(Engine.StageError)(outcome.failure)) { - throw new Error(`the run was expected to be refused as a stage, not a platform failure: ${String(outcome.failure)}`) + if (!S.is(Engine.RunFailure)(outcome.failure)) { + throw new Error( + `the run was expected to be refused as a run failure, not a platform failure: ${String(outcome.failure)}`, + ) } return outcome.failure } @@ -72,7 +74,7 @@ const failureOf = ( const carriesMessage = (value: unknown): value is { readonly message: string } => typeof value === 'object' && value !== null && 'message' in value && typeof value.message === 'string' -const textOf = (cause: Engine.StageError['cause']): string => +const textOf = (cause: Engine.RunFailure['cause']): string => carriesMessage(cause) ? cause.message : 'the refused cause carried no message' const runLayer = Layer.mergeAll(Engine.nodePlatformLayer, Stdio.layerTest({})) @@ -99,14 +101,14 @@ Feature('Opting a mutation run into extra mutations') const failure = failureOf(s.outcome) const cause = textOf(failure.cause) return expect({ - stage: failure.stage, - exitClass: failure['exitClass'], - unknownEntryNamed: failure.message.includes(UNKNOWN_NAME), + evidenceTag: failure.evidence._tag, + stage: failure.evidence.stage, + detailNamesTheUnknownEntry: failure.detail.includes(UNKNOWN_NAME), causeNamesTheUnknownEntry: cause.includes(UNKNOWN_NAME), }).toEqual({ - stage: 'prepare', - exitClass: 'ConfigError', - unknownEntryNamed: true, + evidenceTag: 'ConfigInvalid', + stage: 'config', + detailNamesTheUnknownEntry: true, causeNamesTheUnknownEntry: true, }) }, diff --git a/packages/stryker-js/tests/mutant-location-parity.integration.test.ts b/packages/stryker-js/tests/mutant-location-parity.integration.test.ts index b663cb14..8416cdd8 100644 --- a/packages/stryker-js/tests/mutant-location-parity.integration.test.ts +++ b/packages/stryker-js/tests/mutant-location-parity.integration.test.ts @@ -95,7 +95,7 @@ const environmentFor = (directory: string): Engine.RunEnvironmentShape => ({ }) interface ObservedRun { - readonly exit: Exit.Exit + readonly exit: Exit.Exit readonly events: ReadonlyArray readonly report: Option.Option } diff --git a/packages/stryker-js/tests/plugin-mutator-provider.integration.test.ts b/packages/stryker-js/tests/plugin-mutator-provider.integration.test.ts index 11d92830..1f2003bc 100644 --- a/packages/stryker-js/tests/plugin-mutator-provider.integration.test.ts +++ b/packages/stryker-js/tests/plugin-mutator-provider.integration.test.ts @@ -67,7 +67,7 @@ const environmentFor = (directory: string): Engine.RunEnvironmentShape => ({ }) interface ObservedRun { - readonly outcome: Result.Result + readonly outcome: Result.Result readonly events: ReadonlyArray readonly report: Option.Option } @@ -153,12 +153,19 @@ const reportOf = (run: ObservedRun): Report.MutationTestResult => Option.getOrThrowWith(run.report, () => new Error('the run wrote no JSON report')) const failureTextOf = (run: ObservedRun): string => - Result.isFailure(run.outcome) && S.is(Engine.StageError)(run.outcome.failure) - ? run.outcome.failure.message + Result.isFailure(run.outcome) && S.is(Engine.RunFailure)(run.outcome.failure) + ? run.outcome.failure.detail : 'no prepare refusal' const stageOf = (run: ObservedRun): string | null => - Result.isFailure(run.outcome) && S.is(Engine.StageError)(run.outcome.failure) ? run.outcome.failure.stage : null + Result.isFailure(run.outcome) && S.is(Engine.RunFailure)(run.outcome.failure) + ? run.outcome.failure.evidence.stage + : null + +const tagOf = (run: ObservedRun): string | null => + Result.isFailure(run.outcome) && S.is(Engine.RunFailure)(run.outcome.failure) + ? run.outcome.failure.evidence._tag + : null Feature('A plugin contributing a namespaced mutator catalog') .withLayer(Layer.empty) @@ -193,19 +200,19 @@ Feature('A plugin contributing a namespaced mutator catalog') Gherkin.Do.pipe( Given('two provider modules declaring the same namespace')('plugins', () => Effect.succeed([PROVIDER, RIVAL])), When('a mutation run loads both')('observation', (s) => runAndClean(s.plugins)), - Then('the run is refused while preparing, and the refusal names both modules')((s, expect) => { + Then('the run is refused as a configuration error, and the refusal names both modules')((s, expect) => { const message = failureTextOf(s.observation) return expect({ refused: Result.isFailure(s.observation.outcome), + tag: tagOf(s.observation), stage: stageOf(s.observation), - prepareFailureNamed: message.includes('Prepare failed'), providerNamed: message.includes('mutator-provider/index.mjs'), rivalNamed: message.includes('mutator-provider/rival.mjs'), namespaceNamed: message.includes('acme'), }).toEqual({ refused: true, - stage: 'prepare', - prepareFailureNamed: true, + tag: 'ConfigInvalid', + stage: 'config', providerNamed: true, rivalNamed: true, namespaceNamed: true, diff --git a/packages/stryker-js/tests/rerun-by-id.integration.test.ts b/packages/stryker-js/tests/rerun-by-id.integration.test.ts index 794b9172..4cbac598 100644 --- a/packages/stryker-js/tests/rerun-by-id.integration.test.ts +++ b/packages/stryker-js/tests/rerun-by-id.integration.test.ts @@ -7,13 +7,14 @@ import * as Fiber from 'effect/Fiber' import * as FileSystem from 'effect/FileSystem' import * as Option from 'effect/Option' import * as Path from 'effect/Path' +import * as Predicate from 'effect/Predicate' import * as ChildProcess from 'effect/process/ChildProcess' import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner' import * as S from 'effect/Schema' import * as Scope from 'effect/Scope' import * as Stream from 'effect/Stream' -import { ErrorEnvelope, ReportSurvivors, ReproducerList } from './__fixtures__/rerun-by-id.schema.js' +import { ReportSurvivors, ReproducerList } from './__fixtures__/rerun-by-id.schema.js' const Feature = makeFeature({ it }) @@ -123,8 +124,8 @@ const readFirstSurvivorId = (root: string) => const eventLinesOf = (stdout: string): ReadonlyArray => stdout.split('\n').flatMap((line) => (line.trimStart().startsWith('{') ? [line.trim()] : [])) -const errorEnvelopesOf = (stdout: string): ReadonlyArray => - eventLinesOf(stdout).flatMap((line) => Option.toArray(S.decodeOption(S.fromJsonString(ErrorEnvelope))(line))) +const runFailuresOf = (stdout: string): ReadonlyArray => + eventLinesOf(stdout).flatMap((line) => Option.toArray(S.decodeOption(S.fromJsonString(RunEvent.RunFailed))(line))) const mutantDetailsOf = (stdout: string): ReadonlyArray => eventLinesOf(stdout).flatMap((line) => @@ -195,27 +196,36 @@ Feature('Re-running one mutant by its id', { timeout: 180_000 }) (s) => runStryker(s.project.root, 'machine', ['run', '--mutant', UNKNOWN_ID]), ), Then( - 'both runs are refused with the configuration exit code, the person is told the id and the remedy exactly once, and the machine reads the same refusal', + 'both runs are refused with the configuration exit code, the person is told the id and the remedy, and the machine reads the same refusal', )( (s, expect) => { - const failure = Arr.head(errorEnvelopesOf(s.machine.stdout)) + const failure = Arr.head(runFailuresOf(s.machine.stdout)) const says = (needle: string): boolean => - Option.getOrElse(Option.map(failure, (envelope) => envelope.error.includes(needle)), () => false) + Option.getOrElse( + Option.map(failure, (envelope) => { + const record = envelope.record + return Predicate.isTagged(record, 'ConfigInvalid') ? record.detail.includes(needle) : false + }), + () => false, + ) return expect({ humanExitCode: s.human.exitCode, humanNamesTheId: s.human.stderr.includes(UNKNOWN_ID), humanNamesTheRemediation: s.human.stderr.includes('stryker run'), - humanSaysItOnce: s.human.stderr.split(UNKNOWN_ID).length - 1, machineExitCode: s.machine.exitCode, machineCode: Option.map(failure, (envelope) => envelope.code), + machineIsConfigInvalid: Option.exists( + failure, + (envelope) => Predicate.isTagged(envelope.record, 'ConfigInvalid'), + ), machineNamesTheId: says(UNKNOWN_ID), }).toStrictEqual({ humanExitCode: 2, humanNamesTheId: true, humanNamesTheRemediation: true, - humanSaysItOnce: 1, machineExitCode: 2, machineCode: Option.some(2), + machineIsConfigInvalid: true, machineNamesTheId: true, }) }, diff --git a/packages/stryker-js/tests/run-event-stream.integration.test.ts b/packages/stryker-js/tests/run-event-stream.integration.test.ts index 98e13d41..0ae5b3e6 100644 --- a/packages/stryker-js/tests/run-event-stream.integration.test.ts +++ b/packages/stryker-js/tests/run-event-stream.integration.test.ts @@ -1,6 +1,6 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { RunEvent } from '@systemfsoftware/stryker-js' -import { RunEvent as CliContract } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, RunEvent as CliContract } from '@systemfsoftware/stryker-js-cli-contract' import { Mutant, Report } from '@systemfsoftware/stryker-js-plugin-interface' import * as Effect from 'effect/Effect' import * as Layer from 'effect/Layer' @@ -19,14 +19,17 @@ const Feature = makeFeature({ it }) const PLAN_KNOWN = CliContract.PlanKnown.make({ total: 4 }) const PHASE_ENTERED = CliContract.PhaseEntered.make({ phase: 'dry-run', elapsedMs: 1 }) const HEARTBEAT = CliContract.Heartbeat.make({ elapsedMs: 2, completed: 1, total: 4 }) -const HELP_RENDERED = CliContract.HelpRendered.make({ schemaVersion: '2.0', code: 0, help: 'usage' }) +const HELP_RENDERED = CliContract.HelpRendered.make({ schemaVersion: '3.0', code: 0, help: 'usage' }) +const RUN_FAILED_RECORD = FailureRecord.recordOf( + { _tag: 'TestRunnerFailed', stage: 'mutationTest' }, + { cause: [], cwd: '/base', argv: ['stryker', 'run'], env: [], traceId: null }, +) const RUN_FAILED = CliContract.RunFailed.make({ - schemaVersion: '2.0', + schemaVersion: '3.0', code: 3, - error: 'x', - remediation: 'y', - reason: null, + record: RUN_FAILED_RECORD, }) +const RUN_FAILED_TEXT = FailureRecord.terminalTextOf(RUN_FAILED_RECORD) const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)) @@ -255,7 +258,7 @@ Feature('Streaming a run to machine readers') tags: ['stream', 'plan', 'phase', 'tick', 'help'], newlineTerminated: true, stderr: [], - opening: { mode: 'machine', signal: 'tty', schemaVersion: '2.0', runIdIsNonEmpty: true }, + opening: { mode: 'machine', signal: 'tty', schemaVersion: '3.0', runIdIsNonEmpty: true }, }) }), ), @@ -287,7 +290,7 @@ Feature('Streaming a run to machine readers') open: s.result.open, }).toEqual({ stdout: [], - stderr: ['plan 4 mutants', 'phase dry-run', 'error x'], + stderr: ['plan 4 mutants', 'phase dry-run', RUN_FAILED_TEXT], open: false, }) ), @@ -364,7 +367,7 @@ Feature('Streaming a run to machine readers') }), ), Then( - 'the consumer receives only the opening header and the error document, which carries the failure code, error message, and remediation guidance, and the stream is permanently closed', + 'the consumer receives only the opening header and the failure document, which carries the failure code and its structured record, and the stream is permanently closed', )((s, expect) => { const failure = Option.filter(decodedEventAt(s.result.stdout, 1), S.is(CliContract.RunFailed)) return expect({ @@ -373,15 +376,14 @@ Feature('Streaming a run to machine readers') onNone: () => null, onSome: (failed) => ({ code: failed.code, - error: failed.error, - remediation: failed.remediation, + tag: failed.record._tag, schemaVersion: failed.schemaVersion, }), }), open: s.result.open, }).toEqual({ tags: ['stream', 'error'], - failure: { code: 3, error: 'x', remediation: 'y', schemaVersion: '2.0' }, + failure: { code: 3, tag: 'TestRunnerFailed', schemaVersion: '3.0' }, open: false, }) }), diff --git a/packages/stryker-js/tests/slow-reporter-terminal-report.integration.test.ts b/packages/stryker-js/tests/slow-reporter-terminal-report.integration.test.ts index 5342fb3b..2c126fe2 100644 --- a/packages/stryker-js/tests/slow-reporter-terminal-report.integration.test.ts +++ b/packages/stryker-js/tests/slow-reporter-terminal-report.integration.test.ts @@ -35,7 +35,7 @@ interface Workspace { } interface ObservedRun { - readonly exit: Exit.Exit + readonly exit: Exit.Exit readonly observation: Option.Option readonly report: Option.Option } diff --git a/packages/stryker-js/tests/vm-parity.differential.test.ts b/packages/stryker-js/tests/vm-parity.differential.test.ts index e0354af5..a681a1c3 100644 --- a/packages/stryker-js/tests/vm-parity.differential.test.ts +++ b/packages/stryker-js/tests/vm-parity.differential.test.ts @@ -279,7 +279,7 @@ const withVmRunner = ( const describeDryRunFailure = (dry: Exclude): string => dry.status === 'error' ? `the vm dry run failed: ${dry.errorMessage}` : `the vm dry run ended in ${dry.status}` -const vmTestOutcomes = (root: string, path: Path.Path): Effect.Effect => +const vmTestOutcomes = (root: string): Effect.Effect => serialized( withVmRunner(root, (runner) => Effect.gen(function*() { @@ -292,7 +292,7 @@ const vmTestOutcomes = (root: string, path: Path.Path): Effect.Effect return recordOf(dry.tests.map((test): readonly [string, string] => { const hash = test.id.lastIndexOf('#') const file = hash === -1 ? '' : test.id.slice(0, hash) - const name = test.name === path.join(root, file) ? FILE_FAILED_WITHOUT_FAILING_TEST : test.name + const name = test.name === file ? FILE_FAILED_WITHOUT_FAILING_TEST : test.name return [`${file}#${name}`, test.status] })) })), @@ -658,7 +658,7 @@ const generatedRealOutcomes = (suite: GeneratedSuite): Effect.Effect = ) const generatedVmOutcomes = (suite: GeneratedSuite): Effect.Effect => - withGeneratedSuite(suite, (directory, path) => vmTestOutcomes(directory, path)).pipe( + withGeneratedSuite(suite, (directory) => vmTestOutcomes(directory)).pipe( Effect.provide(Engine.nodePlatformLayer), dieOnFailure, labelled('the vm runner failed'), diff --git a/packages/stryker-js/tests/wall-clock-timeout.integration.test.ts b/packages/stryker-js/tests/wall-clock-timeout.integration.test.ts index e9053b3a..a193531a 100644 --- a/packages/stryker-js/tests/wall-clock-timeout.integration.test.ts +++ b/packages/stryker-js/tests/wall-clock-timeout.integration.test.ts @@ -112,7 +112,7 @@ interface MutantRow { } interface ObservedRun { - readonly exit: Exit.Exit + readonly exit: Exit.Exit readonly events: ReadonlyArray readonly daemonMutants: ReadonlyArray } diff --git a/packages/stryker-js/tests/worker-connection-loss.integration.test.ts b/packages/stryker-js/tests/worker-connection-loss.integration.test.ts index be48e4d3..dc2e4579 100644 --- a/packages/stryker-js/tests/worker-connection-loss.integration.test.ts +++ b/packages/stryker-js/tests/worker-connection-loss.integration.test.ts @@ -109,6 +109,7 @@ const makeHarness = (bootRefusals = 0) => rpcs: Plugin.CheckerRpcs, tempDirPrefix: 'checker-', workingDirectory: '/project', + workerKind: 'checker', }).pipe(Effect.provide(launcherLayer)) return { client, gate, received } satisfies SilentWorkerHarness diff --git a/packages/stryker-js/tests/worker-launcher.integration.test.ts b/packages/stryker-js/tests/worker-launcher.integration.test.ts index bcdb3ac0..a5324060 100644 --- a/packages/stryker-js/tests/worker-launcher.integration.test.ts +++ b/packages/stryker-js/tests/worker-launcher.integration.test.ts @@ -43,6 +43,7 @@ const bootPingWorker = ( workingDirectory: WORKING_DIRECTORY, execArgv: EXEC_ARGV, tempDirPrefix: TEMP_DIR_PREFIX, + workerKind: 'testRunner', env: undefined, }).pipe( Effect.flatMap((client) => client.ping({ message: 'boot' })), diff --git a/scripts/guards/check-contract-versions.ts b/scripts/guards/check-contract-versions.ts index c8b9ecbb..f7672565 100755 --- a/scripts/guards/check-contract-versions.ts +++ b/scripts/guards/check-contract-versions.ts @@ -15,6 +15,7 @@ const KIND_BY_BASENAME: Record = { 'stream.schema.json': 'json-schema', 'report.schema.json': 'json-schema', 'stock-catalog.json': 'catalog', + 'failure-catalog.json': 'catalog', 'span-taxonomy.json': 'taxonomy', } diff --git a/test/e2e/tests/__fixtures__/typescript-checker.fixture.ts b/test/e2e/tests/__fixtures__/typescript-checker.fixture.ts index 8d50d88c..d68f56ef 100644 --- a/test/e2e/tests/__fixtures__/typescript-checker.fixture.ts +++ b/test/e2e/tests/__fixtures__/typescript-checker.fixture.ts @@ -1,4 +1,4 @@ -import type { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, type RunEvent } from '@systemfsoftware/stryker-js-cli-contract' import { Report } from '@systemfsoftware/stryker-js-plugin-interface' import type { Check, Expect } from '@systemfsoftware/vitest' import type { ExecResult } from '../../src/Harness/guest-job.schema.js' @@ -86,6 +86,9 @@ export const reportEnvelopeOf = (run: ExecResult, report: Report.MutationTestRes export const verifyReportEnvelope = (expect: Expect, envelope: ReportEnvelope): Check => expect(envelope).toStrictEqual({ exitCode: 0, schemaVersion: '1.0' }) +const BROKEN_CHECKER_EXIT_CODE = 3 +const MISSING_TSCONFIG = 'non-existent-tsconfig.json' + export const verifyBrokenCheckerError = ( expect: Expect, run: ExecResult, @@ -93,20 +96,27 @@ export const verifyBrokenCheckerError = ( terminal: RunEvent.RunEvent, ): Check => { const kinds = events.map((event) => event._tag) - const errorDocument: RunEvent.RunFailed | undefined = terminal._tag === 'error' ? terminal : undefined + const failure: RunEvent.RunFailed | undefined = terminal._tag === 'error' ? terminal : undefined + const record: FailureRecord.FailureRecord | undefined = failure?.record return expect({ - exitCodeIsZero: run.exitCode === 0, + exitCode: run.exitCode, lastKind: kinds.at(-1), carriesVerdict: kinds.includes('verdict'), terminalTag: terminal._tag, - errorNamesTsconfig: (errorDocument?.error ?? '').includes('non-existent-tsconfig.json'), + schemaVersion: failure?.schemaVersion, + recordTag: record?._tag, + checker: record?._tag === 'CheckerFailed' ? record.checker : undefined, + causeNamesTsconfig: (record?.cause ?? []).some((link) => link.message.includes(MISSING_TSCONFIG)), }).toStrictEqual({ - exitCodeIsZero: false, + exitCode: BROKEN_CHECKER_EXIT_CODE, lastKind: 'error', carriesVerdict: false, terminalTag: 'error', - errorNamesTsconfig: true, + schemaVersion: '3.0', + recordTag: 'CheckerFailed', + checker: 'typescript', + causeNamesTsconfig: true, }) } diff --git a/test/e2e/tests/failing-run.e2e.test.ts b/test/e2e/tests/failing-run.e2e.test.ts index b9ca9538..fe477fda 100644 --- a/test/e2e/tests/failing-run.e2e.test.ts +++ b/test/e2e/tests/failing-run.e2e.test.ts @@ -1,17 +1,38 @@ import { Gherkin, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' -import type { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import { FailureRecord, type RunEvent } from '@systemfsoftware/stryker-js-cli-contract' import type { Check, Expect } from '@systemfsoftware/vitest' -import { Effect } from 'effect' +import { Effect, Schema } from 'effect' import type { ExecResult } from '../src/Harness/guest-job.schema.js' import { E2eHarnessLive, runStryker } from './__fixtures__/e2e-harness.fixture.js' -import { decodeStream, terminalEvent } from './__fixtures__/machine-stream.fixture.js' +import { decodeStream, MachineStreamError, terminalEvent } from './__fixtures__/machine-stream.fixture.js' -const FAILING_DRY_RUN_RUNTIME_ERROR_CODE = 3 +const BASELINE_TESTS_FAILED_EXIT_CODE = 5 +const STREAM_SCHEMA_VERSION = '3.0' const FAILING_FIXTURE_URL = new URL('../testResources/failing-fixture', import.meta.url) const FAILING_TEST_NAME = 'isEven reports three as even' +const FAILING_TEST_FILE = 'src/thing.test.ts' +const FAILING_TEST_LINE = 7 +const FAILURE_RECORD_FILE = 'reports/mutation/failure.json' +const FAILING_DRY_RUN_ARGV: ReadonlyArray = ['stryker', 'run'] +const BASELINE_TESTS_FAILED_NEXT_ACTION: FailureRecord.NextAction = { primary: 'fixCode', otherwise: 'fixTest' } + +const failureRecordOf = (terminal: RunEvent.RunEvent): FailureRecord.FailureRecord | undefined => + terminal._tag === 'error' ? terminal.record : undefined + +const failingTestOf = ( + record: FailureRecord.FailureRecord | undefined, +): FailureRecord.FailedTestEvidence | undefined => record?._tag === 'BaselineTestsFailed' ? record.tests[0] : undefined + +const decodeFailureRecord = ( + text: string, +): Effect.Effect => + Effect.mapError( + Schema.decodeUnknownEffect(FailureRecord.FailureRecordFile)(text), + (issue) => new MachineStreamError({ line: text.slice(0, 400), detail: `failure record: ${issue.message}` }), + ) const verifyFailingDryRunExit = (expect: Expect, run: ExecResult): Check => - expect(run.exitCode).toBe(FAILING_DRY_RUN_RUNTIME_ERROR_CODE) + expect(run.exitCode).toBe(BASELINE_TESTS_FAILED_EXIT_CODE) const verifyStreamCleanliness = (expect: Expect, events: ReadonlyArray): Check => expect({ @@ -24,31 +45,56 @@ const verifyTypedErrorDocument = ( terminal: RunEvent.RunEvent, events: ReadonlyArray, ): Check => { - const errorDocument: RunEvent.RunFailed | undefined = terminal._tag === 'error' ? terminal : undefined + const failure: RunEvent.RunFailed | undefined = terminal._tag === 'error' ? terminal : undefined + const record = failureRecordOf(terminal) + const capsule = record?.capsule const tags = events.map((event) => event._tag) + const failed = failingTestOf(record) return expect({ terminalTag: terminal._tag, - schemaVersion: errorDocument?.schemaVersion, - code: errorDocument?.code, - errorIsString: typeof errorDocument?.error === 'string', - remediationHasContent: /\S/.test(errorDocument?.remediation ?? ''), + schemaVersion: failure?.schemaVersion, + code: failure?.code, + recordTag: record?._tag, + stage: record?.stage, + capsuleReplays: capsule?._tag === 'Replays', + argv: capsule?._tag === 'Replays' ? capsule.argv : undefined, + nextAction: record?.nextAction, carriesVerdict: tags.includes('verdict'), + testCount: record?._tag === 'BaselineTestsFailed' ? record.testCount : undefined, + failedTestCount: record?._tag === 'BaselineTestsFailed' ? record.tests.length : undefined, + name: failed?.name, + fileNameNamesTheFailingTest: (failed?.file ?? '').endsWith(FAILING_TEST_FILE), + locationFile: failed?.location?.file, + locationLine: failed?.location?.line, + locationColumnIsPositive: (failed?.location?.column ?? 0) >= 1, + messageNamesTheFailure: /\S/.test(failed?.message ?? '') && (failed?.message ?? '').includes('expected'), }).toStrictEqual({ terminalTag: 'error', - schemaVersion: '2.0', - code: FAILING_DRY_RUN_RUNTIME_ERROR_CODE, - errorIsString: true, - remediationHasContent: true, + schemaVersion: STREAM_SCHEMA_VERSION, + code: BASELINE_TESTS_FAILED_EXIT_CODE, + recordTag: 'BaselineTestsFailed', + stage: 'dryRun', + capsuleReplays: true, + argv: FAILING_DRY_RUN_ARGV, + nextAction: BASELINE_TESTS_FAILED_NEXT_ACTION, carriesVerdict: false, + testCount: 1, + failedTestCount: 1, + name: FAILING_TEST_NAME, + fileNameNamesTheFailingTest: true, + locationFile: FAILING_TEST_FILE, + locationLine: FAILING_TEST_LINE, + locationColumnIsPositive: true, + messageNamesTheFailure: true, }) } -const verifyErrorDocumentNamesTheFailingTest = (expect: Expect, errorText: string): Check => - expect({ - namesFailingTest: errorText.includes(FAILING_TEST_NAME), - carriesFailureMessage: /\S/.test(errorText) && errorText.includes('expected'), - }).toStrictEqual({ namesFailingTest: true, carriesFailureMessage: true }) +const verifyPersistedFailureRecord = ( + expect: Expect, + terminal: RunEvent.RunEvent, + persisted: FailureRecord.FailureRecord, +): Check => expect(persisted).toStrictEqual(failureRecordOf(terminal)) const Feature = makeFeature({ it }) @@ -65,7 +111,7 @@ Feature('Failing a mutation run at the process boundary') 'run', () => runStryker({ fixture: FAILING_FIXTURE_URL, label: 'failing-fixture', args: ['run'] }), ), - Then('the process exits with the failing dry run code')((s, expect) => + Then('the process exits with the baseline tests failed code')((s, expect) => verifyFailingDryRunExit(expect, s.run.output.result) ), When('the stdout event stream decodes to run events')( @@ -74,15 +120,15 @@ Feature('Failing a mutation run at the process boundary') ), Then('every machine event is a tagged record')((s, expect) => verifyStreamCleanliness(expect, s.events)), When('the terminal event of the decoded stream is read')('terminal', (s) => terminalEvent(s.events)), - Then('the run emits a structured error document and no verdict')((s, expect) => - verifyTypedErrorDocument(expect, s.terminal, s.events) - ), - When('the failure text carried by the error document is read')( - 'errorText', - (s) => Effect.succeed(s.terminal._tag === 'error' ? s.terminal.error : ''), + Then( + 'the run emits a structured error document whose baseline failure record names the failing test with its file, line and failure message, and no verdict', + )((s, expect) => verifyTypedErrorDocument(expect, s.terminal, s.events)), + When('the failure record the run wrote to disk is read')( + 'persistedRecord', + (s) => Effect.flatMap(s.run.output.readFile(FAILURE_RECORD_FILE), decodeFailureRecord), ), - Then('the error document names the failing test with its failure message')((s, expect) => - verifyErrorDocumentNamesTheFailingTest(expect, s.errorText) + Then('the persisted failure record is the one the terminal event carries')((s, expect) => + verifyPersistedFailureRecord(expect, s.terminal, s.persistedRecord) ), ), )