@skyramp/mcp 0.2.0-rc.1 → 0.2.0-rc.2
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/index.js +4 -2
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +215 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +3 -1
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +222 -1
- package/build/prompts/testbot/testbot-prompts.js +18 -62
- package/build/prompts/testbot/testbot-prompts.test.js +65 -31
- package/build/services/ScenarioGenerationService.js +11 -1
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +171 -63
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +168 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +54 -5
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +4 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +6 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +21 -4
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +3 -0
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- package/build/types/TestHealth.js +0 -4
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var possibleAssertions_exports = {};
|
|
20
|
+
__export(possibleAssertions_exports, {
|
|
21
|
+
buildFullCaptureAssertions: () => buildFullCaptureAssertions,
|
|
22
|
+
buildPossibleAssertions: () => buildPossibleAssertions
|
|
23
|
+
});
|
|
24
|
+
module.exports = __toCommonJS(possibleAssertions_exports);
|
|
25
|
+
const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
26
|
+
"button",
|
|
27
|
+
"link",
|
|
28
|
+
"textbox",
|
|
29
|
+
"combobox",
|
|
30
|
+
"tab",
|
|
31
|
+
"menuitem",
|
|
32
|
+
"switch",
|
|
33
|
+
"checkbox",
|
|
34
|
+
"radio",
|
|
35
|
+
"option",
|
|
36
|
+
"searchbox",
|
|
37
|
+
"dialog",
|
|
38
|
+
"alertdialog"
|
|
39
|
+
]);
|
|
40
|
+
function escapeForSingleQuote(s) {
|
|
41
|
+
let cleaned = s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/[\r\n]+/g, " ");
|
|
42
|
+
if (cleaned.length > 80)
|
|
43
|
+
cleaned = cleaned.slice(0, 79) + "\u2026";
|
|
44
|
+
return cleaned;
|
|
45
|
+
}
|
|
46
|
+
function buildPossibleAssertions(delta) {
|
|
47
|
+
const results = [];
|
|
48
|
+
for (const el of delta.elementsAdded) {
|
|
49
|
+
if (!el.accessibleName.trim()) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const escapedName = escapeForSingleQuote(el.accessibleName);
|
|
53
|
+
const code = `await expect(page.getByRole('${el.role}', { name: '${escapedName}' })).toBeVisible();`;
|
|
54
|
+
let rationale;
|
|
55
|
+
let tier;
|
|
56
|
+
if (INTERACTIVE_ROLES.has(el.role)) {
|
|
57
|
+
rationale = `Element added to DOM after action: ${el.role} "${el.accessibleName}"`;
|
|
58
|
+
tier = "MEDIUM";
|
|
59
|
+
} else if (el.role === "heading") {
|
|
60
|
+
rationale = `Heading appeared after action: "${el.accessibleName}"`;
|
|
61
|
+
tier = "MEDIUM";
|
|
62
|
+
} else {
|
|
63
|
+
rationale = `${el.role} live region appeared: "${el.accessibleName}"`;
|
|
64
|
+
tier = "LOW";
|
|
65
|
+
}
|
|
66
|
+
results.push({ code, rationale, tier });
|
|
67
|
+
}
|
|
68
|
+
for (const el of delta.elementsRemoved) {
|
|
69
|
+
if (!el.accessibleName.trim()) continue;
|
|
70
|
+
const escapedName = escapeForSingleQuote(el.accessibleName);
|
|
71
|
+
const code = `await expect(page.getByRole('${el.role}', { name: '${escapedName}' })).not.toBeVisible();`;
|
|
72
|
+
let tier;
|
|
73
|
+
if (INTERACTIVE_ROLES.has(el.role)) {
|
|
74
|
+
tier = "MEDIUM";
|
|
75
|
+
} else {
|
|
76
|
+
tier = "LOW";
|
|
77
|
+
}
|
|
78
|
+
results.push({
|
|
79
|
+
code,
|
|
80
|
+
rationale: `Element removed from DOM after action: ${el.role} "${el.accessibleName}"`,
|
|
81
|
+
tier
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const tc of delta.textChanges) {
|
|
85
|
+
if (!tc.before.trim() && !tc.after.trim()) continue;
|
|
86
|
+
if (!tc.accessibleName.trim()) continue;
|
|
87
|
+
const escapedAccessibleName = escapeForSingleQuote(tc.accessibleName);
|
|
88
|
+
const escapedAfter = escapeForSingleQuote(tc.after);
|
|
89
|
+
const code = `await expect(page.getByRole('${tc.role}', { name: '${escapedAccessibleName}' })).toHaveText('${escapedAfter}');`;
|
|
90
|
+
results.push({
|
|
91
|
+
code,
|
|
92
|
+
rationale: `Text changed: "${tc.before}" \u2192 "${tc.after}"`,
|
|
93
|
+
tier: "HIGH"
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
for (const rc of delta.repeatingCountChanges) {
|
|
97
|
+
if (rc.before === rc.after) continue;
|
|
98
|
+
if (!rc.accessibleNameTemplate.trim()) continue;
|
|
99
|
+
const regexPattern = templateToRegex(rc.accessibleNameTemplate);
|
|
100
|
+
const code = `await expect(page.getByRole('${rc.role}', { name: ${regexPattern} })).toHaveCount(${rc.after});`;
|
|
101
|
+
results.push({
|
|
102
|
+
code,
|
|
103
|
+
rationale: `Repeating element count changed: ${rc.before} \u2192 ${rc.after}`,
|
|
104
|
+
tier: "HIGH"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const tierOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
108
|
+
results.sort((a, b) => tierOrder[a.tier] - tierOrder[b.tier]);
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
function buildFullCaptureAssertions(blueprint) {
|
|
112
|
+
const out = [];
|
|
113
|
+
if (blueprint.url && blueprint.url.trim()) {
|
|
114
|
+
const escapedUrl = escapeForSingleQuote(blueprint.url);
|
|
115
|
+
out.push({
|
|
116
|
+
code: `await expect(page).toHaveURL('${escapedUrl}');`,
|
|
117
|
+
rationale: `Page URL after action: ${blueprint.url}`,
|
|
118
|
+
tier: "HIGH"
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const heading = findFirstHeading(blueprint);
|
|
122
|
+
if (heading) {
|
|
123
|
+
const escapedName = escapeForSingleQuote(heading);
|
|
124
|
+
out.push({
|
|
125
|
+
code: `await expect(page.getByRole('heading', { name: '${escapedName}' })).toBeVisible();`,
|
|
126
|
+
rationale: `Heading visible on destination page: "${heading}"`,
|
|
127
|
+
tier: "LOW"
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function findFirstHeading(blueprint) {
|
|
133
|
+
for (const section of blueprint.sections) {
|
|
134
|
+
for (const el of section.elements) {
|
|
135
|
+
if (el.role === "heading" && el.accessibleName.trim())
|
|
136
|
+
return el.accessibleName;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
function templateToRegex(template) {
|
|
142
|
+
let pattern = template.replace(/[.+*?^$()|[\]\\]/g, "\\$&");
|
|
143
|
+
pattern = pattern.replace(/\{[a-zA-Z0-9_]+\}/g, ".+");
|
|
144
|
+
return `/^${pattern}$/i`;
|
|
145
|
+
}
|
|
146
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
147
|
+
0 && (module.exports = {
|
|
148
|
+
buildFullCaptureAssertions,
|
|
149
|
+
buildPossibleAssertions
|
|
150
|
+
});
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var import_possibleAssertions = require("./possibleAssertions");
|
|
3
|
+
const cases = [];
|
|
4
|
+
function test(name, run) {
|
|
5
|
+
cases.push({ name, run });
|
|
6
|
+
}
|
|
7
|
+
function assertEqual(actual, expected, msg) {
|
|
8
|
+
const a = JSON.stringify(actual);
|
|
9
|
+
const e = JSON.stringify(expected);
|
|
10
|
+
if (a !== e)
|
|
11
|
+
throw new Error(`${msg ?? "assertEqual"} \u2014 expected ${e}, got ${a}`);
|
|
12
|
+
}
|
|
13
|
+
test("Empty delta \u2192 empty array", () => {
|
|
14
|
+
const delta = {
|
|
15
|
+
hasStructuralChange: false,
|
|
16
|
+
sectionsAdded: [],
|
|
17
|
+
sectionsRemoved: [],
|
|
18
|
+
elementsAdded: [],
|
|
19
|
+
elementsRemoved: [],
|
|
20
|
+
repeatingCountChanges: [],
|
|
21
|
+
repeatingItemsChanged: [],
|
|
22
|
+
textChanges: [],
|
|
23
|
+
enrichmentChanges: []
|
|
24
|
+
};
|
|
25
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
26
|
+
assertEqual(assertions.length, 0);
|
|
27
|
+
});
|
|
28
|
+
test("elementsAdded with a button \u2192 1 assertion, role-based locator, toBeVisible(), MEDIUM tier", () => {
|
|
29
|
+
const delta = {
|
|
30
|
+
hasStructuralChange: true,
|
|
31
|
+
sectionsAdded: [],
|
|
32
|
+
sectionsRemoved: [],
|
|
33
|
+
elementsAdded: [
|
|
34
|
+
{
|
|
35
|
+
logicalName: "save_btn",
|
|
36
|
+
sectionLogicalName: "main",
|
|
37
|
+
role: "button",
|
|
38
|
+
accessibleName: "Save Changes",
|
|
39
|
+
kind: "persistent"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
elementsRemoved: [],
|
|
43
|
+
repeatingCountChanges: [],
|
|
44
|
+
repeatingItemsChanged: [],
|
|
45
|
+
textChanges: [],
|
|
46
|
+
enrichmentChanges: []
|
|
47
|
+
};
|
|
48
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
49
|
+
assertEqual(assertions.length, 1);
|
|
50
|
+
assertEqual(assertions[0].code, "await expect(page.getByRole('button', { name: 'Save Changes' })).toBeVisible();");
|
|
51
|
+
assertEqual(assertions[0].tier, "MEDIUM");
|
|
52
|
+
if (!assertions[0].rationale.includes("button"))
|
|
53
|
+
throw new Error("rationale should mention button role");
|
|
54
|
+
});
|
|
55
|
+
test("elementsRemoved with a textbox \u2192 .not.toBeVisible(), MEDIUM", () => {
|
|
56
|
+
const delta = {
|
|
57
|
+
hasStructuralChange: true,
|
|
58
|
+
sectionsAdded: [],
|
|
59
|
+
sectionsRemoved: [],
|
|
60
|
+
elementsAdded: [],
|
|
61
|
+
elementsRemoved: [
|
|
62
|
+
{
|
|
63
|
+
logicalName: "email_input",
|
|
64
|
+
sectionLogicalName: "form",
|
|
65
|
+
role: "textbox",
|
|
66
|
+
accessibleName: "Email"
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
repeatingCountChanges: [],
|
|
70
|
+
repeatingItemsChanged: [],
|
|
71
|
+
textChanges: [],
|
|
72
|
+
enrichmentChanges: []
|
|
73
|
+
};
|
|
74
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
75
|
+
assertEqual(assertions.length, 1);
|
|
76
|
+
assertEqual(assertions[0].code, "await expect(page.getByRole('textbox', { name: 'Email' })).not.toBeVisible();");
|
|
77
|
+
assertEqual(assertions[0].tier, "MEDIUM");
|
|
78
|
+
if (!assertions[0].rationale.includes("removed"))
|
|
79
|
+
throw new Error("rationale should mention removed");
|
|
80
|
+
});
|
|
81
|
+
test("repeatingCountChanges 12 \u2192 13 \u2192 toHaveCount(13), HIGH", () => {
|
|
82
|
+
const delta = {
|
|
83
|
+
hasStructuralChange: true,
|
|
84
|
+
sectionsAdded: [],
|
|
85
|
+
sectionsRemoved: [],
|
|
86
|
+
elementsAdded: [],
|
|
87
|
+
elementsRemoved: [],
|
|
88
|
+
repeatingCountChanges: [
|
|
89
|
+
{
|
|
90
|
+
logicalName: "order_row_btn",
|
|
91
|
+
sectionLogicalName: "main",
|
|
92
|
+
role: "button",
|
|
93
|
+
accessibleNameTemplate: "View details for order {orderId}",
|
|
94
|
+
before: 12,
|
|
95
|
+
after: 13,
|
|
96
|
+
delta: 1
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
repeatingItemsChanged: [],
|
|
100
|
+
textChanges: [],
|
|
101
|
+
enrichmentChanges: []
|
|
102
|
+
};
|
|
103
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
104
|
+
assertEqual(assertions.length, 1);
|
|
105
|
+
if (!assertions[0].code.includes("toHaveCount(13)"))
|
|
106
|
+
throw new Error("code should include toHaveCount(13)");
|
|
107
|
+
if (!assertions[0].code.includes("/^View details for order .+$/i"))
|
|
108
|
+
throw new Error("code should include regex pattern from template");
|
|
109
|
+
assertEqual(assertions[0].tier, "HIGH");
|
|
110
|
+
if (!assertions[0].rationale.includes("12") || !assertions[0].rationale.includes("13"))
|
|
111
|
+
throw new Error("rationale should mention before and after counts");
|
|
112
|
+
});
|
|
113
|
+
test('textChanges "Inbox (5)" \u2192 "Inbox (6)" \u2192 toHaveText(...), HIGH', () => {
|
|
114
|
+
const delta = {
|
|
115
|
+
hasStructuralChange: false,
|
|
116
|
+
sectionsAdded: [],
|
|
117
|
+
sectionsRemoved: [],
|
|
118
|
+
elementsAdded: [],
|
|
119
|
+
elementsRemoved: [],
|
|
120
|
+
repeatingCountChanges: [],
|
|
121
|
+
repeatingItemsChanged: [],
|
|
122
|
+
textChanges: [
|
|
123
|
+
{
|
|
124
|
+
logicalName: "inbox_link",
|
|
125
|
+
sectionLogicalName: "sidebar",
|
|
126
|
+
role: "link",
|
|
127
|
+
accessibleName: "Inbox (6)",
|
|
128
|
+
before: "Inbox (5)",
|
|
129
|
+
after: "Inbox (6)"
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
enrichmentChanges: []
|
|
133
|
+
};
|
|
134
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
135
|
+
assertEqual(assertions.length, 1);
|
|
136
|
+
if (!assertions[0].code.includes("toHaveText('Inbox (6)')"))
|
|
137
|
+
throw new Error("code should include toHaveText with after value");
|
|
138
|
+
if (!assertions[0].code.includes("getByRole('link'"))
|
|
139
|
+
throw new Error("code should use role-based locator");
|
|
140
|
+
assertEqual(assertions[0].tier, "HIGH");
|
|
141
|
+
if (!assertions[0].rationale.includes("Inbox (5)") || !assertions[0].rationale.includes("Inbox (6)"))
|
|
142
|
+
throw new Error("rationale should mention before and after text");
|
|
143
|
+
});
|
|
144
|
+
test("elementsAdded with empty accessibleName + no testId + no stableId \u2192 skipped (zero output)", () => {
|
|
145
|
+
const delta = {
|
|
146
|
+
hasStructuralChange: true,
|
|
147
|
+
sectionsAdded: [],
|
|
148
|
+
sectionsRemoved: [],
|
|
149
|
+
elementsAdded: [
|
|
150
|
+
{
|
|
151
|
+
logicalName: "button_1",
|
|
152
|
+
sectionLogicalName: "main",
|
|
153
|
+
role: "button",
|
|
154
|
+
accessibleName: "",
|
|
155
|
+
kind: "persistent"
|
|
156
|
+
}
|
|
157
|
+
],
|
|
158
|
+
elementsRemoved: [],
|
|
159
|
+
repeatingCountChanges: [],
|
|
160
|
+
repeatingItemsChanged: [],
|
|
161
|
+
textChanges: [],
|
|
162
|
+
enrichmentChanges: []
|
|
163
|
+
};
|
|
164
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
165
|
+
assertEqual(assertions.length, 0, "should skip element with empty accessibleName");
|
|
166
|
+
});
|
|
167
|
+
test("Quote-escaping: accessibleName with quotes", () => {
|
|
168
|
+
const delta = {
|
|
169
|
+
hasStructuralChange: true,
|
|
170
|
+
sectionsAdded: [],
|
|
171
|
+
sectionsRemoved: [],
|
|
172
|
+
elementsAdded: [
|
|
173
|
+
{
|
|
174
|
+
logicalName: "greeting_btn",
|
|
175
|
+
sectionLogicalName: "main",
|
|
176
|
+
role: "button",
|
|
177
|
+
accessibleName: "He said 'hi'",
|
|
178
|
+
kind: "persistent"
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
elementsRemoved: [],
|
|
182
|
+
repeatingCountChanges: [],
|
|
183
|
+
repeatingItemsChanged: [],
|
|
184
|
+
textChanges: [],
|
|
185
|
+
enrichmentChanges: []
|
|
186
|
+
};
|
|
187
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
188
|
+
assertEqual(assertions.length, 1);
|
|
189
|
+
if (!assertions[0].code.includes("\\'"))
|
|
190
|
+
throw new Error("code should escape single quotes");
|
|
191
|
+
});
|
|
192
|
+
test("textChanges with empty accessibleName \u2192 skipped", () => {
|
|
193
|
+
const delta = {
|
|
194
|
+
hasStructuralChange: false,
|
|
195
|
+
sectionsAdded: [],
|
|
196
|
+
sectionsRemoved: [],
|
|
197
|
+
elementsAdded: [],
|
|
198
|
+
elementsRemoved: [],
|
|
199
|
+
repeatingCountChanges: [],
|
|
200
|
+
repeatingItemsChanged: [],
|
|
201
|
+
textChanges: [
|
|
202
|
+
{
|
|
203
|
+
logicalName: "icon_btn",
|
|
204
|
+
sectionLogicalName: "main",
|
|
205
|
+
role: "button",
|
|
206
|
+
accessibleName: "",
|
|
207
|
+
before: "",
|
|
208
|
+
after: "New Text"
|
|
209
|
+
}
|
|
210
|
+
],
|
|
211
|
+
enrichmentChanges: []
|
|
212
|
+
};
|
|
213
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
214
|
+
assertEqual(assertions.length, 0, "should skip textChange with empty accessibleName");
|
|
215
|
+
});
|
|
216
|
+
test("repeatingCountChanges with empty accessibleNameTemplate \u2192 skipped", () => {
|
|
217
|
+
const delta = {
|
|
218
|
+
hasStructuralChange: true,
|
|
219
|
+
sectionsAdded: [],
|
|
220
|
+
sectionsRemoved: [],
|
|
221
|
+
elementsAdded: [],
|
|
222
|
+
elementsRemoved: [],
|
|
223
|
+
repeatingCountChanges: [
|
|
224
|
+
{
|
|
225
|
+
logicalName: "item",
|
|
226
|
+
sectionLogicalName: "main",
|
|
227
|
+
role: "listitem",
|
|
228
|
+
accessibleNameTemplate: "",
|
|
229
|
+
before: 5,
|
|
230
|
+
after: 6,
|
|
231
|
+
delta: 1
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
repeatingItemsChanged: [],
|
|
235
|
+
textChanges: [],
|
|
236
|
+
enrichmentChanges: []
|
|
237
|
+
};
|
|
238
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
239
|
+
assertEqual(assertions.length, 0, "should skip repeatingCountChange with empty accessibleNameTemplate");
|
|
240
|
+
});
|
|
241
|
+
test("Mixed delta with all four kinds \u2192 all four translations appear, ordered HIGH first then MEDIUM then LOW", () => {
|
|
242
|
+
const delta = {
|
|
243
|
+
hasStructuralChange: true,
|
|
244
|
+
sectionsAdded: [],
|
|
245
|
+
sectionsRemoved: [],
|
|
246
|
+
elementsAdded: [
|
|
247
|
+
{
|
|
248
|
+
logicalName: "save_btn",
|
|
249
|
+
sectionLogicalName: "main",
|
|
250
|
+
role: "button",
|
|
251
|
+
accessibleName: "Save",
|
|
252
|
+
kind: "persistent"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
logicalName: "notification_status",
|
|
256
|
+
sectionLogicalName: "main",
|
|
257
|
+
role: "status",
|
|
258
|
+
accessibleName: "Operation complete",
|
|
259
|
+
kind: "transient"
|
|
260
|
+
}
|
|
261
|
+
],
|
|
262
|
+
elementsRemoved: [
|
|
263
|
+
{
|
|
264
|
+
logicalName: "cancel_btn",
|
|
265
|
+
sectionLogicalName: "main",
|
|
266
|
+
role: "button",
|
|
267
|
+
accessibleName: "Cancel"
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
repeatingCountChanges: [
|
|
271
|
+
{
|
|
272
|
+
logicalName: "order_row_btn",
|
|
273
|
+
sectionLogicalName: "main",
|
|
274
|
+
role: "button",
|
|
275
|
+
accessibleNameTemplate: "View details for order {orderId}",
|
|
276
|
+
before: 12,
|
|
277
|
+
after: 13,
|
|
278
|
+
delta: 1
|
|
279
|
+
}
|
|
280
|
+
],
|
|
281
|
+
repeatingItemsChanged: [],
|
|
282
|
+
textChanges: [
|
|
283
|
+
{
|
|
284
|
+
logicalName: "inbox_link",
|
|
285
|
+
sectionLogicalName: "sidebar",
|
|
286
|
+
role: "link",
|
|
287
|
+
accessibleName: "Inbox (6)",
|
|
288
|
+
before: "Inbox (5)",
|
|
289
|
+
after: "Inbox (6)"
|
|
290
|
+
}
|
|
291
|
+
],
|
|
292
|
+
enrichmentChanges: []
|
|
293
|
+
};
|
|
294
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
295
|
+
assertEqual(assertions.length, 5);
|
|
296
|
+
assertEqual(assertions[0].tier, "HIGH");
|
|
297
|
+
assertEqual(assertions[1].tier, "HIGH");
|
|
298
|
+
assertEqual(assertions[2].tier, "MEDIUM");
|
|
299
|
+
assertEqual(assertions[3].tier, "MEDIUM");
|
|
300
|
+
assertEqual(assertions[4].tier, "LOW");
|
|
301
|
+
});
|
|
302
|
+
function makeBlueprint(url, sections = []) {
|
|
303
|
+
return {
|
|
304
|
+
schemaVersion: 1,
|
|
305
|
+
url,
|
|
306
|
+
capturedAt: "2026-01-01T00:00:00Z",
|
|
307
|
+
pageHash: "0:0",
|
|
308
|
+
sections
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
test("Full capture with URL + heading \u2192 2 assertions, HIGH then LOW", () => {
|
|
312
|
+
const blueprint = makeBlueprint("http://localhost:8055/settings/schema", [
|
|
313
|
+
{
|
|
314
|
+
name: "page",
|
|
315
|
+
landmark: "main",
|
|
316
|
+
elements: [
|
|
317
|
+
{
|
|
318
|
+
logicalName: "page_title",
|
|
319
|
+
role: "heading",
|
|
320
|
+
accessibleName: "Data Model",
|
|
321
|
+
xpath: "//h1",
|
|
322
|
+
mutability: "immutable",
|
|
323
|
+
widgetType: "native",
|
|
324
|
+
framePath: [],
|
|
325
|
+
shadowRoot: false
|
|
326
|
+
}
|
|
327
|
+
],
|
|
328
|
+
repeatingElements: []
|
|
329
|
+
}
|
|
330
|
+
]);
|
|
331
|
+
const assertions = (0, import_possibleAssertions.buildFullCaptureAssertions)(blueprint);
|
|
332
|
+
assertEqual(assertions.length, 2);
|
|
333
|
+
assertEqual(assertions[0].tier, "HIGH");
|
|
334
|
+
assertEqual(
|
|
335
|
+
assertions[0].code,
|
|
336
|
+
`await expect(page).toHaveURL('http://localhost:8055/settings/schema');`
|
|
337
|
+
);
|
|
338
|
+
assertEqual(assertions[1].tier, "LOW");
|
|
339
|
+
assertEqual(
|
|
340
|
+
assertions[1].code,
|
|
341
|
+
`await expect(page.getByRole('heading', { name: 'Data Model' })).toBeVisible();`
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
test("Full capture with URL only (no headings) \u2192 1 assertion", () => {
|
|
345
|
+
const blueprint = makeBlueprint("http://localhost:8055/applications", [
|
|
346
|
+
{
|
|
347
|
+
name: "page",
|
|
348
|
+
landmark: "main",
|
|
349
|
+
elements: [
|
|
350
|
+
{
|
|
351
|
+
logicalName: "create_btn",
|
|
352
|
+
role: "button",
|
|
353
|
+
accessibleName: "Create new",
|
|
354
|
+
xpath: "//button",
|
|
355
|
+
mutability: "mutable",
|
|
356
|
+
widgetType: "native",
|
|
357
|
+
framePath: [],
|
|
358
|
+
shadowRoot: false
|
|
359
|
+
}
|
|
360
|
+
],
|
|
361
|
+
repeatingElements: []
|
|
362
|
+
}
|
|
363
|
+
]);
|
|
364
|
+
const assertions = (0, import_possibleAssertions.buildFullCaptureAssertions)(blueprint);
|
|
365
|
+
assertEqual(assertions.length, 1);
|
|
366
|
+
assertEqual(assertions[0].tier, "HIGH");
|
|
367
|
+
});
|
|
368
|
+
test("Full capture with empty URL \u2192 no assertions", () => {
|
|
369
|
+
const blueprint = makeBlueprint("", []);
|
|
370
|
+
const assertions = (0, import_possibleAssertions.buildFullCaptureAssertions)(blueprint);
|
|
371
|
+
assertEqual(assertions.length, 0);
|
|
372
|
+
});
|
|
373
|
+
test("Full capture skips heading with empty accessibleName", () => {
|
|
374
|
+
const blueprint = makeBlueprint("http://example.com/", [
|
|
375
|
+
{
|
|
376
|
+
name: "page",
|
|
377
|
+
landmark: "main",
|
|
378
|
+
elements: [
|
|
379
|
+
{
|
|
380
|
+
logicalName: "unnamed_heading",
|
|
381
|
+
role: "heading",
|
|
382
|
+
accessibleName: " ",
|
|
383
|
+
xpath: "//h1",
|
|
384
|
+
mutability: "immutable",
|
|
385
|
+
widgetType: "native",
|
|
386
|
+
framePath: [],
|
|
387
|
+
shadowRoot: false
|
|
388
|
+
}
|
|
389
|
+
],
|
|
390
|
+
repeatingElements: []
|
|
391
|
+
}
|
|
392
|
+
]);
|
|
393
|
+
const assertions = (0, import_possibleAssertions.buildFullCaptureAssertions)(blueprint);
|
|
394
|
+
assertEqual(assertions.length, 1);
|
|
395
|
+
assertEqual(assertions[0].tier, "HIGH");
|
|
396
|
+
});
|
|
397
|
+
test("Full capture finds first heading across multiple sections", () => {
|
|
398
|
+
const blueprint = makeBlueprint("http://example.com/page", [
|
|
399
|
+
{
|
|
400
|
+
name: "header",
|
|
401
|
+
landmark: "banner",
|
|
402
|
+
elements: [],
|
|
403
|
+
// no heading here
|
|
404
|
+
repeatingElements: []
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "page",
|
|
408
|
+
landmark: "main",
|
|
409
|
+
elements: [
|
|
410
|
+
{
|
|
411
|
+
logicalName: "main_heading",
|
|
412
|
+
role: "heading",
|
|
413
|
+
accessibleName: "Welcome",
|
|
414
|
+
xpath: "//h1",
|
|
415
|
+
mutability: "immutable",
|
|
416
|
+
widgetType: "native",
|
|
417
|
+
framePath: [],
|
|
418
|
+
shadowRoot: false
|
|
419
|
+
}
|
|
420
|
+
],
|
|
421
|
+
repeatingElements: []
|
|
422
|
+
}
|
|
423
|
+
]);
|
|
424
|
+
const assertions = (0, import_possibleAssertions.buildFullCaptureAssertions)(blueprint);
|
|
425
|
+
assertEqual(assertions.length, 2);
|
|
426
|
+
assertEqual(assertions[1].rationale, 'Heading visible on destination page: "Welcome"');
|
|
427
|
+
});
|
|
428
|
+
test("Full capture escapes single-quotes in URL and heading", () => {
|
|
429
|
+
const blueprint = makeBlueprint(`http://example.com/q?name='foo'`, [
|
|
430
|
+
{
|
|
431
|
+
name: "page",
|
|
432
|
+
landmark: "main",
|
|
433
|
+
elements: [
|
|
434
|
+
{
|
|
435
|
+
logicalName: "h1",
|
|
436
|
+
role: "heading",
|
|
437
|
+
accessibleName: `Bob's profile`,
|
|
438
|
+
xpath: "//h1",
|
|
439
|
+
mutability: "immutable",
|
|
440
|
+
widgetType: "native",
|
|
441
|
+
framePath: [],
|
|
442
|
+
shadowRoot: false
|
|
443
|
+
}
|
|
444
|
+
],
|
|
445
|
+
repeatingElements: []
|
|
446
|
+
}
|
|
447
|
+
]);
|
|
448
|
+
const assertions = (0, import_possibleAssertions.buildFullCaptureAssertions)(blueprint);
|
|
449
|
+
assertEqual(assertions.length, 2);
|
|
450
|
+
assertEqual(assertions[0].code.includes(`\\'`), true);
|
|
451
|
+
assertEqual(assertions[1].code.includes(`\\'`), true);
|
|
452
|
+
});
|
|
453
|
+
let passed = 0;
|
|
454
|
+
let failed = 0;
|
|
455
|
+
const failures = [];
|
|
456
|
+
for (const c of cases) {
|
|
457
|
+
try {
|
|
458
|
+
c.run();
|
|
459
|
+
passed++;
|
|
460
|
+
} catch (e) {
|
|
461
|
+
failed++;
|
|
462
|
+
failures.push({ name: c.name, error: e.message });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
console.log(`${passed}/${cases.length} passed${failed ? `, ${failed} failed` : ""}`);
|
|
466
|
+
for (const f of failures)
|
|
467
|
+
console.error(` \u2717 ${f.name}: ${f.error}`);
|
|
468
|
+
if (failed > 0) {
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
@@ -59,7 +59,7 @@ class Tab extends import_events.EventEmitter {
|
|
|
59
59
|
* same URL instead of the full payload. Cleared on tab close (not on
|
|
60
60
|
* navigation — same-URL revisits should reuse the prior blueprint).
|
|
61
61
|
*/
|
|
62
|
-
this.blueprintCache = new import_blueprintCache.BlueprintCache(
|
|
62
|
+
this.blueprintCache = new import_blueprintCache.BlueprintCache(10);
|
|
63
63
|
this.context = context;
|
|
64
64
|
this.page = page;
|
|
65
65
|
this._onPageClose = onPageClose;
|
|
@@ -25,6 +25,7 @@ var import_mcpBundle = require("playwright-core/lib/mcpBundle");
|
|
|
25
25
|
var import_tool = require("./tool");
|
|
26
26
|
var import_blueprint = require("../../../dom-analyzer/blueprint");
|
|
27
27
|
var import_blueprintDiff = require("../../../dom-analyzer/blueprintDiff");
|
|
28
|
+
var import_possibleAssertions = require("../../../dom-analyzer/possibleAssertions");
|
|
28
29
|
const blueprint = (0, import_tool.defineTabTool)({
|
|
29
30
|
capability: "core",
|
|
30
31
|
schema: {
|
|
@@ -36,13 +37,25 @@ const blueprint = (0, import_tool.defineTabTool)({
|
|
|
36
37
|
"RETURN SHAPE \u2014 two cases:",
|
|
37
38
|
"",
|
|
38
39
|
"1. First call at a URL (or after the cache has evicted the entry):",
|
|
39
|
-
' {"isFullCapture": true, "pageHash": "...", "blueprint": {...full PageBlueprint...}}',
|
|
40
|
+
' {"isFullCapture": true, "pageHash": "...", "blueprint": {...full PageBlueprint...}, "possibleAssertions": [...]}',
|
|
41
|
+
" The possibleAssertions[] for full captures is small (\u22642 entries):",
|
|
42
|
+
" a HIGH-tier toHaveURL assertion plus a LOW-tier heading visibility",
|
|
43
|
+
" check. Anchor candidates only \u2014 full deltas come from same-URL pairs.",
|
|
40
44
|
"",
|
|
41
45
|
"2. Subsequent call at the same URL:",
|
|
42
|
-
' {"isFullCapture": false, "pageHash": "...", "previousPageHash": "...", "delta": {...BlueprintDelta...}}',
|
|
46
|
+
' {"isFullCapture": false, "pageHash": "...", "previousPageHash": "...", "delta": {...BlueprintDelta...}, "possibleAssertions": [...]}',
|
|
43
47
|
" The delta is computed automatically against your most recent blueprint at this URL.",
|
|
44
48
|
" Empty arrays in delta (elementsAdded/Removed/textChanges) mean the action did not change observable DOM.",
|
|
45
49
|
"",
|
|
50
|
+
" The possibleAssertions[] here is built mechanically from the delta",
|
|
51
|
+
" (interactive element add/remove, text changes, repeating-element",
|
|
52
|
+
" counts) and tends to carry richer signal than the full-capture set.",
|
|
53
|
+
"",
|
|
54
|
+
" In both cases possibleAssertions are options the data supports, not",
|
|
55
|
+
" directives \u2014 read each, pick the ones that test something meaningful,",
|
|
56
|
+
" edit the locator if needed, and emit them as browser_assert calls.",
|
|
57
|
+
" Skip noise (e.g. timestamps).",
|
|
58
|
+
"",
|
|
46
59
|
"CAPTURE-ACT-CAPTURE: capture once before your action, perform the action, capture again \u2014 the second",
|
|
47
60
|
"response IS the diff. You only need browser_blueprint_diff when comparing across different URLs.",
|
|
48
61
|
"",
|
|
@@ -59,20 +72,24 @@ const blueprint = (0, import_tool.defineTabTool)({
|
|
|
59
72
|
const previous = tab.blueprintCache.get(url);
|
|
60
73
|
if (!previous) {
|
|
61
74
|
tab.blueprintCache.put(url, current);
|
|
75
|
+
const possibleAssertions2 = (0, import_possibleAssertions.buildFullCaptureAssertions)(current);
|
|
62
76
|
response.addTextResult(JSON.stringify({
|
|
63
77
|
isFullCapture: true,
|
|
64
78
|
pageHash: current.pageHash,
|
|
65
|
-
blueprint: current
|
|
79
|
+
blueprint: current,
|
|
80
|
+
possibleAssertions: possibleAssertions2
|
|
66
81
|
}, null, 2));
|
|
67
82
|
return;
|
|
68
83
|
}
|
|
69
84
|
const delta = (0, import_blueprintDiff.diffBlueprints)(previous, current);
|
|
85
|
+
const possibleAssertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
70
86
|
tab.blueprintCache.put(url, current);
|
|
71
87
|
response.addTextResult(JSON.stringify({
|
|
72
88
|
isFullCapture: false,
|
|
73
89
|
pageHash: current.pageHash,
|
|
74
90
|
previousPageHash: previous.pageHash,
|
|
75
|
-
delta
|
|
91
|
+
delta,
|
|
92
|
+
possibleAssertions
|
|
76
93
|
}, null, 2));
|
|
77
94
|
} catch (err) {
|
|
78
95
|
if (err instanceof import_blueprint.BlueprintInvariantError) {
|