@skyramp/mcp 0.3.8 → 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/commandLibrary.d.ts +1 -1
- package/build/commands/commandLibrary.js +3 -3
- package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
- package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
- package/build/commands/testThisEndpointCommand.js +35 -19
- package/build/index.js +9 -3
- package/build/playwright/blueprintDigest.d.ts +15 -0
- package/build/playwright/blueprintDigest.js +152 -0
- package/build/playwright/blueprintDigestStore.d.ts +31 -0
- package/build/playwright/blueprintDigestStore.js +117 -0
- package/build/playwright/registerPlaywrightTools.js +60 -12
- package/build/playwright/traceRecordingPrompt.js +8 -7
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
- package/build/prompts/promptAssets.d.ts +20 -0
- package/build/prompts/promptAssets.js +55 -0
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
- package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
- package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
- package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
- package/build/prompts/test-recommendation/recommendationSections.js +67 -309
- package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
- package/build/prompts/test-recommendation/recommendationShared.js +49 -155
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
- package/build/prompts/testbot/planDeclarations.d.ts +6 -0
- package/build/prompts/testbot/planDeclarations.js +9 -0
- package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
- package/build/prompts/testbot/testbot-prompts.js +256 -381
- package/build/recommendation/answers.d.ts +35 -0
- package/build/recommendation/answers.js +96 -0
- package/build/recommendation/registerPlan.d.ts +49 -0
- package/build/recommendation/registerPlan.js +117 -0
- package/build/recommendation/runVerifiers.d.ts +10 -0
- package/build/recommendation/runVerifiers.js +49 -0
- package/build/recommendation/subjectStep.d.ts +42 -0
- package/build/recommendation/subjectStep.js +86 -0
- package/build/recommendation/types.d.ts +163 -0
- package/build/recommendation/types.js +20 -0
- package/build/recommendation/verifierContracts.d.ts +382 -0
- package/build/recommendation/verifierContracts.js +263 -0
- package/build/recommendation/verifiers/changedFile.d.ts +2 -0
- package/build/recommendation/verifiers/changedFile.js +82 -0
- package/build/recommendation/verifiers/citedPath.d.ts +12 -0
- package/build/recommendation/verifiers/citedPath.js +35 -0
- package/build/recommendation/verifiers/coverage.d.ts +7 -0
- package/build/recommendation/verifiers/coverage.js +617 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
- package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
- package/build/recommendation/verifiers/endpointGrounded.js +128 -0
- package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
- package/build/recommendation/verifiers/existingCoverage.js +51 -0
- package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
- package/build/recommendation/verifiers/expectedOutcome.js +105 -0
- package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
- package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
- package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
- package/build/recommendation/verifiers/reportedCategory.js +84 -0
- package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
- package/build/recommendation/verifiers/screenRoute.js +118 -0
- package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
- package/build/recommendation/verifiers/statedDifference.js +140 -0
- package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
- package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
- package/build/resources/analysisResources.js +1 -114
- package/build/resources/testbotResource.js +23 -13
- package/build/services/ModularizationService.js +2 -1
- package/build/services/TestDiscoveryService.d.ts +3 -72
- package/build/services/TestDiscoveryService.js +10 -303
- package/build/services/containerEnv.d.ts +1 -1
- package/build/services/containerEnv.js +12 -0
- package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
- package/build/skills/fixTestImportErrorsSkill.js +20 -0
- package/build/toolNames.d.ts +1 -0
- package/build/toolNames.js +1 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
- package/build/tools/code-refactor/modularizationTool.js +2 -1
- package/build/tools/executeSkyrampTestTool.d.ts +80 -0
- package/build/tools/executeSkyrampTestTool.js +246 -19
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
- package/build/tools/generate-tests/generateContractRestTool.js +3 -3
- package/build/tools/generate-tests/planGuard.d.ts +2 -2
- package/build/tools/generate-tests/planGuard.js +78 -18
- package/build/tools/one-click/oneClickTool.d.ts +0 -1
- package/build/tools/one-click/oneClickTool.js +0 -5
- package/build/tools/submitReportTool.d.ts +48 -42
- package/build/tools/submitReportTool.js +576 -193
- package/build/tools/test-management/actionsTool.js +72 -4
- package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
- package/build/tools/test-management/analyzeChangesTool.js +212 -1219
- package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
- package/build/tools/test-management/index.d.ts +1 -0
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
- package/build/tools/test-management/registerTestPlanTool.js +609 -542
- package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
- package/build/tools/test-management/resolveScreenTool.js +289 -0
- package/build/types/BlueprintDigest.d.ts +34 -0
- package/build/types/BlueprintDigest.js +1 -0
- package/build/types/RepositoryAnalysis.d.ts +20 -1559
- package/build/types/RepositoryAnalysis.js +2 -58
- package/build/types/StepMethod.d.ts +40 -0
- package/build/types/StepMethod.js +77 -0
- package/build/types/TestAnalysis.d.ts +12 -0
- package/build/types/TestExecution.d.ts +4 -0
- package/build/types/TestRecommendation.d.ts +24 -24
- package/build/types/TestRecommendation.js +91 -89
- package/build/types/TestbotPromptOptions.d.ts +0 -4
- package/build/types/TestbotReport.d.ts +64 -2
- package/build/utils/AnalysisStateManager.d.ts +79 -113
- package/build/utils/AnalysisStateManager.js +147 -57
- package/build/utils/assertion-verify/api-shared-lints.js +1 -1
- package/build/utils/assertion-verify/metrics.js +85 -36
- package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
- package/build/utils/assertion-verify/ui-lints.js +32 -0
- package/build/utils/branchDiff.d.ts +63 -31
- package/build/utils/branchDiff.js +242 -94
- package/build/utils/containedPath.d.ts +18 -0
- package/build/utils/containedPath.js +73 -0
- package/build/utils/dartRouteExtractor.d.ts +18 -34
- package/build/utils/dartRouteExtractor.js +101 -173
- package/build/utils/featureFlags.d.ts +12 -0
- package/build/utils/featureFlags.js +14 -0
- package/build/utils/frontendSelectors.d.ts +48 -27
- package/build/utils/frontendSelectors.js +241 -80
- package/build/utils/pathMatching.d.ts +2 -4
- package/build/utils/pathMatching.js +2 -4
- package/build/utils/planMatchKeys.d.ts +38 -47
- package/build/utils/planMatchKeys.js +143 -81
- package/build/utils/rebaselineSnapshots.d.ts +24 -0
- package/build/utils/rebaselineSnapshots.js +65 -0
- package/build/utils/removedUiElements.d.ts +22 -0
- package/build/utils/removedUiElements.js +106 -0
- package/build/utils/reportVerification.d.ts +2 -6
- package/build/utils/reportVerification.js +61 -2
- package/build/utils/screenRoutes.d.ts +66 -0
- package/build/utils/screenRoutes.js +727 -0
- package/build/utils/sourceRouteExtractor.js +320 -112
- package/build/utils/testFileClassification.d.ts +11 -2
- package/build/utils/testFileClassification.js +44 -2
- package/build/utils/testFixtures.d.ts +5 -0
- package/build/utils/testFixtures.js +13 -0
- package/build/utils/utils.d.ts +0 -1
- package/build/utils/utils.js +0 -11
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +12 -12
- package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/plugin.json +6 -0
- package/plugin/prompts/declaring-a-plan.md +20 -0
- package/plugin/prompts/generate-tests/context-fetching.md +4 -0
- package/plugin/prompts/generate-tests/execution-plan.md +63 -0
- package/plugin/prompts/generate-tests/generation.md +108 -0
- package/plugin/prompts/generate-tests/path-parameters.md +1 -0
- package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
- package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
- package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
- package/plugin/prompts/plan-tests.md +42 -0
- package/plugin/prompts/testbot-task1.md +82 -0
- package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
- package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
- package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
- package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
- package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
- package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
- package/build/recommendation/budgeters/shared.d.ts +0 -32
- package/build/recommendation/budgeters/shared.js +0 -246
- package/build/recommendation/discriminators.d.ts +0 -37
- package/build/recommendation/discriminators.js +0 -379
- package/build/recommendation/diversity.d.ts +0 -47
- package/build/recommendation/diversity.js +0 -101
- package/build/recommendation/planRanker.d.ts +0 -65
- package/build/recommendation/planRanker.js +0 -83
- package/build/recommendation/testFixtures.d.ts +0 -25
- package/build/recommendation/testFixtures.js +0 -45
- package/build/types/FrontendIntegration.d.ts +0 -28
- package/build/types/FrontendIntegration.js +0 -22
- package/build/types/Recommendation.d.ts +0 -146
- package/build/types/Recommendation.js +0 -74
- package/build/utils/changedRoutes.d.ts +0 -29
- package/build/utils/changedRoutes.js +0 -87
- package/build/utils/frontendIntegration.d.ts +0 -9
- package/build/utils/frontendIntegration.js +0 -243
- package/build/utils/importerHop.d.ts +0 -135
- package/build/utils/importerHop.js +0 -489
- package/build/utils/pathAffinityClassification.d.ts +0 -49
- package/build/utils/pathAffinityClassification.js +0 -180
- package/build/utils/pythonMountPrefixes.d.ts +0 -25
- package/build/utils/pythonMountPrefixes.js +0 -347
- package/build/utils/repoScanner.d.ts +0 -34
- package/build/utils/repoScanner.js +0 -300
- package/build/utils/routeParsers.d.ts +0 -95
- package/build/utils/routeParsers.js +0 -951
- package/build/utils/scenarioDrafting.d.ts +0 -92
- package/build/utils/scenarioDrafting.js +0 -951
- package/build/utils/subjectEndpoints.d.ts +0 -19
- package/build/utils/subjectEndpoints.js +0 -98
- package/build/utils/uiPageEnumerator.d.ts +0 -172
- package/build/utils/uiPageEnumerator.js +0 -474
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { allChangedFiles } from "../types.js";
|
|
2
|
+
import { LOCKFILE, NOT_A_ROUTE_FILE, NOT_SOURCE_DIR } from "./endpointGrounded.js";
|
|
3
|
+
import { isTestPath } from "../../utils/testFileClassification.js";
|
|
4
|
+
import { normalizeCitedPath } from "./citedPath.js";
|
|
5
|
+
import { COVERAGE_CONTRACT, fillPlaceholders } from "../verifierContracts.js";
|
|
6
|
+
import { isUIPlannedTest, stateRouteKey, subjectStep } from "../subjectStep.js";
|
|
7
|
+
/** Whether a path can be product code at all, by the deny list route grounding
|
|
8
|
+
* already applies to a cited file. Nothing else is excluded: a file's kind is
|
|
9
|
+
* not this check's judgment to make. */
|
|
10
|
+
function couldBeProductCode(file) {
|
|
11
|
+
const path = file.trim();
|
|
12
|
+
if (!path)
|
|
13
|
+
return false;
|
|
14
|
+
return !(isTestPath(path) || NOT_SOURCE_DIR.test(path) || LOCKFILE.test(path) || NOT_A_ROUTE_FILE.test(path));
|
|
15
|
+
}
|
|
16
|
+
/** Every changed file some planned test cites, in normalised spelling. THREE fields,
|
|
17
|
+
* all name-level: `changedFile`, `routes[].file` and `screenEvidence.file`
|
|
18
|
+
* — citing a changed file through any of them is being about that file. Coverage
|
|
19
|
+
* by citation reads names alone, where an anchor search needed the diff text. */
|
|
20
|
+
function citedFiles(plannedTests) {
|
|
21
|
+
const cited = new Set();
|
|
22
|
+
const add = (value) => {
|
|
23
|
+
const path = normalizeCitedPath(value);
|
|
24
|
+
if (path)
|
|
25
|
+
cited.add(path);
|
|
26
|
+
};
|
|
27
|
+
for (const plannedTest of plannedTests) {
|
|
28
|
+
add(plannedTest?.declarations?.changedFile);
|
|
29
|
+
add(plannedTest?.declarations?.screenEvidence?.file);
|
|
30
|
+
// The plan is read off disk, so this is an array only on the validated
|
|
31
|
+
// path. A throw here would reach the agent as `:crashed`, hiding the real
|
|
32
|
+
// objection instead of raising it.
|
|
33
|
+
const evidence = plannedTest?.declarations?.routes;
|
|
34
|
+
if (Array.isArray(evidence))
|
|
35
|
+
for (const entry of evidence)
|
|
36
|
+
add(entry?.file);
|
|
37
|
+
}
|
|
38
|
+
return cited;
|
|
39
|
+
}
|
|
40
|
+
/** The behaviour ids some planned test cites, trimmed. */
|
|
41
|
+
function citedBehaviors(plannedTests) {
|
|
42
|
+
const cited = new Set();
|
|
43
|
+
for (const plannedTest of plannedTests) {
|
|
44
|
+
const declared = plannedTest?.declarations?.changes;
|
|
45
|
+
if (!Array.isArray(declared))
|
|
46
|
+
continue;
|
|
47
|
+
for (const id of declared) {
|
|
48
|
+
const trimmed = String(id ?? "").trim();
|
|
49
|
+
if (trimmed)
|
|
50
|
+
cited.add(trimmed);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return cited;
|
|
54
|
+
}
|
|
55
|
+
/** Every declared behaviour needs a planned test citing it, or an answer. Coverage per
|
|
56
|
+
* file is the basic minimum — one shape test on a changed file satisfies it while
|
|
57
|
+
* the behaviour that file promises goes untested. This half is the target.
|
|
58
|
+
*
|
|
59
|
+
* Checked whether or not the run has a diff: the agent declared these itself, so
|
|
60
|
+
* there is always something to hold it to. */
|
|
61
|
+
function claimObjections(registration) {
|
|
62
|
+
const changes = Array.isArray(registration.changes) ? registration.changes : [];
|
|
63
|
+
const plannedTests = registration.plannedTests ?? [];
|
|
64
|
+
const objections = [];
|
|
65
|
+
if (changes.length === 0) {
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
objectionId: "coverage:noChanges",
|
|
69
|
+
verifier: "coverage",
|
|
70
|
+
message: COVERAGE_CONTRACT.objections.noChanges.message,
|
|
71
|
+
evidence: "the registered plan declares an empty `changes` list.",
|
|
72
|
+
suggestion: COVERAGE_CONTRACT.objections.noChanges.suggestion,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
const declared = new Set();
|
|
77
|
+
for (const change of changes) {
|
|
78
|
+
const id = String(change?.id ?? "").trim();
|
|
79
|
+
if (id)
|
|
80
|
+
declared.add(id);
|
|
81
|
+
}
|
|
82
|
+
const cited = citedBehaviors(plannedTests);
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
const id = String(change?.id ?? "").trim();
|
|
86
|
+
if (!id || seen.has(id))
|
|
87
|
+
continue;
|
|
88
|
+
seen.add(id);
|
|
89
|
+
if (cited.has(id))
|
|
90
|
+
continue;
|
|
91
|
+
objections.push({
|
|
92
|
+
objectionId: `coverage:change:${id}`,
|
|
93
|
+
verifier: "coverage",
|
|
94
|
+
message: COVERAGE_CONTRACT.objections.change.message,
|
|
95
|
+
evidence: `"${String(change?.text ?? "").trim()}" (${String(change?.source ?? "").trim()}): no planned test cites behaviour ${id}.`,
|
|
96
|
+
suggestion: COVERAGE_CONTRACT.objections.change.suggestion,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// One objection per planned test, not per id: a planned test with three unknown ids
|
|
100
|
+
// has one mistake to fix, and three objections would need three answers.
|
|
101
|
+
for (const plannedTest of plannedTests) {
|
|
102
|
+
const claimIds = plannedTest?.declarations?.changes;
|
|
103
|
+
if (!Array.isArray(claimIds))
|
|
104
|
+
continue;
|
|
105
|
+
const unknown = [...new Set(claimIds.map((id) => String(id ?? "").trim()))].filter((id) => id && !declared.has(id));
|
|
106
|
+
if (unknown.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
objections.push({
|
|
109
|
+
objectionId: `coverage:unknownChange:${plannedTest.plannedTestId}`,
|
|
110
|
+
verifier: "coverage",
|
|
111
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
112
|
+
message: COVERAGE_CONTRACT.objections.unknownChange.message,
|
|
113
|
+
evidence: `${plannedTest.plannedTestId} cites ${unknown.map((id) => `"${id}"`).join(", ")}; this plan declares ${[...declared].map((id) => `"${id}"`).join(", ") || "no claims"}.`,
|
|
114
|
+
suggestion: COVERAGE_CONTRACT.objections.unknownChange.suggestion,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return objections;
|
|
118
|
+
}
|
|
119
|
+
// ── The state surface ───────────────────────────────────────────────────────
|
|
120
|
+
/** The methods that write. A GET reads what is already there, so what the record
|
|
121
|
+
* held before the call decides nothing; these four run new code against a value
|
|
122
|
+
* the change did not write, which is where a latent bug sits. */
|
|
123
|
+
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
124
|
+
/** Whether a planned test names the state its starting record is in. Presence is all
|
|
125
|
+
* that is checked: no two repositories describe a record's state the same way, and
|
|
126
|
+
* the test puts the record in that state itself rather than reading it from a file. */
|
|
127
|
+
function declaresStartState(plannedTest) {
|
|
128
|
+
return String(plannedTest?.declarations?.startState ?? "").trim().length > 0;
|
|
129
|
+
}
|
|
130
|
+
/** The route a planned test's test mutates, by SHAPE — `stateRouteKey`, so the id a
|
|
131
|
+
* step happened to write does not split one route in two. UI planned tests are OUT:
|
|
132
|
+
* their subject is a screen, and their endpoints are attributions the agent wrote
|
|
133
|
+
* rather than what the test asserts. */
|
|
134
|
+
function mutatedRoute(plannedTest) {
|
|
135
|
+
if (isUIPlannedTest(plannedTest))
|
|
136
|
+
return undefined;
|
|
137
|
+
const step = subjectStep(plannedTest);
|
|
138
|
+
if (!MUTATING_METHODS.has(String(step?.method ?? "").trim().toUpperCase()))
|
|
139
|
+
return undefined;
|
|
140
|
+
return stateRouteKey(step);
|
|
141
|
+
}
|
|
142
|
+
/** The routes some planned test already tests from a non-default state. A UI planned test
|
|
143
|
+
* COUNTS here where it does not above: a browser test that drives the mutation
|
|
144
|
+
* against such a record is the same evidence as an API one. */
|
|
145
|
+
function routesTestedFromNonDefaultState(plannedTests) {
|
|
146
|
+
const covered = new Set();
|
|
147
|
+
for (const plannedTest of plannedTests) {
|
|
148
|
+
if (!declaresStartState(plannedTest))
|
|
149
|
+
continue;
|
|
150
|
+
// Both sides of the comparison read the same key, or a covered route would
|
|
151
|
+
// never match the route that owes the test.
|
|
152
|
+
const route = stateRouteKey(subjectStep(plannedTest));
|
|
153
|
+
if (route)
|
|
154
|
+
covered.add(route);
|
|
155
|
+
}
|
|
156
|
+
return covered;
|
|
157
|
+
}
|
|
158
|
+
/** Every mutated route needs one test that starts from a record in a state a
|
|
159
|
+
* freshly created record is not in. Read off the PLAN alone, like the behaviour
|
|
160
|
+
* half — the agent declared the mutation itself, so a run with no diff has
|
|
161
|
+
* something to hold it to. */
|
|
162
|
+
function stateTestObjections(registration) {
|
|
163
|
+
const plannedTests = registration.plannedTests ?? [];
|
|
164
|
+
const covered = routesTestedFromNonDefaultState(plannedTests);
|
|
165
|
+
const objections = [];
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
for (const plannedTest of plannedTests) {
|
|
168
|
+
const route = mutatedRoute(plannedTest);
|
|
169
|
+
if (!route || covered.has(route) || seen.has(route))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(route);
|
|
172
|
+
objections.push({
|
|
173
|
+
// Keyed on the ROUTE, not the planned test: the plan owes one state test per
|
|
174
|
+
// route, whichever planned test delivers it.
|
|
175
|
+
objectionId: `coverage:stateTest:${route}`,
|
|
176
|
+
verifier: "coverage",
|
|
177
|
+
message: fillPlaceholders(COVERAGE_CONTRACT.objections.stateTest.message, { route }),
|
|
178
|
+
evidence: `${plannedTest.plannedTestId} calls ${route} as its subject step; no planned test declaring startState has a subject step on it.`,
|
|
179
|
+
suggestion: COVERAGE_CONTRACT.objections.stateTest.suggestion,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return objections;
|
|
183
|
+
}
|
|
184
|
+
// ── The declared cases ──────────────────────────────────────────────────────
|
|
185
|
+
/** One number, from a value a step sends or a case declares. A parameter is as
|
|
186
|
+
* often a string as a number, so `"100"` and `100` are one value. Anything else
|
|
187
|
+
* is no number at all: `Number("")` is 0, which would read as a value that was
|
|
188
|
+
* sent. */
|
|
189
|
+
function asNumber(value) {
|
|
190
|
+
if (typeof value === "number")
|
|
191
|
+
return Number.isFinite(value) ? value : undefined;
|
|
192
|
+
if (typeof value !== "string")
|
|
193
|
+
return undefined;
|
|
194
|
+
const text = value.trim();
|
|
195
|
+
if (!text)
|
|
196
|
+
return undefined;
|
|
197
|
+
const parsed = Number(text);
|
|
198
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
199
|
+
}
|
|
200
|
+
/** Whether a value a step sends is the value a case declares. Numbers when both
|
|
201
|
+
* sides read as numbers, text otherwise, so a query string's `"100"` matches a
|
|
202
|
+
* declared `100` and `"asc"` matches `"asc"`. A declared `null` matches only a
|
|
203
|
+
* value written as null. */
|
|
204
|
+
function sameValue(sent, declared) {
|
|
205
|
+
// Only an `absent` case has no value, and that case never reaches here.
|
|
206
|
+
if (declared === undefined)
|
|
207
|
+
return false;
|
|
208
|
+
if (declared === null)
|
|
209
|
+
return sent === null;
|
|
210
|
+
const sentNumber = asNumber(sent);
|
|
211
|
+
const declaredNumber = asNumber(declared);
|
|
212
|
+
if (sentNumber !== undefined && declaredNumber !== undefined)
|
|
213
|
+
return sentNumber === declaredNumber;
|
|
214
|
+
return String(sent) === String(declared);
|
|
215
|
+
}
|
|
216
|
+
/** Every value of one parameter a step sends, wherever it carries it: the query
|
|
217
|
+
* string written into the path, the `queryParams` object, or the top level of the
|
|
218
|
+
* request body. Nothing reads deeper into a body — a constrained input is a field
|
|
219
|
+
* of the request, not a field nested inside one. */
|
|
220
|
+
function valuesSent(step, param) {
|
|
221
|
+
const found = [];
|
|
222
|
+
const query = String(step?.path ?? "").split("?").slice(1).join("?");
|
|
223
|
+
if (query)
|
|
224
|
+
found.push(...new URLSearchParams(query).getAll(param));
|
|
225
|
+
for (const carrier of [step?.queryParams, step?.requestBody]) {
|
|
226
|
+
if (carrier && typeof carrier === "object" && !Array.isArray(carrier)) {
|
|
227
|
+
const fields = carrier;
|
|
228
|
+
if (param in fields)
|
|
229
|
+
found.push(fields[param]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
/** The parameter name that names the request's verb rather than one of its inputs. */
|
|
235
|
+
const METHOD_PARAM = "method";
|
|
236
|
+
/** A path segment written as a placeholder: `{id}`, `:id` or `[id]`. */
|
|
237
|
+
const PLACEHOLDER_SEGMENT = /^(?:\{(.+)\}|:(.+)|\[(.+)\])$/;
|
|
238
|
+
/** A step's path segments, with any query string dropped. */
|
|
239
|
+
function pathSegments(step) {
|
|
240
|
+
return String(step?.path ?? "").split("?")[0].trim().split("/");
|
|
241
|
+
}
|
|
242
|
+
/** The parameter a placeholder segment names, or undefined for a literal one. */
|
|
243
|
+
function placeholderName(segment) {
|
|
244
|
+
const match = PLACEHOLDER_SEGMENT.exec(segment.trim());
|
|
245
|
+
if (!match)
|
|
246
|
+
return undefined;
|
|
247
|
+
return (match[1] ?? match[2] ?? match[3]).trim() || undefined;
|
|
248
|
+
}
|
|
249
|
+
/** Where `param` sits in a path, according to the steps that write it as a
|
|
250
|
+
* placeholder. Only the plan says this: no route template is inferred from a
|
|
251
|
+
* concrete path, so a plan whose steps are all concrete yields no slot and its
|
|
252
|
+
* path cases read as not sent. */
|
|
253
|
+
function pathSlots(steps, param) {
|
|
254
|
+
const slots = [];
|
|
255
|
+
for (const step of steps) {
|
|
256
|
+
const segments = pathSegments(step);
|
|
257
|
+
segments.forEach((segment, index) => {
|
|
258
|
+
if (placeholderName(segment) === param)
|
|
259
|
+
slots.push({ segments, index });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return slots;
|
|
263
|
+
}
|
|
264
|
+
/** Every name one parameter wears in a route. A case names the input — `id` —
|
|
265
|
+
* where the step writes the route's own placeholder — `{collectionId}`. An accept
|
|
266
|
+
* case whose value is that placeholder is the plan saying the two are the same
|
|
267
|
+
* parameter, so its inner name counts as a name for `param` as well. The plan is
|
|
268
|
+
* still the only source: the names come from what it declares, never from a
|
|
269
|
+
* concrete path. */
|
|
270
|
+
function pathSlotNames(cases, param) {
|
|
271
|
+
const names = new Set([param]);
|
|
272
|
+
for (const declared of cases ?? []) {
|
|
273
|
+
if (String(declared?.param ?? "").trim() !== param)
|
|
274
|
+
continue;
|
|
275
|
+
if (declared?.expect !== "accept" || declared?.absent === true)
|
|
276
|
+
continue;
|
|
277
|
+
if (!isPlaceholderValue(declared?.value))
|
|
278
|
+
continue;
|
|
279
|
+
const inner = String(declared.value).trim().slice(1, -1).trim();
|
|
280
|
+
if (inner)
|
|
281
|
+
names.add(inner);
|
|
282
|
+
}
|
|
283
|
+
return [...names];
|
|
284
|
+
}
|
|
285
|
+
/** The one slot a route with a single placeholder has. A step that writes exactly
|
|
286
|
+
* one placeholder says where the record it names sits, whatever that placeholder
|
|
287
|
+
* is called, so a case on a parameter whose name ends in `id` reads that segment
|
|
288
|
+
* without the plan spelling out the alias. A step with no placeholder still says
|
|
289
|
+
* nothing — no template is read from a concrete path — and a step with two says
|
|
290
|
+
* nothing about which of them is the id. */
|
|
291
|
+
function soleSlots(steps) {
|
|
292
|
+
const slots = [];
|
|
293
|
+
for (const step of steps) {
|
|
294
|
+
const segments = pathSegments(step);
|
|
295
|
+
const placeholders = segments.reduce((at, segment, index) => (placeholderName(segment) ? [...at, index] : at), []);
|
|
296
|
+
if (placeholders.length === 1)
|
|
297
|
+
slots.push({ segments, index: placeholders[0] });
|
|
298
|
+
}
|
|
299
|
+
return slots;
|
|
300
|
+
}
|
|
301
|
+
/** Where `param` sits in a path, under any of the names it wears. */
|
|
302
|
+
function pathSlotsFor(steps, cases, param) {
|
|
303
|
+
const named = pathSlotNames(cases, param).flatMap((name) => pathSlots(steps, name));
|
|
304
|
+
return ID_PARAM.test(param) ? [...named, ...soleSlots(steps)] : named;
|
|
305
|
+
}
|
|
306
|
+
/** Whether a step carries `value` in the segment `param` occupies. Every other
|
|
307
|
+
* segment has to be the template's own, so the same value at the same depth of a
|
|
308
|
+
* different route does not count as sent. */
|
|
309
|
+
function sendsInPath(step, slot, value) {
|
|
310
|
+
const segments = pathSegments(step);
|
|
311
|
+
if (segments.length !== slot.segments.length)
|
|
312
|
+
return false;
|
|
313
|
+
for (let at = 0; at < segments.length; at++) {
|
|
314
|
+
if (at !== slot.index && segments[at] !== slot.segments[at])
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
return sameValue(segments[slot.index], value);
|
|
318
|
+
}
|
|
319
|
+
/** Whether a step's verb is the declared value. A rejected method is the input. */
|
|
320
|
+
function sendsAsMethod(step, value) {
|
|
321
|
+
const method = String(step?.method ?? "").trim();
|
|
322
|
+
if (!method)
|
|
323
|
+
return false;
|
|
324
|
+
return method.toUpperCase() === String(value).trim().toUpperCase();
|
|
325
|
+
}
|
|
326
|
+
/** Whether a step states that it leaves one parameter out. STATED, not inferred:
|
|
327
|
+
* a step that does not carry a parameter is not distinguishable from a step the
|
|
328
|
+
* parameter has nothing to do with. */
|
|
329
|
+
function omittedBy(step, param) {
|
|
330
|
+
const omits = step?.omits;
|
|
331
|
+
if (!Array.isArray(omits))
|
|
332
|
+
return false;
|
|
333
|
+
return omits.some((named) => String(named ?? "").trim() === param);
|
|
334
|
+
}
|
|
335
|
+
/** One declared case as the objection spells it. */
|
|
336
|
+
function renderCase(declared) {
|
|
337
|
+
const param = String(declared.param).trim();
|
|
338
|
+
const sent = declared.absent === true ? "absent" : `=${JSON.stringify(declared.value)}`;
|
|
339
|
+
return `${param}${declared.absent === true ? " " : ""}${sent} (${declared.expect})`;
|
|
340
|
+
}
|
|
341
|
+
/** Whether a planned test cites one change by id. */
|
|
342
|
+
function citesChange(plannedTest, id) {
|
|
343
|
+
const declared = plannedTest?.declarations?.changes;
|
|
344
|
+
if (!Array.isArray(declared))
|
|
345
|
+
return false;
|
|
346
|
+
return declared.some((cited) => String(cited ?? "").trim() === id);
|
|
347
|
+
}
|
|
348
|
+
/** Every declared case needs a step of a citing test that sends it. The agent says
|
|
349
|
+
* what proves the change; the server reads whether the plan sends it. Nothing here
|
|
350
|
+
* knows a kind of input, so a constraint nobody anticipated is checked the same
|
|
351
|
+
* way as a range.
|
|
352
|
+
*
|
|
353
|
+
* Four runs on one fixture declared a 1-100 range on `take` and sent the upper
|
|
354
|
+
* end in one of the four, while a report claimed both ends. A value the plan
|
|
355
|
+
* never sends is the gap.
|
|
356
|
+
*
|
|
357
|
+
* An `absent` case reads the step's `omits`: a step that simply does not carry a
|
|
358
|
+
* parameter says nothing, so the omission has to be stated to be checked.
|
|
359
|
+
*
|
|
360
|
+
* A case names a query parameter, a body field, a path segment, or the verb. Run
|
|
361
|
+
* 34078857176 declared five cases the plan did send — two ids in a path segment,
|
|
362
|
+
* two rejected methods — and drew an objection for each, because only the query
|
|
363
|
+
* string and the body were read. */
|
|
364
|
+
function caseObjections(registration) {
|
|
365
|
+
const changes = Array.isArray(registration.changes) ? registration.changes : [];
|
|
366
|
+
const plannedTests = registration.plannedTests ?? [];
|
|
367
|
+
const objections = [];
|
|
368
|
+
const seen = new Set();
|
|
369
|
+
for (const change of changes) {
|
|
370
|
+
const id = String(change?.id ?? "").trim();
|
|
371
|
+
const cases = change?.cases;
|
|
372
|
+
if (!id || !Array.isArray(cases) || cases.length === 0 || seen.has(id))
|
|
373
|
+
continue;
|
|
374
|
+
// A change nothing cites already draws `coverage:change:<id>`. A second
|
|
375
|
+
// objection about the same gap would need a second answer for one fix.
|
|
376
|
+
const citing = plannedTests.filter((plannedTest) => citesChange(plannedTest, id));
|
|
377
|
+
if (citing.length === 0)
|
|
378
|
+
continue;
|
|
379
|
+
seen.add(id);
|
|
380
|
+
const citingSteps = citing.flatMap((plannedTest) => {
|
|
381
|
+
const steps = plannedTest?.scenario?.steps;
|
|
382
|
+
return Array.isArray(steps) ? steps : [];
|
|
383
|
+
});
|
|
384
|
+
const missing = cases.filter((declared) => {
|
|
385
|
+
const param = String(declared?.param ?? "").trim();
|
|
386
|
+
if (!param)
|
|
387
|
+
return false;
|
|
388
|
+
if (declared.absent === true)
|
|
389
|
+
return !citingSteps.some((step) => omittedBy(step, param));
|
|
390
|
+
const slots = pathSlotsFor(citingSteps, cases, param);
|
|
391
|
+
const isMethod = param.toLowerCase() === METHOD_PARAM;
|
|
392
|
+
return !citingSteps.some((step) => valuesSent(step, param).some((sent) => sameValue(sent, declared.value)) ||
|
|
393
|
+
(isMethod && sendsAsMethod(step, declared.value)) ||
|
|
394
|
+
slots.some((slot) => sendsInPath(step, slot, declared.value)));
|
|
395
|
+
});
|
|
396
|
+
// One objection per case. A single objection listing three cases took one
|
|
397
|
+
// answer that addressed two of them and closed: run 34167252679 answered
|
|
398
|
+
// patch-highlight-guards for id="abc" and id=999999, and the third case, a
|
|
399
|
+
// second user's id no step ever sent, went with it. The id carries the
|
|
400
|
+
// rendered case, so it is the same string on the next registration whatever
|
|
401
|
+
// order the cases arrive in, and each one needs its own answer.
|
|
402
|
+
for (const declared of missing) {
|
|
403
|
+
const rendered = renderCase(declared);
|
|
404
|
+
objections.push({
|
|
405
|
+
objectionId: `coverage:cases:${id}:${rendered}`,
|
|
406
|
+
verifier: "coverage",
|
|
407
|
+
message: fillPlaceholders(COVERAGE_CONTRACT.objections.cases.message, { missing: rendered }),
|
|
408
|
+
evidence: `${citing.map((plannedTest) => plannedTest.plannedTestId).join(", ")} cite ${id}; no step of theirs sends ${rendered}.`,
|
|
409
|
+
suggestion: COVERAGE_CONTRACT.objections.cases.suggestion,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return objections;
|
|
414
|
+
}
|
|
415
|
+
/** A value written as a placeholder: the record an earlier step created. A step
|
|
416
|
+
* sends the same spelling, so `{memberUserId}` in a case is that record and a
|
|
417
|
+
* literal is a value the plan chose. */
|
|
418
|
+
const PLACEHOLDER_VALUE = /^\{[A-Za-z][A-Za-z0-9]*\}$/;
|
|
419
|
+
/** A parameter that names a record by id, by its own name. */
|
|
420
|
+
const ID_PARAM = /id$/i;
|
|
421
|
+
function isPlaceholderValue(value) {
|
|
422
|
+
return typeof value === "string" && PLACEHOLDER_VALUE.test(value.trim());
|
|
423
|
+
}
|
|
424
|
+
/** Every parameter some change rejects with a value, anywhere in the plan. A
|
|
425
|
+
* rejection belongs to whichever change lists it, so one change's reject case
|
|
426
|
+
* answers another's accept on the same parameter. An `absent` case is out: it
|
|
427
|
+
* rejects a missing parameter, not a record that is not there. */
|
|
428
|
+
function paramsWithValuedReject(changes) {
|
|
429
|
+
const rejected = new Set();
|
|
430
|
+
for (const change of changes) {
|
|
431
|
+
const cases = change?.cases;
|
|
432
|
+
if (!Array.isArray(cases))
|
|
433
|
+
continue;
|
|
434
|
+
for (const declared of cases) {
|
|
435
|
+
if (declared?.expect !== "reject" || declared?.absent === true || declared?.value === undefined)
|
|
436
|
+
continue;
|
|
437
|
+
const param = String(declared?.param ?? "").trim();
|
|
438
|
+
if (param)
|
|
439
|
+
rejected.add(param);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return rejected;
|
|
443
|
+
}
|
|
444
|
+
/** A route that takes a record by id can be given one that is not there, or one
|
|
445
|
+
* that belongs to another parent, and answer as if it were. `DELETE
|
|
446
|
+
* /collections/:id/members?userId=N` answered 200 "Member removed." for a user
|
|
447
|
+
* that was never a member; six runs on that fixture planned the accept and the
|
|
448
|
+
* absent-parameter rejection, and none planned the userId that is not a member.
|
|
449
|
+
*
|
|
450
|
+
* Read off the PLAN alone — no diff, no route table. An accept whose value is a
|
|
451
|
+
* placeholder names a record an earlier step created, and the parameter is an id
|
|
452
|
+
* either by its name or because a citing test sends it as a path segment. */
|
|
453
|
+
function rejectObjections(registration) {
|
|
454
|
+
const changes = Array.isArray(registration.changes) ? registration.changes : [];
|
|
455
|
+
const plannedTests = registration.plannedTests ?? [];
|
|
456
|
+
const rejected = paramsWithValuedReject(changes);
|
|
457
|
+
const objections = [];
|
|
458
|
+
const seen = new Set();
|
|
459
|
+
for (const change of changes) {
|
|
460
|
+
const id = String(change?.id ?? "").trim();
|
|
461
|
+
const cases = change?.cases;
|
|
462
|
+
if (!id || !Array.isArray(cases) || cases.length === 0 || seen.has(id))
|
|
463
|
+
continue;
|
|
464
|
+
// A change nothing cites already draws `coverage:change:<id>`, as above.
|
|
465
|
+
const citing = plannedTests.filter((plannedTest) => citesChange(plannedTest, id));
|
|
466
|
+
if (citing.length === 0)
|
|
467
|
+
continue;
|
|
468
|
+
seen.add(id);
|
|
469
|
+
const citingSteps = citing.flatMap((plannedTest) => {
|
|
470
|
+
const steps = plannedTest?.scenario?.steps;
|
|
471
|
+
return Array.isArray(steps) ? steps : [];
|
|
472
|
+
});
|
|
473
|
+
const unsatisfied = cases.find((declared) => {
|
|
474
|
+
const param = String(declared?.param ?? "").trim();
|
|
475
|
+
if (!param || param.toLowerCase() === METHOD_PARAM)
|
|
476
|
+
return false;
|
|
477
|
+
if (declared?.expect !== "accept" || declared?.absent === true)
|
|
478
|
+
return false;
|
|
479
|
+
if (!isPlaceholderValue(declared?.value))
|
|
480
|
+
return false;
|
|
481
|
+
if (!ID_PARAM.test(param) && pathSlotsFor(citingSteps, cases, param).length === 0)
|
|
482
|
+
return false;
|
|
483
|
+
return !rejected.has(param);
|
|
484
|
+
});
|
|
485
|
+
if (!unsatisfied)
|
|
486
|
+
continue;
|
|
487
|
+
const param = String(unsatisfied.param).trim();
|
|
488
|
+
const value = String(unsatisfied.value);
|
|
489
|
+
objections.push({
|
|
490
|
+
objectionId: `coverage:reject:${id}`,
|
|
491
|
+
verifier: "coverage",
|
|
492
|
+
message: fillPlaceholders(COVERAGE_CONTRACT.objections.reject.message, { param, value }),
|
|
493
|
+
evidence: `${id} declares ${param}=${value} (accept); no change in this plan declares a reject case on ${param} with a value.`,
|
|
494
|
+
suggestion: fillPlaceholders(COVERAGE_CONTRACT.objections.reject.suggestion, { param }),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return objections;
|
|
498
|
+
}
|
|
499
|
+
// ── The surface a change is on ──────────────────────────────────────────────
|
|
500
|
+
/** The test types that reach each surface. An `e2e` test drives the page AND the
|
|
501
|
+
* requests behind it, so it counts for both. */
|
|
502
|
+
const TEST_TYPES_FOR = {
|
|
503
|
+
api: { types: new Set(["contract", "integration", "e2e"]), kind: "contract, integration or e2e", name: "API" },
|
|
504
|
+
page: { types: new Set(["ui", "e2e"]), kind: "ui or e2e", name: "page" },
|
|
505
|
+
};
|
|
506
|
+
/** Every surface a change states, without repeats and without a name nothing maps. */
|
|
507
|
+
function declaredSurfaces(change) {
|
|
508
|
+
const named = change?.surfaces;
|
|
509
|
+
if (!Array.isArray(named))
|
|
510
|
+
return [];
|
|
511
|
+
return [...new Set(named.map((surface) => String(surface ?? "").trim()))].filter((surface) => surface in TEST_TYPES_FOR);
|
|
512
|
+
}
|
|
513
|
+
/** A change on a page needs a test that opens the page, and a change on the API
|
|
514
|
+
* needs one that calls it. Run 34065056830 cited a page change from an API test
|
|
515
|
+
* and nothing objected: no test in that plan clicked the filter the change added.
|
|
516
|
+
*
|
|
517
|
+
* Read off the PLAN alone, like the other two halves. */
|
|
518
|
+
function surfaceObjections(registration) {
|
|
519
|
+
const changes = Array.isArray(registration.changes) ? registration.changes : [];
|
|
520
|
+
const plannedTests = registration.plannedTests ?? [];
|
|
521
|
+
const objections = [];
|
|
522
|
+
const seen = new Set();
|
|
523
|
+
for (const change of changes) {
|
|
524
|
+
const id = String(change?.id ?? "").trim();
|
|
525
|
+
if (!id)
|
|
526
|
+
continue;
|
|
527
|
+
// A change nothing cites already draws `coverage:change:<id>`. A second
|
|
528
|
+
// objection about the same gap would need a second answer for one fix.
|
|
529
|
+
const citing = plannedTests.filter((plannedTest) => citesChange(plannedTest, id));
|
|
530
|
+
if (citing.length === 0)
|
|
531
|
+
continue;
|
|
532
|
+
for (const surface of declaredSurfaces(change)) {
|
|
533
|
+
const { types, kind, name } = TEST_TYPES_FOR[surface];
|
|
534
|
+
const objectionId = `coverage:surface:${id}:${surface}`;
|
|
535
|
+
if (seen.has(objectionId))
|
|
536
|
+
continue;
|
|
537
|
+
if (citing.some((plannedTest) => types.has(String(plannedTest?.scenario?.testType ?? "").trim().toLowerCase())))
|
|
538
|
+
continue;
|
|
539
|
+
seen.add(objectionId);
|
|
540
|
+
objections.push({
|
|
541
|
+
objectionId,
|
|
542
|
+
verifier: "coverage",
|
|
543
|
+
message: fillPlaceholders(COVERAGE_CONTRACT.objections.surface.message, { kind, surface: name }),
|
|
544
|
+
evidence: `${citing.map((plannedTest) => plannedTest.plannedTestId).join(", ")} cite ${id}; none of them is a ${kind} test.`,
|
|
545
|
+
suggestion: COVERAGE_CONTRACT.objections.surface.suggestion,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return objections;
|
|
550
|
+
}
|
|
551
|
+
/** One repository's changed files, and the name to attribute uncovered ones to.
|
|
552
|
+
* Keying the objection id on the path alone let two repositories that each change
|
|
553
|
+
* `src/App.tsx` share one id. A single-repository run names no repository in its
|
|
554
|
+
* ids, which is the id it produced before there was a second one. */
|
|
555
|
+
function repositoryPartitions(ctx) {
|
|
556
|
+
const named = Object.entries(ctx.changedFiles ?? {});
|
|
557
|
+
if (named.length < 2)
|
|
558
|
+
return [{ changedFiles: named[0]?.[1] ?? [] }];
|
|
559
|
+
return named.map(([repository, changedFiles]) => ({ repository, changedFiles: changedFiles ?? [] }));
|
|
560
|
+
}
|
|
561
|
+
/** The planned tests that count as covering one repository's change: those naming it,
|
|
562
|
+
* plus those naming no repository. ABSENT IS A WILDCARD, as `checkEveryDeliveredTestRan`
|
|
563
|
+
* and `collectExecutionOutcomes` read it — silence is nothing to disagree with. */
|
|
564
|
+
function candidatesFor(plannedTests, repository) {
|
|
565
|
+
if (!repository)
|
|
566
|
+
return plannedTests;
|
|
567
|
+
return plannedTests.filter((plannedTest) => {
|
|
568
|
+
const named = String(plannedTest?.repository ?? "").trim();
|
|
569
|
+
return named === "" || named === repository;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/** The completeness check, over three surfaces: every declared behaviour, every
|
|
573
|
+
* mutated route, and every changed file needs a test in the plan. More is
|
|
574
|
+
* welcome, fewer is the objection. ONE RULE for the file half, no classification
|
|
575
|
+
* — every changed file is a surface unless it cannot be product code, including a
|
|
576
|
+
* removed one. A FLOOR, never a cap. Only the file half needs a diff. */
|
|
577
|
+
export const coverage = {
|
|
578
|
+
name: "coverage",
|
|
579
|
+
run(registration, ctx) {
|
|
580
|
+
const objections = [
|
|
581
|
+
...claimObjections(registration),
|
|
582
|
+
...stateTestObjections(registration),
|
|
583
|
+
...caseObjections(registration),
|
|
584
|
+
...rejectObjections(registration),
|
|
585
|
+
...surfaceObjections(registration),
|
|
586
|
+
];
|
|
587
|
+
if (allChangedFiles(ctx).length === 0)
|
|
588
|
+
return objections;
|
|
589
|
+
const all = registration.plannedTests ?? [];
|
|
590
|
+
for (const { repository, changedFiles } of repositoryPartitions(ctx)) {
|
|
591
|
+
const cited = citedFiles(candidatesFor(all, repository));
|
|
592
|
+
const seen = new Set();
|
|
593
|
+
for (const entry of changedFiles) {
|
|
594
|
+
// Compared in the normalised spelling the citations are reduced to. The
|
|
595
|
+
// objection quotes the path as `--name-status` gave it, which for a
|
|
596
|
+
// non-ASCII name is the unquoted form, not the escaped one the diff shows.
|
|
597
|
+
const file = entry?.path ?? "";
|
|
598
|
+
const path = normalizeCitedPath(file);
|
|
599
|
+
if (!path || !couldBeProductCode(path) || seen.has(path))
|
|
600
|
+
continue;
|
|
601
|
+
seen.add(path);
|
|
602
|
+
if (cited.has(path))
|
|
603
|
+
continue;
|
|
604
|
+
objections.push({
|
|
605
|
+
objectionId: repository
|
|
606
|
+
? `coverage:${repository}/${file}`
|
|
607
|
+
: `coverage:${file}`,
|
|
608
|
+
verifier: "coverage",
|
|
609
|
+
message: COVERAGE_CONTRACT.objections.file.message,
|
|
610
|
+
evidence: `${repository ? `${repository} ` : ""}${file}${entry?.deleted ? " (removed by this change)" : ""}: no planned test cites it in changedFile, routes or screenEvidence.`,
|
|
611
|
+
suggestion: COVERAGE_CONTRACT.objections.file.suggestion,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return objections;
|
|
616
|
+
},
|
|
617
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Objection } from "../types.js";
|
|
2
|
+
import { Plan } from "../registerPlan.js";
|
|
3
|
+
/** One test as it appears in the submitted report. */
|
|
4
|
+
export interface DeliveredTest {
|
|
5
|
+
plannedTestId: string;
|
|
6
|
+
}
|
|
7
|
+
/** Verifier 5, post-execution half. Both directions on one pass: a report entry
|
|
8
|
+
* that was in no plan, and a planned planned test that never shipped. The two lists
|
|
9
|
+
* join on `plannedTestId` exactly — never a name, an endpoint or a similarity
|
|
10
|
+
* score. Objections go to the report: the tests already exist. */
|
|
11
|
+
export declare function checkDeliveredMatchesPlan(plan: Plan, delivered: DeliveredTest[]): Objection[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Verifier 5, post-execution half. Both directions on one pass: a report entry
|
|
2
|
+
* that was in no plan, and a planned planned test that never shipped. The two lists
|
|
3
|
+
* join on `plannedTestId` exactly — never a name, an endpoint or a similarity
|
|
4
|
+
* score. Objections go to the report: the tests already exist. */
|
|
5
|
+
export function checkDeliveredMatchesPlan(plan, delivered) {
|
|
6
|
+
const planned = new Set(plan.plannedTests.map((c) => c.plannedTestId));
|
|
7
|
+
const shipped = new Set(delivered.map((d) => d.plannedTestId));
|
|
8
|
+
const objections = [];
|
|
9
|
+
for (const id of shipped) {
|
|
10
|
+
if (planned.has(id))
|
|
11
|
+
continue;
|
|
12
|
+
objections.push({
|
|
13
|
+
objectionId: `deliveredMatchesPlan:${id}`,
|
|
14
|
+
verifier: "deliveredMatchesPlan",
|
|
15
|
+
message: "This test is in the report but was not in the plan. Every test written must be declared first.",
|
|
16
|
+
evidence: `delivered but unplanned: ${id}`,
|
|
17
|
+
suggestion: "Declare it in the plan and register it again, or take it out of the report.",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
for (const id of planned) {
|
|
21
|
+
if (shipped.has(id))
|
|
22
|
+
continue;
|
|
23
|
+
objections.push({
|
|
24
|
+
objectionId: `deliveredMatchesPlan:${id}`,
|
|
25
|
+
verifier: "deliveredMatchesPlan",
|
|
26
|
+
plannedTestId: id,
|
|
27
|
+
message: "This test was in the plan and is not in the report. Say why it did not ship.",
|
|
28
|
+
evidence: `planned but not delivered: ${id}`,
|
|
29
|
+
suggestion: "Add the test to the report, or record in the report why it was dropped.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return objections;
|
|
33
|
+
}
|