@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,140 @@
|
|
|
1
|
+
import { callSteps, isUIPlannedTest, stepRouteKey, subjectStep } from "../subjectStep.js";
|
|
2
|
+
import { STATED_DIFFERENCE_CONTRACT } from "../verifierContracts.js";
|
|
3
|
+
/** What two planned tests must share before this asks how they differ: the method and
|
|
4
|
+
* path of their subject step. UI planned tests are OUT — two tests of one screen are
|
|
5
|
+
* the normal shape of UI coverage. */
|
|
6
|
+
function subjectIdentity(plannedTest) {
|
|
7
|
+
if (isUIPlannedTest(plannedTest))
|
|
8
|
+
return undefined;
|
|
9
|
+
return stepRouteKey(subjectStep(plannedTest));
|
|
10
|
+
}
|
|
11
|
+
/** A reference reduced to the spelling the id is stored in: trimmed, and exact
|
|
12
|
+
* otherwise. Case is NOT folded — the report joins on the exact id, so folding
|
|
13
|
+
* here would make two ids one name. */
|
|
14
|
+
function nameKey(raw) {
|
|
15
|
+
return typeof raw === "string" ? raw.trim() : "";
|
|
16
|
+
}
|
|
17
|
+
/** Whether either side states a real difference from the other. A planned test id IS
|
|
18
|
+
* its scenario name and the schema refuses a repeated one, so a reference either
|
|
19
|
+
* names exactly one planned test or names none. A reference naming none states no
|
|
20
|
+
* difference, and the pair's own objection is the question about it — naming the
|
|
21
|
+
* bad reference too would ask twice about one mistake. */
|
|
22
|
+
function differenceStated(a, b) {
|
|
23
|
+
const states = (from, to) => (from.declarations?.differsFrom ?? []).some((entry) => nameKey(entry?.plannedTestId) === nameKey(to.plannedTestId) && (entry?.difference ?? "").trim().length > 0);
|
|
24
|
+
return states(a, b) || states(b, a);
|
|
25
|
+
}
|
|
26
|
+
/** One objection per `differsFrom` entry naming no planned test in the plan. A
|
|
27
|
+
* reference is how the agent states that two tests differ, so a misspelled one
|
|
28
|
+
* has to come back AS a misspelling: the pair objection on its own reads as a
|
|
29
|
+
* question the agent believes it already answered, and it would answer it again
|
|
30
|
+
* the same way. A blank reference names nothing either and is one of these. */
|
|
31
|
+
function unknownPartnerObjections(plannedTests) {
|
|
32
|
+
const known = new Set(plannedTests.map((plannedTest) => nameKey(plannedTest?.plannedTestId)).filter(Boolean));
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
const objections = [];
|
|
35
|
+
for (const plannedTest of plannedTests) {
|
|
36
|
+
for (const entry of plannedTest?.declarations?.differsFrom ?? []) {
|
|
37
|
+
const raw = entry?.plannedTestId;
|
|
38
|
+
if (typeof raw !== "string")
|
|
39
|
+
continue;
|
|
40
|
+
const reference = nameKey(raw);
|
|
41
|
+
if (reference && known.has(reference))
|
|
42
|
+
continue;
|
|
43
|
+
// One objection per (planned test, reference): the same bad name written by
|
|
44
|
+
// two planned tests is two mistakes, and each owes its own answer.
|
|
45
|
+
const key = `${nameKey(plannedTest?.plannedTestId)}\u0000${reference}`;
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
objections.push({
|
|
50
|
+
objectionId: `statedDifference:unknownPartner:${plannedTest.plannedTestId}:${reference}`,
|
|
51
|
+
verifier: "statedDifference",
|
|
52
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
53
|
+
message: STATED_DIFFERENCE_CONTRACT.objections.unknownPartner.message,
|
|
54
|
+
evidence: `"${raw}" is not the scenarioName of any planned test in this plan`,
|
|
55
|
+
suggestion: STATED_DIFFERENCE_CONTRACT.objections.unknownPartner.suggestion,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return objections;
|
|
60
|
+
}
|
|
61
|
+
/** One objection per planned test that makes calls but has no subject step: it makes
|
|
62
|
+
* several and names none, or its `stepUnderTest` names a step it does not make.
|
|
63
|
+
* Without this the pair check goes SILENT on exactly the planned tests most likely
|
|
64
|
+
* to repeat one another, because a planned test with no subject is never compared. */
|
|
65
|
+
function unstatedSubjectObjections(plannedTests) {
|
|
66
|
+
const objections = [];
|
|
67
|
+
for (const plannedTest of plannedTests) {
|
|
68
|
+
if (isUIPlannedTest(plannedTest))
|
|
69
|
+
continue;
|
|
70
|
+
const calls = callSteps(plannedTest);
|
|
71
|
+
if (calls.length === 0 || subjectStep(plannedTest))
|
|
72
|
+
continue;
|
|
73
|
+
const declared = plannedTest?.declarations?.stepUnderTest;
|
|
74
|
+
objections.push({
|
|
75
|
+
objectionId: `statedDifference:subjectNotStated:${plannedTest.plannedTestId}`,
|
|
76
|
+
verifier: "statedDifference",
|
|
77
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
78
|
+
message: STATED_DIFFERENCE_CONTRACT.objections.subjectNotStated.message,
|
|
79
|
+
evidence: typeof declared === "number"
|
|
80
|
+
? `stepUnderTest ${declared} names none of the ${calls.length} calls this test makes`
|
|
81
|
+
: `${calls.length} calls and no stepUnderTest`,
|
|
82
|
+
suggestion: STATED_DIFFERENCE_CONTRACT.objections.subjectNotStated.suggestion,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return objections;
|
|
86
|
+
}
|
|
87
|
+
/** Verifier 3. Two planned tests whose subject steps name the same method and path
|
|
88
|
+
* must say how they differ — ASKING, where the old dedup key removed one of the
|
|
89
|
+
* pair on a string that says nothing about what either asserts. Both sides get an
|
|
90
|
+
* objection; a stated difference is taken at its word. No request, no compare. */
|
|
91
|
+
export const statedDifference = {
|
|
92
|
+
name: "statedDifference",
|
|
93
|
+
run(registration, _ctx) {
|
|
94
|
+
const plannedTests = registration.plannedTests ?? [];
|
|
95
|
+
/** Planned-test id -> route -> the partners on that route it has NOT explained,
|
|
96
|
+
* recorded while the pair is known to be unexplained. Deriving it afterwards
|
|
97
|
+
* from the route would list partners this planned test already explained. */
|
|
98
|
+
const unexplained = new Map();
|
|
99
|
+
const record = (id, route, partnerId) => {
|
|
100
|
+
let routes = unexplained.get(id);
|
|
101
|
+
if (!routes) {
|
|
102
|
+
routes = new Map();
|
|
103
|
+
unexplained.set(id, routes);
|
|
104
|
+
}
|
|
105
|
+
let partners = routes.get(route);
|
|
106
|
+
if (!partners) {
|
|
107
|
+
partners = new Set();
|
|
108
|
+
routes.set(route, partners);
|
|
109
|
+
}
|
|
110
|
+
partners.add(partnerId);
|
|
111
|
+
};
|
|
112
|
+
for (let i = 0; i < plannedTests.length; i++) {
|
|
113
|
+
for (let j = i + 1; j < plannedTests.length; j++) {
|
|
114
|
+
const a = plannedTests[i];
|
|
115
|
+
const b = plannedTests[j];
|
|
116
|
+
const identity = subjectIdentity(a);
|
|
117
|
+
if (!identity || identity !== subjectIdentity(b))
|
|
118
|
+
continue;
|
|
119
|
+
if (differenceStated(a, b))
|
|
120
|
+
continue;
|
|
121
|
+
record(a.plannedTestId, identity, b.plannedTestId);
|
|
122
|
+
record(b.plannedTestId, identity, a.plannedTestId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const objections = unknownPartnerObjections(plannedTests).concat(unstatedSubjectObjections(plannedTests));
|
|
126
|
+
return objections.concat([...unexplained.entries()].map(([plannedTestId, routes]) => {
|
|
127
|
+
const shared = [...routes.entries()]
|
|
128
|
+
.map(([route, partners]) => `${route} with ${[...partners].join(", ")}`)
|
|
129
|
+
.join("; ");
|
|
130
|
+
return {
|
|
131
|
+
objectionId: `statedDifference:${plannedTestId}`,
|
|
132
|
+
verifier: "statedDifference",
|
|
133
|
+
plannedTestId,
|
|
134
|
+
message: STATED_DIFFERENCE_CONTRACT.objections.unexplainedPair.message,
|
|
135
|
+
evidence: `shares ${shared}`,
|
|
136
|
+
suggestion: STATED_DIFFERENCE_CONTRACT.objections.unexplainedPair.suggestion,
|
|
137
|
+
};
|
|
138
|
+
}));
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Verifier } from "../types.js";
|
|
2
|
+
/** Verifier 7. A UI planned test's `elements.items` must be COPIES of elements a
|
|
3
|
+
* blueprint captured this run really holds. Captures are EVIDENCE, never an
|
|
4
|
+
* allowlist: a page nobody captured draws nothing, and a missing element is
|
|
5
|
+
* ANSWERABLE, since one behind a modal is legitimately absent. Plan time only,
|
|
6
|
+
* over the pre-scan. */
|
|
7
|
+
export declare const uiElementGrounded: Verifier;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { isUIPlannedTest } from "../subjectStep.js";
|
|
2
|
+
import { UI_ELEMENT_GROUNDED_CONTRACT } from "../verifierContracts.js";
|
|
3
|
+
/** An IDENTIFIER, compared exactly apart from surrounding space: `saveBtn` and
|
|
4
|
+
* `savebtn` are different DOM attributes. Absence has one spelling — the digest
|
|
5
|
+
* omits a field the element does not have and a declaration may send `null` or
|
|
6
|
+
* `""` for the same thing, so those must not read as two different values. */
|
|
7
|
+
function field(value) {
|
|
8
|
+
return typeof value === "string" ? value.trim() : "";
|
|
9
|
+
}
|
|
10
|
+
/** A ROLE or an accessible NAME, reduced to what two honest copies of it share.
|
|
11
|
+
* This much leniency is deliberate and one-directional: it can only ground an
|
|
12
|
+
* element that would otherwise draw an objection, and a false objection teaches
|
|
13
|
+
* the agent that objections are noise. It forgives how a name is SPELLED —
|
|
14
|
+
* letter case, and the repeated spaces an HTML render collapses — and nothing
|
|
15
|
+
* about what it says: a shortened or paraphrased name is not a copy. */
|
|
16
|
+
function folded(value) {
|
|
17
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ").toLowerCase() : "";
|
|
18
|
+
}
|
|
19
|
+
function context(value) {
|
|
20
|
+
return Array.isArray(value) ? value.map(folded).filter(Boolean) : [];
|
|
21
|
+
}
|
|
22
|
+
function fieldsOf(raw) {
|
|
23
|
+
const element = (raw && typeof raw === "object" ? raw : {});
|
|
24
|
+
return {
|
|
25
|
+
role: folded(element.role),
|
|
26
|
+
name: folded(element.accessibleName),
|
|
27
|
+
testId: field(element.testId),
|
|
28
|
+
stableId: field(element.stableId),
|
|
29
|
+
contextText: context(element.contextText),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Whether a captured element is the one the declaration names.
|
|
33
|
+
*
|
|
34
|
+
* An IDENTIFIER decides on its own. `data-testid` and `id` name exactly one
|
|
35
|
+
* element on a page, so a declaration carrying one a capture holds names an
|
|
36
|
+
* element that is really there, whatever else the declaration got wrong. The
|
|
37
|
+
* other fields are not compared then, and deliberately so: objecting that an
|
|
38
|
+
* element "is in no capture" when its test id IS in the capture states
|
|
39
|
+
* something false, and a false objection teaches the agent that objections are
|
|
40
|
+
* noise.
|
|
41
|
+
*
|
|
42
|
+
* With no identifier to go on, the role, the name and a row's context text have
|
|
43
|
+
* to agree — spelling forgiven, content not. Four tiers used to stand here, each
|
|
44
|
+
* with its own leniency and its own rule about when a blank role counted; this
|
|
45
|
+
* is the same reach with one rule per kind of evidence. */
|
|
46
|
+
function names(declared, captured) {
|
|
47
|
+
if (declared.testId)
|
|
48
|
+
return declared.testId === captured.testId;
|
|
49
|
+
if (declared.stableId)
|
|
50
|
+
return declared.stableId === captured.stableId;
|
|
51
|
+
if (declared.role && declared.role !== captured.role)
|
|
52
|
+
return false;
|
|
53
|
+
// A row's own text, which is what separates it from its siblings. Its NAME is
|
|
54
|
+
// not compared here: the capture keeps a repeating element under a template
|
|
55
|
+
// name and again per row with the values filled in, so a row's name really
|
|
56
|
+
// does vary between two honest readings of one page. Row text never grounds an
|
|
57
|
+
// element on its own — the role has to agree, checked above — because a probe
|
|
58
|
+
// got an invented button onto a page by copying a real table row's text.
|
|
59
|
+
if (declared.contextText.length > 0)
|
|
60
|
+
return rowKey(declared) === rowKey(captured);
|
|
61
|
+
return declared.name === captured.name;
|
|
62
|
+
}
|
|
63
|
+
/** `role` plus a row's own text, serialised rather than joined on a separator:
|
|
64
|
+
* row text is page content and can hold any character. */
|
|
65
|
+
function rowKey(element) {
|
|
66
|
+
return JSON.stringify([element.role, element.contextText]);
|
|
67
|
+
}
|
|
68
|
+
function addTo(index, key, element) {
|
|
69
|
+
if (!key)
|
|
70
|
+
return;
|
|
71
|
+
const bucket = index.get(key);
|
|
72
|
+
if (bucket)
|
|
73
|
+
bucket.push(element);
|
|
74
|
+
else
|
|
75
|
+
index.set(key, [element]);
|
|
76
|
+
}
|
|
77
|
+
function poolOf(captured) {
|
|
78
|
+
const pool = { byTestId: new Map(), byStableId: new Map(), byRow: new Map(), byName: new Map() };
|
|
79
|
+
for (const raw of captured) {
|
|
80
|
+
const element = fieldsOf(raw);
|
|
81
|
+
addTo(pool.byTestId, element.testId, element);
|
|
82
|
+
addTo(pool.byStableId, element.stableId, element);
|
|
83
|
+
if (element.contextText.length > 0)
|
|
84
|
+
addTo(pool.byRow, rowKey(element), element);
|
|
85
|
+
addTo(pool.byName, element.name, element);
|
|
86
|
+
}
|
|
87
|
+
return pool;
|
|
88
|
+
}
|
|
89
|
+
/** Narrowed by whatever the declaration can be looked up by, in the order
|
|
90
|
+
* `names` reads them. An entry that states none of the four is UNGROUNDED:
|
|
91
|
+
* there is nothing to look it up by. The schema refuses one, so that only
|
|
92
|
+
* decides the unvalidated path, and refusing is the safe answer there. */
|
|
93
|
+
function grounded(declared, pool) {
|
|
94
|
+
const narrowed = (declared.testId && pool.byTestId.get(declared.testId)) ||
|
|
95
|
+
(declared.stableId && pool.byStableId.get(declared.stableId)) ||
|
|
96
|
+
(declared.contextText.length > 0 && pool.byRow.get(rowKey(declared))) ||
|
|
97
|
+
(declared.name && pool.byName.get(declared.name)) ||
|
|
98
|
+
[];
|
|
99
|
+
return narrowed.some((captured) => names(declared, captured));
|
|
100
|
+
}
|
|
101
|
+
/** Identity for the duplicate check: two entries agreeing on every field name
|
|
102
|
+
* one element, so the second says nothing the first did not. */
|
|
103
|
+
function identityKey(element) {
|
|
104
|
+
return JSON.stringify([element.role, element.name, element.testId, element.stableId, element.contextText]);
|
|
105
|
+
}
|
|
106
|
+
/** How an element reads back in an objection, in the agent's own spelling. */
|
|
107
|
+
function label(raw) {
|
|
108
|
+
const element = (raw && typeof raw === "object" ? raw : {});
|
|
109
|
+
const parts = [
|
|
110
|
+
element.role ? `role "${String(element.role).trim()}"` : "",
|
|
111
|
+
element.accessibleName ? `name "${String(element.accessibleName).trim()}"` : "",
|
|
112
|
+
element.testId ? `testId "${String(element.testId).trim()}"` : "",
|
|
113
|
+
element.stableId ? `stableId "${String(element.stableId).trim()}"` : "",
|
|
114
|
+
].filter(Boolean);
|
|
115
|
+
return parts.length > 0 ? parts.join(" ") : "an entry with no role, name or identifier";
|
|
116
|
+
}
|
|
117
|
+
/** A url split into the part before the fragment and the fragment WHEN it is a
|
|
118
|
+
* route (`#/…`). An ordinary anchor comes back as an empty route and is
|
|
119
|
+
* dropped: it names a place on a page, not a page. */
|
|
120
|
+
function splitHashRoute(raw) {
|
|
121
|
+
const value = String(raw ?? "").trim();
|
|
122
|
+
const hash = value.indexOf("#");
|
|
123
|
+
if (hash < 0)
|
|
124
|
+
return { base: value, route: "" };
|
|
125
|
+
const fragment = value.slice(hash);
|
|
126
|
+
return { base: value.slice(0, hash), route: fragment.startsWith("#/") ? fragment.replace(/\/+$/, "") : "" };
|
|
127
|
+
}
|
|
128
|
+
/** The path half of a url. The ROOT stays `/` rather than folding to empty, or a
|
|
129
|
+
* page named by its host alone would have no path to match on and the check
|
|
130
|
+
* would go silent on it. The query string is dropped, which only pools MORE
|
|
131
|
+
* captures for one page. */
|
|
132
|
+
function urlPath(raw) {
|
|
133
|
+
const { base, route } = splitHashRoute(raw);
|
|
134
|
+
// A hash route IS the path for these apps; the part before the `#` is the same
|
|
135
|
+
// shell url on every screen.
|
|
136
|
+
if (route)
|
|
137
|
+
return route.slice(1).split("?")[0].replace(/\/+$/, "") || "/";
|
|
138
|
+
if (!base)
|
|
139
|
+
return "";
|
|
140
|
+
try {
|
|
141
|
+
return new URL(base).pathname.replace(/\/+$/, "") || "/";
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return base.startsWith("/") ? base.split("?")[0].replace(/\/+$/, "") || "/" : "";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The spellings of the machine the run is on: a capture at `127.0.0.1` and a
|
|
148
|
+
* page url at `localhost` are the same page. */
|
|
149
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
|
|
150
|
+
/** The host half of a url, empty for a path-only one. Read so a wrong host
|
|
151
|
+
* cannot ground an element on another service's capture. */
|
|
152
|
+
function urlHost(raw) {
|
|
153
|
+
const value = String(raw ?? "").trim().split("#")[0];
|
|
154
|
+
if (!value)
|
|
155
|
+
return "";
|
|
156
|
+
try {
|
|
157
|
+
const url = new URL(value);
|
|
158
|
+
const hostname = url.hostname.toLowerCase();
|
|
159
|
+
const host = LOOPBACK_HOSTS.has(hostname) ? "localhost" : hostname;
|
|
160
|
+
return url.port ? `${host}:${url.port}` : host;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Every element captured for the page the planned test names.
|
|
167
|
+
* UNDEFINED means NO CAPTURE COVERS THE PAGE; a capture that covers it and holds
|
|
168
|
+
* nothing returns an EMPTY pool, so a declared element is ungrounded. Every
|
|
169
|
+
* capture of the page is pooled — later ones hold what an action revealed. */
|
|
170
|
+
function elementsForPage(captures, url) {
|
|
171
|
+
const path = urlPath(url);
|
|
172
|
+
if (!path)
|
|
173
|
+
return undefined;
|
|
174
|
+
const host = urlHost(url);
|
|
175
|
+
const pool = captures.filter((capture) => urlPath(capture.url) === path && (!host || urlHost(capture.url) === host));
|
|
176
|
+
if (pool.length === 0)
|
|
177
|
+
return undefined;
|
|
178
|
+
return poolOf(pool.flatMap((capture) => (Array.isArray(capture.elements) ? capture.elements : [])));
|
|
179
|
+
}
|
|
180
|
+
/** How many elements this run captured, across every page. The exemption below
|
|
181
|
+
* reads this and never `captures.length`: a capture object that holds nothing
|
|
182
|
+
* is not evidence of anything the run saw. */
|
|
183
|
+
function capturedElementCount(captures) {
|
|
184
|
+
return captures.reduce((total, capture) => total + (Array.isArray(capture.elements) ? capture.elements.length : 0), 0);
|
|
185
|
+
}
|
|
186
|
+
function capturedPages(captures) {
|
|
187
|
+
const pages = [];
|
|
188
|
+
for (const capture of captures) {
|
|
189
|
+
const url = String(capture?.url ?? "").trim();
|
|
190
|
+
if (url && !pages.includes(url))
|
|
191
|
+
pages.push(url);
|
|
192
|
+
}
|
|
193
|
+
return pages;
|
|
194
|
+
}
|
|
195
|
+
/** The `data-testid` values the diff removed from a page that survives.
|
|
196
|
+
*
|
|
197
|
+
* A removal-guard element is DECLARED because it is gone: the test asserts it no
|
|
198
|
+
* longer renders, so no capture of the page can hold it and "not in any capture"
|
|
199
|
+
* is not a fault. `testbot-task1.md` asks the agent for exactly this entry, so
|
|
200
|
+
* objecting to it would contradict the instruction it followed.
|
|
201
|
+
*
|
|
202
|
+
* `data-testid` alone: it is the one attribute a declared element has a field
|
|
203
|
+
* for. A guard on `data-cy` or `data-qa` reaches the plan with `testId` null and
|
|
204
|
+
* nothing else that carries the pair, so this cannot recognise it. */
|
|
205
|
+
function removedTestIds(ctx) {
|
|
206
|
+
const removed = Array.isArray(ctx?.removedUiElements) ? ctx.removedUiElements : [];
|
|
207
|
+
return new Set(removed
|
|
208
|
+
.filter((item) => field(item?.attribute).toLowerCase() === "data-testid")
|
|
209
|
+
.map((item) => field(item?.value))
|
|
210
|
+
.filter(Boolean));
|
|
211
|
+
}
|
|
212
|
+
/** An objection names everything it saw. It used to stop at eight and say "and
|
|
213
|
+
* N more", which decided for the agent which pages were worth reading. */
|
|
214
|
+
function list(values) {
|
|
215
|
+
return values.join("; ");
|
|
216
|
+
}
|
|
217
|
+
/** Verifier 7. A UI planned test's `elements.items` must be COPIES of elements a
|
|
218
|
+
* blueprint captured this run really holds. Captures are EVIDENCE, never an
|
|
219
|
+
* allowlist: a page nobody captured draws nothing, and a missing element is
|
|
220
|
+
* ANSWERABLE, since one behind a modal is legitimately absent. Plan time only,
|
|
221
|
+
* over the pre-scan. */
|
|
222
|
+
export const uiElementGrounded = {
|
|
223
|
+
name: "uiElementGrounded",
|
|
224
|
+
run(registration, ctx) {
|
|
225
|
+
const objections = [];
|
|
226
|
+
const captures = Array.isArray(ctx.uiCaptures) ? ctx.uiCaptures.filter((capture) => capture && typeof capture === "object") : [];
|
|
227
|
+
const pools = new Map();
|
|
228
|
+
const removedIds = removedTestIds(ctx);
|
|
229
|
+
for (const plannedTest of registration.plannedTests ?? []) {
|
|
230
|
+
if (!isUIPlannedTest(plannedTest))
|
|
231
|
+
continue;
|
|
232
|
+
const raw = plannedTest.declarations?.elements?.items;
|
|
233
|
+
const declared = Array.isArray(raw) ? raw : [];
|
|
234
|
+
const url = String(plannedTest.declarations?.elements?.pageUrl ?? "").trim();
|
|
235
|
+
if (declared.length === 0) {
|
|
236
|
+
// A run that saw nothing cannot ask this: standalone and IDE use store no
|
|
237
|
+
// digest and a backend-only run takes none. The boundary is the ELEMENT
|
|
238
|
+
// count, not the capture count — a capture holding no elements shows the
|
|
239
|
+
// agent nothing to copy, and the suggestion has no element to name.
|
|
240
|
+
if (capturedElementCount(captures) === 0)
|
|
241
|
+
continue;
|
|
242
|
+
objections.push({
|
|
243
|
+
objectionId: `uiElementGrounded:nogrounding:${plannedTest.plannedTestId}`,
|
|
244
|
+
verifier: "uiElementGrounded",
|
|
245
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
246
|
+
message: UI_ELEMENT_GROUNDED_CONTRACT.objections.nogrounding.message,
|
|
247
|
+
evidence: `this run captured ${captures.length} blueprint(s), covering: ${list(capturedPages(captures))}`,
|
|
248
|
+
suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.nogrounding.suggestion,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const duplicates = declared.filter((element) => {
|
|
254
|
+
const key = identityKey(fieldsOf(element));
|
|
255
|
+
if (seen.has(key))
|
|
256
|
+
return true;
|
|
257
|
+
seen.add(key);
|
|
258
|
+
return false;
|
|
259
|
+
});
|
|
260
|
+
if (duplicates.length > 0) {
|
|
261
|
+
objections.push({
|
|
262
|
+
objectionId: `uiElementGrounded:duplicate:${plannedTest.plannedTestId}`,
|
|
263
|
+
verifier: "uiElementGrounded",
|
|
264
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
265
|
+
message: UI_ELEMENT_GROUNDED_CONTRACT.objections.duplicate.message,
|
|
266
|
+
evidence: `repeated: ${list(duplicates.map(label))}`,
|
|
267
|
+
suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.duplicate.suggestion,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (!url) {
|
|
271
|
+
objections.push({
|
|
272
|
+
objectionId: `uiElementGrounded:pagecontext:${plannedTest.plannedTestId}`,
|
|
273
|
+
verifier: "uiElementGrounded",
|
|
274
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
275
|
+
message: UI_ELEMENT_GROUNDED_CONTRACT.objections.pagecontext.message,
|
|
276
|
+
evidence: `${declared.length} element(s) declared, \`elements.pageUrl\` empty`,
|
|
277
|
+
suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.pagecontext.suggestion,
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// Pooled once per page, not once per planned test: two planned tests on one page
|
|
282
|
+
// are the normal shape of a UI plan.
|
|
283
|
+
if (!pools.has(url))
|
|
284
|
+
pools.set(url, elementsForPage(captures, url));
|
|
285
|
+
const pool = pools.get(url);
|
|
286
|
+
// A page no capture covers is SILENT: the pre-scan captures the plan is
|
|
287
|
+
// checked against are taken before generation's action-gated states, so a
|
|
288
|
+
// page can be legitimately missing from all of them.
|
|
289
|
+
if (pool === undefined)
|
|
290
|
+
continue;
|
|
291
|
+
const ungrounded = declared.filter((element) => {
|
|
292
|
+
const fields = fieldsOf(element);
|
|
293
|
+
// The removal guard: gone from the page on purpose, so the capture is
|
|
294
|
+
// the wrong place to look for it.
|
|
295
|
+
if (fields.testId && removedIds.has(fields.testId))
|
|
296
|
+
return false;
|
|
297
|
+
return !grounded(fields, pool);
|
|
298
|
+
});
|
|
299
|
+
if (ungrounded.length === 0)
|
|
300
|
+
continue;
|
|
301
|
+
objections.push({
|
|
302
|
+
objectionId: `uiElementGrounded:${plannedTest.plannedTestId}`,
|
|
303
|
+
verifier: "uiElementGrounded",
|
|
304
|
+
plannedTestId: plannedTest.plannedTestId,
|
|
305
|
+
message: UI_ELEMENT_GROUNDED_CONTRACT.objections.ungrounded.message,
|
|
306
|
+
// A capture the element cap cut is a PARTIAL view of that page, so "not
|
|
307
|
+
// in any capture" is not a fact about it. The objection still fires — it
|
|
308
|
+
// is answerable — but it says which it is.
|
|
309
|
+
evidence: `not in any capture of ${url}: ${list(ungrounded.map(label))}` +
|
|
310
|
+
(captures.some((capture) => capture.url === url && capture.elementsTruncated)
|
|
311
|
+
? " (a capture of this page was cut at the element cap, so it holds only part of the page)"
|
|
312
|
+
: ""),
|
|
313
|
+
suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.ungrounded.suggestion,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return objections;
|
|
317
|
+
},
|
|
318
|
+
};
|
|
@@ -96,14 +96,7 @@ export function registerAnalysisResources(server) {
|
|
|
96
96
|
},
|
|
97
97
|
authentication: analysis.authentication,
|
|
98
98
|
infrastructure: analysis.infrastructure,
|
|
99
|
-
|
|
100
|
-
totalPaths: analysis.apiEndpoints.endpoints.length,
|
|
101
|
-
totalMethods: analysis.apiEndpoints.endpoints.reduce((sum, ep) => sum + ep.methods.length, 0),
|
|
102
|
-
totalInteractions: analysis.apiEndpoints.endpoints.reduce((sum, ep) => sum +
|
|
103
|
-
ep.methods.reduce((msum, m) => msum + m.interactions.length, 0), 0),
|
|
104
|
-
baseUrl: analysis.apiEndpoints.baseUrl,
|
|
105
|
-
},
|
|
106
|
-
scenarioCount: analysis.businessContext.draftedScenarios.length,
|
|
99
|
+
workspace: analysis.workspace,
|
|
107
100
|
existingTests: analysis.existingTests,
|
|
108
101
|
};
|
|
109
102
|
return {
|
|
@@ -116,112 +109,6 @@ export function registerAnalysisResources(server) {
|
|
|
116
109
|
],
|
|
117
110
|
};
|
|
118
111
|
});
|
|
119
|
-
// ── Endpoints (compact listing) ──
|
|
120
|
-
server.registerResource("analysis_endpoints", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/endpoints`, {
|
|
121
|
-
list: makeListCallback("endpoints"),
|
|
122
|
-
}), {
|
|
123
|
-
title: "Endpoint Listing",
|
|
124
|
-
description: "Compact listing of all endpoints (path, methods, interaction counts). Use path-specific resources for full detail.",
|
|
125
|
-
mimeType: "application/json",
|
|
126
|
-
}, async (uri, params) => {
|
|
127
|
-
const sessionId = params.sessionId;
|
|
128
|
-
const { analysis } = await loadAnalysis(sessionId);
|
|
129
|
-
const compact = analysis.apiEndpoints.endpoints.map((ep) => ({
|
|
130
|
-
path: ep.path,
|
|
131
|
-
resourceGroup: ep.resourceGroup,
|
|
132
|
-
pathParams: ep.pathParams.map((p) => p.name),
|
|
133
|
-
methods: ep.methods.map((m) => ({
|
|
134
|
-
method: m.method,
|
|
135
|
-
authRequired: m.authRequired,
|
|
136
|
-
interactionCount: m.interactions.length,
|
|
137
|
-
interactionTypes: m.interactions.map((i) => i.type),
|
|
138
|
-
hasCookies: m.interactions.some((i) => (i.response.cookies?.length ?? 0) > 0),
|
|
139
|
-
hasResponseHeaders: m.interactions.some((i) => Object.keys(i.response.headers ?? {}).length > 0),
|
|
140
|
-
createsResource: m.createsResource,
|
|
141
|
-
})),
|
|
142
|
-
}));
|
|
143
|
-
return {
|
|
144
|
-
contents: [
|
|
145
|
-
{
|
|
146
|
-
uri: uri.href,
|
|
147
|
-
mimeType: "application/json",
|
|
148
|
-
text: JSON.stringify({ baseUrl: analysis.apiEndpoints.baseUrl, endpoints: compact }, null, 2),
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
};
|
|
152
|
-
});
|
|
153
|
-
// ── Single Path Detail ──
|
|
154
|
-
// No list callback — these are parameterized drilldowns, not top-level resources.
|
|
155
|
-
server.registerResource("analysis_endpoint_path", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/endpoints/{+path}`, { list: undefined }), {
|
|
156
|
-
title: "Endpoint Path Detail",
|
|
157
|
-
description: "Full detail for a specific path: all methods, interactions, params.",
|
|
158
|
-
mimeType: "application/json",
|
|
159
|
-
}, async (uri, params) => {
|
|
160
|
-
const sessionId = params.sessionId;
|
|
161
|
-
const endpointPath = "/" + params.path;
|
|
162
|
-
const { analysis } = await loadAnalysis(sessionId);
|
|
163
|
-
const ep = analysis.apiEndpoints.endpoints.find((e) => e.path === endpointPath);
|
|
164
|
-
if (!ep) {
|
|
165
|
-
throw new Error(`Endpoint path "${endpointPath}" not found in session "${sessionId}".`);
|
|
166
|
-
}
|
|
167
|
-
return {
|
|
168
|
-
contents: [
|
|
169
|
-
{
|
|
170
|
-
uri: uri.href,
|
|
171
|
-
mimeType: "application/json",
|
|
172
|
-
text: JSON.stringify(ep, null, 2),
|
|
173
|
-
},
|
|
174
|
-
],
|
|
175
|
-
};
|
|
176
|
-
});
|
|
177
|
-
// ── Single Method Detail ──
|
|
178
|
-
server.registerResource("analysis_endpoint_method", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/endpoints/{+path}/{method}`, { list: undefined }), {
|
|
179
|
-
title: "Endpoint Method Detail",
|
|
180
|
-
description: "Full detail for a specific method on a path: interactions, params, auth.",
|
|
181
|
-
mimeType: "application/json",
|
|
182
|
-
}, async (uri, params) => {
|
|
183
|
-
const sessionId = params.sessionId;
|
|
184
|
-
const endpointPath = "/" + params.path;
|
|
185
|
-
const method = params.method.toUpperCase();
|
|
186
|
-
const { analysis } = await loadAnalysis(sessionId);
|
|
187
|
-
const ep = analysis.apiEndpoints.endpoints.find((e) => e.path === endpointPath);
|
|
188
|
-
if (!ep) {
|
|
189
|
-
throw new Error(`Endpoint path "${endpointPath}" not found in session "${sessionId}".`);
|
|
190
|
-
}
|
|
191
|
-
const m = ep.methods.find((em) => em.method.toUpperCase() === method);
|
|
192
|
-
if (!m) {
|
|
193
|
-
throw new Error(`Method "${method}" not found on "${endpointPath}" in session "${sessionId}".`);
|
|
194
|
-
}
|
|
195
|
-
return {
|
|
196
|
-
contents: [
|
|
197
|
-
{
|
|
198
|
-
uri: uri.href,
|
|
199
|
-
mimeType: "application/json",
|
|
200
|
-
text: JSON.stringify({ path: ep.path, resourceGroup: ep.resourceGroup, pathParams: ep.pathParams, ...m }, null, 2),
|
|
201
|
-
},
|
|
202
|
-
],
|
|
203
|
-
};
|
|
204
|
-
});
|
|
205
|
-
// ── Scenarios ──
|
|
206
|
-
server.registerResource("analysis_scenarios", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/scenarios`, {
|
|
207
|
-
list: makeListCallback("scenarios"),
|
|
208
|
-
}), {
|
|
209
|
-
title: "Drafted Scenarios",
|
|
210
|
-
description: "All drafted user-flow scenarios with steps, chaining, and priority.",
|
|
211
|
-
mimeType: "application/json",
|
|
212
|
-
}, async (uri, params) => {
|
|
213
|
-
const sessionId = params.sessionId;
|
|
214
|
-
const { analysis } = await loadAnalysis(sessionId);
|
|
215
|
-
return {
|
|
216
|
-
contents: [
|
|
217
|
-
{
|
|
218
|
-
uri: uri.href,
|
|
219
|
-
mimeType: "application/json",
|
|
220
|
-
text: JSON.stringify(analysis.businessContext.draftedScenarios, null, 2),
|
|
221
|
-
},
|
|
222
|
-
],
|
|
223
|
-
};
|
|
224
|
-
});
|
|
225
112
|
// ── Branch Diff ──
|
|
226
113
|
server.registerResource("analysis_diff", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/diff`, {
|
|
227
114
|
list: makeListCallback("diff"),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
3
|
import { AnalyticsService } from "../services/AnalyticsService.js";
|
|
4
|
-
import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, } from "../prompts/test-recommendation/recommendationSections.js";
|
|
5
4
|
import { getTestbotPrompt, parseRelatedRepositories, } from "../prompts/testbot/testbot-prompts.js";
|
|
6
5
|
import { readWorkspaceServices } from "../prompts/prompt-utils.js";
|
|
7
6
|
export function registerTestbotResource(server) {
|
|
@@ -20,10 +19,21 @@ export function registerTestbotResource(server) {
|
|
|
20
19
|
mimeType: "text/plain",
|
|
21
20
|
}, async (uri) => {
|
|
22
21
|
const param = (name, fallback) => uri.searchParams.get(name) ?? fallback;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
// Absent or unparseable leaves these NaN, so the knobs below pass undefined
|
|
23
|
+
// rather than the MCP's own default. WHOLE STRING OR NOTHING, where `parseInt`
|
|
24
|
+
// read `8abc` as 8 and `1e3` as 1. NONNEGATIVE AND SAFE, because every reader
|
|
25
|
+
// is an `.int().min(0)` field: out of range is treated as unset.
|
|
26
|
+
const intParam = (name) => {
|
|
27
|
+
const raw = (uri.searchParams.get(name) ?? "").trim();
|
|
28
|
+
// No leading zeros, so `00` cannot arrive as a ceiling of zero from
|
|
29
|
+
// something that reads like a typo. A leading `+` is still accepted — it
|
|
30
|
+
// is a deliberate spelling of a real number, pinned by its own test.
|
|
31
|
+
if (!/^\+?(?:0|[1-9]\d*)$/.test(raw))
|
|
32
|
+
return NaN;
|
|
33
|
+
const value = Number(raw);
|
|
34
|
+
return Number.isSafeInteger(value) ? value : NaN;
|
|
35
|
+
};
|
|
36
|
+
const prNum = intParam("prNumber");
|
|
27
37
|
const repositoryPath = param("repositoryPath", ".");
|
|
28
38
|
const services = await readWorkspaceServices(repositoryPath);
|
|
29
39
|
const prompt = getTestbotPrompt({
|
|
@@ -31,9 +41,6 @@ export function registerTestbotResource(server) {
|
|
|
31
41
|
prDescription: param("prDescription", ""),
|
|
32
42
|
repositoryPath,
|
|
33
43
|
baseBranch: uri.searchParams.get("baseBranch") || undefined,
|
|
34
|
-
maxRecommendations: isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec,
|
|
35
|
-
maxGenerate: isNaN(maxGen) ? MAX_TESTS_TO_GENERATE : maxGen,
|
|
36
|
-
maxCritical: isNaN(maxCrit) ? MAX_CRITICAL_TESTS : maxCrit,
|
|
37
44
|
prNumber: isNaN(prNum) ? undefined : prNum,
|
|
38
45
|
userPrompt: uri.searchParams.get("userPrompt") || undefined,
|
|
39
46
|
services: services.length ? services : undefined,
|
|
@@ -47,14 +54,17 @@ export function registerTestbotResource(server) {
|
|
|
47
54
|
language: uri.searchParams.get("language") || undefined,
|
|
48
55
|
});
|
|
49
56
|
AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
|
|
50
|
-
//
|
|
51
|
-
// and
|
|
52
|
-
//
|
|
53
|
-
//
|
|
57
|
+
// The URI WITHOUT its query string. The agent reads this line above the
|
|
58
|
+
// prompt text, and a legacy count argument in the query was reaching it as
|
|
59
|
+
// a number to plan against even though the server applies none of them —
|
|
60
|
+
// plans cited it back as a ceiling. Stripping the query also keeps
|
|
61
|
+
// `uiCredentials` out of this line. `uri.origin` is the string "null" for a
|
|
62
|
+
// non-special scheme like `skyramp:`, so the parts are joined by hand.
|
|
63
|
+
const uriWithoutQuery = `${uri.protocol}//${uri.host}${uri.pathname}`;
|
|
54
64
|
return {
|
|
55
65
|
contents: [
|
|
56
66
|
{
|
|
57
|
-
uri:
|
|
67
|
+
uri: uriWithoutQuery,
|
|
58
68
|
mimeType: "text/plain",
|
|
59
69
|
text: prompt,
|
|
60
70
|
},
|