@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
|
@@ -24,11 +24,9 @@ export function setTestsRepoDir(dir) {
|
|
|
24
24
|
*
|
|
25
25
|
* The tests repo is a RUN-scoped fact, but analyze_changes is called once per
|
|
26
26
|
* repository and only the PRIMARY call carries `testsRepoDir` — a related
|
|
27
|
-
* repo's call omits it. An unconditional set therefore
|
|
28
|
-
* value on the second call of every multi-repo run,
|
|
29
|
-
*
|
|
30
|
-
* working tree and the agent demoted it out of newTestsCreated (SKYR-4204,
|
|
31
|
-
* run 32536569467). Callers that mean to clear it use setTestsRepoDir.
|
|
27
|
+
* repo's call omits it. An unconditional set therefore wipes the primary's
|
|
28
|
+
* value on the second call of every multi-repo run, leaving the tests repo
|
|
29
|
+
* unknown by report time. Callers that mean to clear it use setTestsRepoDir.
|
|
32
30
|
*/
|
|
33
31
|
export function rememberTestsRepoDir(dir) {
|
|
34
32
|
if (dir)
|
|
@@ -39,21 +37,53 @@ export function getTestsRepoDir() {
|
|
|
39
37
|
}
|
|
40
38
|
/**
|
|
41
39
|
* Multi-repo run anchor: the path of the run-scoped analysis state file for the
|
|
42
|
-
* current Testbot run.
|
|
43
|
-
* module-level value is naturally run-scoped (same pattern as _testsRepoDir and
|
|
44
|
-
* processSessionRegistry above).
|
|
40
|
+
* current Testbot run.
|
|
45
41
|
*
|
|
46
42
|
* skyramp_analyze_changes sets this on the FIRST call of a multi-repo run (the one
|
|
47
43
|
* carrying a `repository`) and REUSES it on subsequent related-repo calls, so every
|
|
48
44
|
* repo's analysis lands in ONE state file with per-repo sections — without the agent
|
|
49
45
|
* ever passing a file path (which previously caused filename hallucinations).
|
|
50
46
|
* Single-repo / standalone calls (no `repository`) never set or read this.
|
|
47
|
+
*
|
|
48
|
+
* A staged Testbot run starts a NEW server process per stage, so this value is gone
|
|
49
|
+
* at every stage boundary. That is what the pointer file below is for.
|
|
51
50
|
*/
|
|
52
51
|
let _activeRunStatePath;
|
|
53
52
|
/** Filename of the run-scoped analysis state file under `runArtifactDir()`.
|
|
54
53
|
* Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
|
|
55
54
|
* which LOOKS there cannot drift apart. */
|
|
56
55
|
export const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
|
|
56
|
+
/** Filename of the pointer that carries the multi-repo anchor across a server
|
|
57
|
+
* restart. It holds one line: the absolute path of the run's state file. */
|
|
58
|
+
export const RUN_STATE_POINTER_FILE_NAME = "run-state-path";
|
|
59
|
+
function runStatePointerFile() {
|
|
60
|
+
const runDir = runArtifactDir();
|
|
61
|
+
return runDir ? path.join(runDir, RUN_STATE_POINTER_FILE_NAME) : undefined;
|
|
62
|
+
}
|
|
63
|
+
/** The anchored path a previous process wrote, or undefined. A pointer naming a
|
|
64
|
+
* file that is gone is deleted rather than returned: the run it belonged to is
|
|
65
|
+
* over, and a stale path would send this stage to the wrong file. */
|
|
66
|
+
function readRunStatePointer() {
|
|
67
|
+
const pointer = runStatePointerFile();
|
|
68
|
+
if (!pointer)
|
|
69
|
+
return undefined;
|
|
70
|
+
let pointed;
|
|
71
|
+
try {
|
|
72
|
+
pointed = fs.readFileSync(pointer, "utf-8").trim();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (pointed && fs.existsSync(pointed))
|
|
78
|
+
return pointed;
|
|
79
|
+
try {
|
|
80
|
+
fs.unlinkSync(pointer);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Nothing depends on the delete succeeding; the path is unusable either way.
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
57
87
|
/**
|
|
58
88
|
* Directory holding this run's Skyramp artifacts — the state file, and the report written
|
|
59
89
|
* beside it (SKYR-4147) — or undefined outside a run.
|
|
@@ -62,15 +92,13 @@ export const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
|
|
|
62
92
|
* work it out from the environment on their own. So neither has to tell the other where
|
|
63
93
|
* the run's files live, and the path never passes through the model.
|
|
64
94
|
*
|
|
65
|
-
* The GitHub Action is the only caller customers run
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* system's variable. Do not add a second variable for a caller that does not exist.
|
|
95
|
+
* The GitHub Action is the only caller customers run and GitHub sets `RUNNER_TEMP` for it;
|
|
96
|
+
* the eval harness sets it itself. If a caller on another CI system is added, rename this
|
|
97
|
+
* then, or let it read that system's variable. Do not add a second variable for a caller
|
|
98
|
+
* that does not exist.
|
|
70
99
|
*
|
|
71
|
-
* Kept in
|
|
72
|
-
*
|
|
73
|
-
* drifting apart. testbot keeps its matching copy in one place too, in `skyrampTempDir()`.
|
|
100
|
+
* Kept in ONE place, like `RUN_STATE_FILE_NAME`: written out per call site, the copies
|
|
101
|
+
* drift. testbot keeps its matching copy in `skyrampTempDir()`.
|
|
74
102
|
*/
|
|
75
103
|
export function runArtifactDir() {
|
|
76
104
|
const runnerTemp = process.env.RUNNER_TEMP?.trim();
|
|
@@ -78,6 +106,21 @@ export function runArtifactDir() {
|
|
|
78
106
|
}
|
|
79
107
|
export function setActiveRunStatePath(stateFilePath) {
|
|
80
108
|
_activeRunStatePath = stateFilePath;
|
|
109
|
+
const pointer = runStatePointerFile();
|
|
110
|
+
if (!pointer)
|
|
111
|
+
return;
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
|
114
|
+
fs.writeFileSync(pointer, stateFilePath, "utf-8");
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// The in-memory anchor still holds for this process, so this stage works
|
|
118
|
+
// either way. Only a later stage loses the path.
|
|
119
|
+
logger.warning("Could not write the run state pointer", {
|
|
120
|
+
pointer,
|
|
121
|
+
error: error instanceof Error ? error.message : String(error),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
81
124
|
}
|
|
82
125
|
export function getActiveRunStatePath() {
|
|
83
126
|
return _activeRunStatePath;
|
|
@@ -85,21 +128,30 @@ export function getActiveRunStatePath() {
|
|
|
85
128
|
/** Reset the run anchor — for tests, and as a safety hook between runs. */
|
|
86
129
|
export function clearActiveRunStatePath() {
|
|
87
130
|
_activeRunStatePath = undefined;
|
|
131
|
+
const pointer = runStatePointerFile();
|
|
132
|
+
if (!pointer)
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
fs.unlinkSync(pointer);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Absent already, which is the state this asks for.
|
|
139
|
+
}
|
|
88
140
|
}
|
|
89
141
|
/**
|
|
90
142
|
* The run's state-file path, for tools that need to read or amend it without the
|
|
91
143
|
* agent naming it.
|
|
92
144
|
*
|
|
93
145
|
* `setActiveRunStatePath` is only called by skyramp_analyze_changes on multi-repo
|
|
94
|
-
* runs (the ones carrying a `repository` param)
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* RUNNER_TEMP and resolves to undefined — correct, since there is no run state.
|
|
146
|
+
* runs (the ones carrying a `repository` param). It is read back two ways: from
|
|
147
|
+
* memory in this process, and from the pointer file a previous stage of the same
|
|
148
|
+
* run wrote — a staged run starts a fresh server per stage, so memory alone would
|
|
149
|
+
* send later stages to the wrong file.
|
|
99
150
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
151
|
+
* A single-repo Testbot run's path is still deterministic in CI: the StateManager
|
|
152
|
+
* constructor above roots "analysis" state under `runArtifactDir()`. Falling back
|
|
153
|
+
* to that covers single-repo runs. Standalone/IDE usage has no RUNNER_TEMP and
|
|
154
|
+
* resolves to undefined — correct, since there is no run state.
|
|
103
155
|
*/
|
|
104
156
|
export function resolveRunStatePath(explicitPath) {
|
|
105
157
|
if (explicitPath)
|
|
@@ -107,6 +159,9 @@ export function resolveRunStatePath(explicitPath) {
|
|
|
107
159
|
const anchored = getActiveRunStatePath();
|
|
108
160
|
if (anchored)
|
|
109
161
|
return anchored;
|
|
162
|
+
const pointed = readRunStatePointer();
|
|
163
|
+
if (pointed)
|
|
164
|
+
return pointed;
|
|
110
165
|
const runDir = runArtifactDir();
|
|
111
166
|
if (runDir) {
|
|
112
167
|
return path.join(runDir, RUN_STATE_FILE_NAME);
|
|
@@ -124,7 +179,7 @@ export function resolveRunStatePath(explicitPath) {
|
|
|
124
179
|
* write to a file that holds no run data is already a no-op.
|
|
125
180
|
*
|
|
126
181
|
* Returns the path rather than a boolean so a caller can also key per-run state on
|
|
127
|
-
* it
|
|
182
|
+
* it. It lives here because this is the module that decides where the file goes.
|
|
128
183
|
*/
|
|
129
184
|
export function currentRunStateFile() {
|
|
130
185
|
const stateFile = resolveRunStatePath();
|
|
@@ -187,7 +242,7 @@ export function hasSessionData(sessionId) {
|
|
|
187
242
|
* and wrap it properly.
|
|
188
243
|
*/
|
|
189
244
|
export function normalizeRecommendationState(data) {
|
|
190
|
-
if (!data.analysis && data.
|
|
245
|
+
if (!data.analysis && data.metadata) {
|
|
191
246
|
logger.info("Detected unwrapped RepositoryAnalysis — adapting");
|
|
192
247
|
return {
|
|
193
248
|
repositoryPath: data.repositoryPath ||
|
|
@@ -214,6 +269,44 @@ const STATE_FILE_PREFIXES = {
|
|
|
214
269
|
* - analysis: Test maintenance workflow (discovery, drift, execution, health)
|
|
215
270
|
* - recommendation: Test recommendation workflow (analyze repo, map tests, recommendations)
|
|
216
271
|
*/
|
|
272
|
+
/** The run-wide root fields no analysis payload owns. Read by `writeData` so a
|
|
273
|
+
* rebuild of the root cannot delete what another tool wrote there. */
|
|
274
|
+
/** Write through a temporary file and rename over the target: `writeFile` truncates
|
|
275
|
+
* then writes, and both readers THROW on a parse failure. */
|
|
276
|
+
async function writeFileAtomically(target, contents) {
|
|
277
|
+
// `rename` needs only a writable DIRECTORY, so it would replace a read-only
|
|
278
|
+
// state file a plain write refuses. Two callers depend on that refusal.
|
|
279
|
+
try {
|
|
280
|
+
await fs.promises.access(target, fs.constants.W_OK);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error?.code !== "ENOENT")
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
// Per CALL, not per process: the run is ONE process, so two writes racing
|
|
287
|
+
// inside it share a pid, interleave their bytes and publish unparseable JSON.
|
|
288
|
+
const temp = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
289
|
+
try {
|
|
290
|
+
await fs.promises.writeFile(temp, contents, "utf-8");
|
|
291
|
+
await fs.promises.rename(temp, target);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
await fs.promises.rm(temp, { force: true }).catch(() => undefined);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const ROOT_FIELDS_OWNED_ELSEWHERE = ["plan", "registrationCount", "reportObjections"];
|
|
299
|
+
function carryForwardRootFields(data, existing) {
|
|
300
|
+
if (!existing)
|
|
301
|
+
return {};
|
|
302
|
+
const carried = {};
|
|
303
|
+
for (const field of ROOT_FIELDS_OWNED_ELSEWHERE) {
|
|
304
|
+
const incoming = data?.[field];
|
|
305
|
+
if (incoming === undefined && existing[field] !== undefined)
|
|
306
|
+
carried[field] = existing[field];
|
|
307
|
+
}
|
|
308
|
+
return carried;
|
|
309
|
+
}
|
|
217
310
|
export class StateManager {
|
|
218
311
|
stateFile;
|
|
219
312
|
sessionId;
|
|
@@ -318,13 +411,16 @@ export class StateManager {
|
|
|
318
411
|
// rewriting the primary (root) payload preserves the multi-repo sections.
|
|
319
412
|
let existingMetadata;
|
|
320
413
|
let existingRelated;
|
|
414
|
+
let existingRoot;
|
|
321
415
|
if (this.exists()) {
|
|
322
416
|
const existing = await this.readFullState();
|
|
323
417
|
existingMetadata = existing?.metadata;
|
|
324
418
|
existingRelated = existing?.relatedRepos;
|
|
419
|
+
existingRoot = existing ?? undefined;
|
|
325
420
|
}
|
|
326
421
|
const state = {
|
|
327
422
|
...data,
|
|
423
|
+
...carryForwardRootFields(data, existingRoot),
|
|
328
424
|
metadata: {
|
|
329
425
|
sessionId: this.sessionId,
|
|
330
426
|
stateType: this.stateType,
|
|
@@ -343,7 +439,7 @@ export class StateManager {
|
|
|
343
439
|
await fs.promises.mkdir(path.dirname(this.stateFile), {
|
|
344
440
|
recursive: true,
|
|
345
441
|
});
|
|
346
|
-
await
|
|
442
|
+
await writeFileAtomically(this.stateFile, JSON.stringify(state, null, 2));
|
|
347
443
|
logger.debug(`Wrote data to state file: ${this.stateFile}`);
|
|
348
444
|
}
|
|
349
445
|
catch (error) {
|
|
@@ -359,16 +455,18 @@ export class StateManager {
|
|
|
359
455
|
// matching the root's metadata.repo) targets the PRIMARY section, so all
|
|
360
456
|
// single-repo behavior is unchanged.
|
|
361
457
|
// -----------------------------------------------------------------------
|
|
362
|
-
/** Is `repo` the primary section?
|
|
363
|
-
*
|
|
364
|
-
* the
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
async isPrimaryRepo(repo) {
|
|
458
|
+
/** Is `repo` the primary section? By NAME when the caller declares one: the run
|
|
459
|
+
* says which repo is primary, so whichever call lands first cannot decide it.
|
|
460
|
+
* Otherwise the legacy rule — falsy `repo` (single-repo callers), the first
|
|
461
|
+
* write of the run, or a `repo` matching the root metadata (SKYR-4333: a
|
|
462
|
+
* docs-only primary diff let the related repo write first and take the root). */
|
|
463
|
+
async isPrimaryRepo(repo, primaryRepo) {
|
|
368
464
|
if (!repo)
|
|
369
465
|
return true;
|
|
370
|
-
|
|
371
|
-
|
|
466
|
+
if (primaryRepo)
|
|
467
|
+
return repo === primaryRepo;
|
|
468
|
+
// No primary declared: the first write of the run is the primary, even when
|
|
469
|
+
// it carries an explicit owner/repo.
|
|
372
470
|
if (!this.exists())
|
|
373
471
|
return true;
|
|
374
472
|
const full = await this.readFullState();
|
|
@@ -379,13 +477,18 @@ export class StateManager {
|
|
|
379
477
|
}
|
|
380
478
|
/**
|
|
381
479
|
* Write one repo's analysis into the run-scoped state file. The primary repo
|
|
382
|
-
* (repo absent,
|
|
383
|
-
*
|
|
480
|
+
* (repo absent, matching the declared `primaryRepo`, matching the root's
|
|
481
|
+
* recorded repo, or the first write of a run that declares no primary) is
|
|
482
|
+
* written to the root via writeData(); a related repo is upserted into
|
|
384
483
|
* `relatedRepos[repo]`, preserving the primary + other sections. Used by
|
|
385
484
|
* skyramp_analyze_changes — every repo in the run writes the SAME stateFile path.
|
|
386
485
|
*/
|
|
486
|
+
/** Fields another tool owns, carried forward when this caller's payload omits
|
|
487
|
+
* them: `writeData` rebuilds the root from its payload, and an analyze call that
|
|
488
|
+
* did so deleted the plan the agent had registered. NAMED rather than "keep
|
|
489
|
+
* everything omitted", which would resurrect fields a re-analysis clears. */
|
|
387
490
|
async writeRepoData(data, options) {
|
|
388
|
-
if (await this.isPrimaryRepo(options.repo)) {
|
|
491
|
+
if (await this.isPrimaryRepo(options.repo, options.primaryRepo)) {
|
|
389
492
|
await this.writeData(data, {
|
|
390
493
|
repositoryPath: options.repositoryPath,
|
|
391
494
|
step: options.step,
|
|
@@ -395,14 +498,17 @@ export class StateManager {
|
|
|
395
498
|
});
|
|
396
499
|
return;
|
|
397
500
|
}
|
|
398
|
-
// Related repo:
|
|
399
|
-
//
|
|
501
|
+
// Related repo: upsert this repo's section into the current file. The file
|
|
502
|
+
// need not exist yet — with a declared primary a related repo may write
|
|
503
|
+
// first, and then the root is created EMPTY and tagged with the primary's
|
|
504
|
+
// name, so the primary's own call still lands at the root when it arrives.
|
|
400
505
|
const existing = (await this.readFullState());
|
|
401
506
|
const base = existing ??
|
|
402
507
|
{
|
|
403
508
|
metadata: {
|
|
404
509
|
sessionId: this.sessionId,
|
|
405
510
|
stateType: this.stateType,
|
|
511
|
+
repository: options.primaryRepo,
|
|
406
512
|
createdAt: new Date().toISOString(),
|
|
407
513
|
updatedAt: new Date().toISOString(),
|
|
408
514
|
},
|
|
@@ -421,7 +527,7 @@ export class StateManager {
|
|
|
421
527
|
base.relatedRepos = relatedRepos;
|
|
422
528
|
base.metadata = { ...base.metadata, updatedAt: new Date().toISOString() };
|
|
423
529
|
await fs.promises.mkdir(path.dirname(this.stateFile), { recursive: true });
|
|
424
|
-
await
|
|
530
|
+
await writeFileAtomically(this.stateFile, JSON.stringify(base, null, 2));
|
|
425
531
|
logger.debug(`Wrote ${options.repo} section to state file: ${this.stateFile}`);
|
|
426
532
|
}
|
|
427
533
|
/**
|
|
@@ -494,22 +600,6 @@ export class StateManager {
|
|
|
494
600
|
logger.debug(`Deleted state file: ${this.stateFile}`);
|
|
495
601
|
}
|
|
496
602
|
}
|
|
497
|
-
async getSize() {
|
|
498
|
-
if (!this.exists()) {
|
|
499
|
-
return 0;
|
|
500
|
-
}
|
|
501
|
-
const stats = await fs.promises.stat(this.stateFile);
|
|
502
|
-
return stats.size;
|
|
503
|
-
}
|
|
504
|
-
async getSizeFormatted() {
|
|
505
|
-
const bytes = await this.getSize();
|
|
506
|
-
if (bytes === 0)
|
|
507
|
-
return "0 B";
|
|
508
|
-
const k = 1024;
|
|
509
|
-
const sizes = ["B", "KB", "MB", "GB"];
|
|
510
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
511
|
-
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
512
|
-
}
|
|
513
603
|
/**
|
|
514
604
|
* Cleanup old state files
|
|
515
605
|
* @param maxAgeHours Maximum age in hours
|
|
@@ -288,7 +288,7 @@ function lintArrayDepth(commentless) {
|
|
|
288
288
|
rule: "array-missing-next-index-check",
|
|
289
289
|
severity: "warn",
|
|
290
290
|
message: `Items of \`${base}\` are asserted up to index ${maxIdx}, but index ${maxIdx + 1} is never asserted absent.`,
|
|
291
|
-
remediation: `Add an absence assertion for the index after the last expected item (e.g. \`getValue(response, "${nextPath}")\` is
|
|
291
|
+
remediation: `Add an absence assertion for the index after the last expected item (e.g. \`getValue(response, "${nextPath}")\` is null / None).`,
|
|
292
292
|
});
|
|
293
293
|
}
|
|
294
294
|
}
|
|
@@ -15,6 +15,8 @@ const EXACT_MATCHERS = new Set([
|
|
|
15
15
|
"toEqual",
|
|
16
16
|
"toStrictEqual",
|
|
17
17
|
"toBeUndefined",
|
|
18
|
+
"toBeNull",
|
|
19
|
+
"to_be_null",
|
|
18
20
|
"to_have_text",
|
|
19
21
|
"to_have_value",
|
|
20
22
|
"to_have_attribute",
|
|
@@ -47,6 +49,8 @@ const PARTIAL_MATCHERS = new Set([
|
|
|
47
49
|
"to_be_enabled",
|
|
48
50
|
"to_be_hidden",
|
|
49
51
|
]);
|
|
52
|
+
/** Absence matchers whose tier depends on whether the chain negates them. */
|
|
53
|
+
const NULL_MATCHERS = new Set(["toBeNull", "to_be_null"]);
|
|
50
54
|
/** Chain links to skip between `expect(...)` and the matcher. */
|
|
51
55
|
const CHAIN_LINKS = new Set([
|
|
52
56
|
"not",
|
|
@@ -75,9 +79,14 @@ export function detectAssertionLanguage(testFile) {
|
|
|
75
79
|
return undefined;
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
|
-
function matcherWeight(matcher) {
|
|
82
|
+
function matcherWeight(matcher, links = []) {
|
|
79
83
|
if (matcher === undefined)
|
|
80
84
|
return 1;
|
|
85
|
+
// `not.toBeNull()` is the weak existence idiom the rubric bans, while a bare
|
|
86
|
+
// `toBeNull()` is the exact absence assertion it asks for. Same matcher, so
|
|
87
|
+
// only the chain separates them.
|
|
88
|
+
if (NULL_MATCHERS.has(matcher) && links.includes("not"))
|
|
89
|
+
return 1;
|
|
81
90
|
if (EXACT_MATCHERS.has(matcher))
|
|
82
91
|
return 3;
|
|
83
92
|
if (PARTIAL_MATCHERS.has(matcher))
|
|
@@ -85,12 +94,45 @@ function matcherWeight(matcher) {
|
|
|
85
94
|
// Unknown matchers weigh 1, never 0 — any added assertion raises the score.
|
|
86
95
|
return 1;
|
|
87
96
|
}
|
|
88
|
-
function fingerprintOf(
|
|
89
|
-
return
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
function fingerprintOf(statement) {
|
|
98
|
+
return statement.replace(/\s+/g, " ").trim().slice(0, FINGERPRINT_CHAR_CAP);
|
|
99
|
+
}
|
|
100
|
+
/** The first comma at paren/bracket/brace depth 0 and outside any quoted literal,
|
|
101
|
+
* or -1. A call's own arguments sit deeper, so `f(a, b)` yields no split, and a
|
|
102
|
+
* comma inside `'…'`, `"…"` or `` `…` `` is text, not a separator. Callers pass
|
|
103
|
+
* raw source here (a fingerprint), where quotes are still present; the python
|
|
104
|
+
* scanner uses the same two quote forms, so one scan covers both dialects. */
|
|
105
|
+
function topLevelCommaIndex(text) {
|
|
106
|
+
let depth = 0;
|
|
107
|
+
for (let i = 0; i < text.length; i++) {
|
|
108
|
+
const c = text[i];
|
|
109
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
110
|
+
// Skip to the matching close quote. An unterminated literal ends the scan,
|
|
111
|
+
// so a truncated fingerprint yields no split rather than a bogus one.
|
|
112
|
+
i++;
|
|
113
|
+
while (i < text.length && text[i] !== c) {
|
|
114
|
+
if (text[i] === "\\")
|
|
115
|
+
i++;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
if (i >= text.length)
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
else if (c === "(" || c === "[" || c === "{")
|
|
122
|
+
depth++;
|
|
123
|
+
else if (c === ")" || c === "]" || c === "}")
|
|
124
|
+
depth--;
|
|
125
|
+
else if (depth === 0 && c === ",")
|
|
126
|
+
return i;
|
|
127
|
+
}
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
130
|
+
/** An argument list without whatever follows its first argument. Every assertion
|
|
131
|
+
* form here takes the subject first and an optional MESSAGE after it, and the
|
|
132
|
+
* message describes the assertion rather than being part of what it checks. */
|
|
133
|
+
function firstArgument(args) {
|
|
134
|
+
const comma = topLevelCommaIndex(args);
|
|
135
|
+
return comma === -1 ? args : args.slice(0, comma);
|
|
94
136
|
}
|
|
95
137
|
function startOfLine(src, offset) {
|
|
96
138
|
const nl = src.lastIndexOf("\n", offset - 1);
|
|
@@ -107,39 +149,51 @@ function expectSites(raw, stripped) {
|
|
|
107
149
|
const argClose = balancedCloseIndex(stripped, openIdx);
|
|
108
150
|
if (argClose === -1)
|
|
109
151
|
continue;
|
|
152
|
+
// `expect(actual, "message")` takes the message second. Cut it at the first
|
|
153
|
+
// top-level comma of the STRIPPED args, where a comma inside a string or a
|
|
154
|
+
// template literal has already been blanked and so is not one.
|
|
155
|
+
const commaInArgs = topLevelCommaIndex(stripped.slice(openIdx + 1, argClose));
|
|
156
|
+
const actualEnd = commaInArgs === -1 ? argClose : openIdx + 1 + commaInArgs;
|
|
157
|
+
const actual = raw.slice(openIdx + 1, actualEnd);
|
|
110
158
|
// Walk the chain past link members (.not / .resolves / …) to the matcher.
|
|
111
159
|
let cursor = argClose + 1;
|
|
112
160
|
let matcher;
|
|
161
|
+
const links = [];
|
|
113
162
|
for (;;) {
|
|
114
163
|
const link = /^\s*\.\s*([A-Za-z_]\w*)/.exec(stripped.slice(cursor));
|
|
115
164
|
if (!link)
|
|
116
165
|
break;
|
|
117
166
|
cursor += link[0].length;
|
|
118
|
-
if (CHAIN_LINKS.has(link[1]))
|
|
167
|
+
if (CHAIN_LINKS.has(link[1])) {
|
|
168
|
+
links.push(link[1]);
|
|
119
169
|
continue;
|
|
170
|
+
}
|
|
120
171
|
matcher = link[1];
|
|
121
172
|
break;
|
|
122
173
|
}
|
|
123
|
-
|
|
124
|
-
// closing paren (or the expect close when there is no chained call).
|
|
125
|
-
let stmtEnd = argClose + 1;
|
|
174
|
+
let matcherArgs;
|
|
126
175
|
if (matcher !== undefined) {
|
|
127
176
|
const matcherOpen = /^\s*\(/.exec(stripped.slice(cursor));
|
|
128
177
|
if (matcherOpen) {
|
|
129
|
-
const
|
|
178
|
+
const open = cursor + matcherOpen[0].length - 1;
|
|
179
|
+
const close = balancedCloseIndex(stripped, open);
|
|
130
180
|
if (close !== -1)
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
stmtEnd = cursor;
|
|
181
|
+
matcherArgs = raw.slice(open + 1, close);
|
|
135
182
|
}
|
|
136
183
|
}
|
|
137
|
-
|
|
184
|
+
// Built from parts rather than sliced off the line, so a reworded message
|
|
185
|
+
// and any code sharing the line are both outside the key. Run 34073154568
|
|
186
|
+
// improved a message, was told the assertion was removed, and reverted the
|
|
187
|
+
// wording to satisfy the check.
|
|
188
|
+
const soft = /\.\s*soft/.test(m[0]) ? ".soft" : "";
|
|
138
189
|
sites.push({
|
|
139
190
|
line: lineOfOffset(stripped, m.index),
|
|
140
191
|
matcher,
|
|
141
|
-
weight: matcherWeight(matcher),
|
|
142
|
-
fingerprint: fingerprintOf(
|
|
192
|
+
weight: matcherWeight(matcher, links),
|
|
193
|
+
fingerprint: fingerprintOf(`expect${soft}(${actual})` +
|
|
194
|
+
links.map((link) => `.${link}`).join("") +
|
|
195
|
+
(matcher === undefined ? "" : `.${matcher}`) +
|
|
196
|
+
(matcherArgs === undefined ? "" : `(${matcherArgs})`)),
|
|
143
197
|
});
|
|
144
198
|
re.lastIndex = Math.max(re.lastIndex, argClose + 1);
|
|
145
199
|
}
|
|
@@ -149,6 +203,9 @@ function expectSites(raw, stripped) {
|
|
|
149
203
|
function pythonAssertWeight(stmt) {
|
|
150
204
|
if (stmt.includes("==") ||
|
|
151
205
|
stmt.includes("!=") ||
|
|
206
|
+
// `is None` is the python spelling of `toBeNull` — an exact absence check.
|
|
207
|
+
// `is not None` stays weak: it is the existence idiom the rubric bans.
|
|
208
|
+
/\bis\s+None\b/.test(stmt) ||
|
|
152
209
|
// Same tier rule (and same spelling tolerance) as the node-assert scanner
|
|
153
210
|
// — the two had drifted, scoring an identical schema check 3 in JS and 1
|
|
154
211
|
// in python when spelled checkSchema.
|
|
@@ -182,7 +239,7 @@ function pythonAssertSites(raw, stripped) {
|
|
|
182
239
|
sites.push({
|
|
183
240
|
line: i + 1,
|
|
184
241
|
weight: pythonAssertWeight(stmt),
|
|
185
|
-
fingerprint: fingerprintOf(raw
|
|
242
|
+
fingerprint: fingerprintOf(raw.slice(offset + (line.length - line.trimStart().length), stmtEnd)),
|
|
186
243
|
});
|
|
187
244
|
i = last;
|
|
188
245
|
offset = stmtEnd + 1;
|
|
@@ -220,7 +277,7 @@ function callSites(raw, stripped, re, matcherOf, weightOf) {
|
|
|
220
277
|
line: lineOfOffset(stripped, m.index),
|
|
221
278
|
matcher: matcherOf(m),
|
|
222
279
|
weight: weightOf(m, args),
|
|
223
|
-
fingerprint: fingerprintOf(raw
|
|
280
|
+
fingerprint: fingerprintOf(raw.slice(startOfLine(raw, m.index), close === -1 ? m.index + m[0].length : close + 1)),
|
|
224
281
|
});
|
|
225
282
|
}
|
|
226
283
|
return sites;
|
|
@@ -256,7 +313,12 @@ export function subjectOf(fingerprint) {
|
|
|
256
313
|
const close = balancedCloseIndex(fingerprint, openIdx);
|
|
257
314
|
if (close === -1)
|
|
258
315
|
return undefined;
|
|
259
|
-
|
|
316
|
+
// The message went into the subject too, so a reworded message lost the
|
|
317
|
+
// correlation that tells a REPLACED assertion from a REMOVED one. Dropping
|
|
318
|
+
// it here is also what lets a baseline written before this change still
|
|
319
|
+
// correlate: its fingerprint no longer matches, but its subject does.
|
|
320
|
+
const subject = firstArgument(fingerprint.slice(openIdx + 1, close)).replace(/\s+/g, "");
|
|
321
|
+
return subject.length > 0 ? subject : undefined;
|
|
260
322
|
}
|
|
261
323
|
// Node assert: the subject is the first top-level argument
|
|
262
324
|
// (`assert.strictEqual(SUBJECT, expected, msg?)`, `assert.ok(SUBJECT, msg?)`).
|
|
@@ -265,20 +327,7 @@ export function subjectOf(fingerprint) {
|
|
|
265
327
|
const openIdx = nodeAssert.index + nodeAssert[0].length - 1;
|
|
266
328
|
const close = balancedCloseIndex(fingerprint, openIdx);
|
|
267
329
|
const args = fingerprint.slice(openIdx + 1, close === -1 ? undefined : close);
|
|
268
|
-
|
|
269
|
-
let end = args.length;
|
|
270
|
-
for (let i = 0; i < args.length; i++) {
|
|
271
|
-
const c = args[i];
|
|
272
|
-
if (c === "(" || c === "[" || c === "{")
|
|
273
|
-
depth++;
|
|
274
|
-
else if (c === ")" || c === "]" || c === "}")
|
|
275
|
-
depth--;
|
|
276
|
-
else if (depth === 0 && c === ",") {
|
|
277
|
-
end = i;
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
const subject = args.slice(0, end).replace(/\s+/g, "");
|
|
330
|
+
const subject = firstArgument(args).replace(/\s+/g, "");
|
|
282
331
|
return subject.length > 0 ? subject : undefined;
|
|
283
332
|
}
|
|
284
333
|
const assertPrefix = /^assert\s+/.exec(fingerprint);
|
|
@@ -1,8 +1,3 @@
|
|
|
1
1
|
import type { AssertionLanguage } from "./metrics.js";
|
|
2
2
|
import { type LintFinding, type LintOptions } from "./lint-types.js";
|
|
3
|
-
/** UI (Playwright) lints. Python (playwright-python) UI specs deliberately get
|
|
4
|
-
* the site-scoped lints only: the structural rules' patterns and accept-lists
|
|
5
|
-
* are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
|
|
6
|
-
* forms), and porting them without a python golden corpus to validate against
|
|
7
|
-
* would recreate the false-block class review round 2 removed. Pinned by test. */
|
|
8
3
|
export declare function lintUiSpec(raw: string, language: AssertionLanguage, opts?: LintOptions): LintFinding[];
|
|
@@ -222,6 +222,36 @@ function lintUnassertedTrailingAction(stripped) {
|
|
|
222
222
|
* are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
|
|
223
223
|
* forms), and porting them without a python golden corpus to validate against
|
|
224
224
|
* would recreate the false-block class review round 2 removed. Pinned by test. */
|
|
225
|
+
// SKYR-4305 — a removal guard (`toBeHidden()` / `not.toBeVisible()` on an element a
|
|
226
|
+
// PR removed) is only meaningful next to a positive assertion on the same page: alone
|
|
227
|
+
// it passes on a blank page, a 404, a typo'd testid, or a page the element never
|
|
228
|
+
// rendered on. `toBeHidden` is a PARTIAL_MATCHER (weight 2), so the strength gate does
|
|
229
|
+
// not catch a negative-only spec; this lint does.
|
|
230
|
+
const ABSENCE_ASSERT_RE = /\.\s*(?:toBeHidden|to_be_hidden)\s*\(|\.\s*not\s*\.\s*(?:toBeVisible|toBeAttached)\s*\(|\.\s*not_to_be_(?:visible|attached)\s*\(/g;
|
|
231
|
+
// Only Playwright LOCATOR/page matchers count as the positive half of the pair —
|
|
232
|
+
// they prove something rendered. Generic Jest matchers (toBe/toEqual/toHaveLength)
|
|
233
|
+
// are deliberately absent: `expect(resp.status()).toBe(200)` next to a lone
|
|
234
|
+
// toBeHidden() proves nothing about the page.
|
|
235
|
+
const POSITIVE_ASSERT_RE = /\.\s*(?:toHaveText|toContainText|toHaveValue|toHaveValues|toHaveAttribute|toHaveCount|toHaveURL|toHaveTitle|toHaveId|toHaveClass|toHaveCSS|toHaveJSProperty|toHaveAccessibleName|toHaveAccessibleDescription|toHaveRole|toHaveScreenshot|toBeVisible|toBeChecked|toBeEnabled|toBeDisabled|toBeEditable|toBeEmpty|toBeFocused|toBeInViewport|toContainClass|to_have_text|to_contain_text|to_have_value|to_have_values|to_have_attribute|to_have_count|to_have_url|to_have_title|to_have_id|to_have_class|to_have_css|to_have_js_property|to_have_accessible_name|to_have_accessible_description|to_have_role|to_have_screenshot|to_be_visible|to_be_checked|to_be_enabled|to_be_disabled|to_be_editable|to_be_empty|to_be_focused|to_be_in_viewport|to_contain_class)\s*\(/;
|
|
236
|
+
function lintNegativeOnlySpec(commentless) {
|
|
237
|
+
ABSENCE_ASSERT_RE.lastIndex = 0;
|
|
238
|
+
const first = ABSENCE_ASSERT_RE.exec(commentless);
|
|
239
|
+
if (!first)
|
|
240
|
+
return [];
|
|
241
|
+
// Strip the absence assertions themselves, then look for a Playwright matcher
|
|
242
|
+
// on a rendered element (generic Jest matchers do not count — see POSITIVE_ASSERT_RE).
|
|
243
|
+
const remainder = commentless.replace(ABSENCE_ASSERT_RE, "");
|
|
244
|
+
ABSENCE_ASSERT_RE.lastIndex = 0;
|
|
245
|
+
if (POSITIVE_ASSERT_RE.test(remainder))
|
|
246
|
+
return [];
|
|
247
|
+
return [{
|
|
248
|
+
rule: "negative-only-spec",
|
|
249
|
+
severity: "hard",
|
|
250
|
+
line: lineOfOffset(commentless, first.index),
|
|
251
|
+
message: "Every locator assertion in this spec asserts absence (toBeHidden / not.toBeVisible); nothing proves the page rendered, so it passes on a blank page, a 404 or a typo'd selector.",
|
|
252
|
+
remediation: "Add at least one positive assertion on a retained element of the same page (toHaveText / toHaveValue / toHaveCount / toHaveURL) next to the absence assertions.",
|
|
253
|
+
}];
|
|
254
|
+
}
|
|
225
255
|
export function lintUiSpec(raw, language, opts) {
|
|
226
256
|
if (language === "java")
|
|
227
257
|
return [];
|
|
@@ -240,5 +270,7 @@ export function lintUiSpec(raw, language, opts) {
|
|
|
240
270
|
findings.push(...lintUnassertedTrailingAction(stripped));
|
|
241
271
|
}
|
|
242
272
|
findings.push(...lintTautologies(commentless, opts));
|
|
273
|
+
if (!maintenance)
|
|
274
|
+
findings.push(...lintNegativeOnlySpec(commentless));
|
|
243
275
|
return findings;
|
|
244
276
|
}
|