@skyramp/mcp 0.2.0-rc.2 → 0.2.0
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/prompts/testbot/testbot-prompts.js +10 -2
- package/build/services/AnalyticsService.js +5 -1
- package/build/tools/submitReportTool.js +56 -0
- package/build/tools/submitReportTool.test.js +106 -0
- package/build/utils/telemetry.js +70 -0
- package/build/utils/telemetry.test.js +70 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +6 -5
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +19 -2
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +47 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +26 -19
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +125 -2
- package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
|
@@ -78,7 +78,15 @@ ${buildDriftAnalysisPrompt({ existingTests: [], scannedEndpoints: [], repository
|
|
|
78
78
|
- Incorrect arithmetic in business logic (discount calculations, price aggregation)
|
|
79
79
|
Log each finding in \`issuesFound\` with a \`severity\` (critical/high/medium/low). These bugs should inform your test design in Task 2.
|
|
80
80
|
|
|
81
|
-
5. **
|
|
81
|
+
5. **Blueprint Citation Invariant** (UI test recommendations only). When step 2 returned recommendations grounded in the captured blueprints from step 1, every named UI element your recommendation \`reasoning\` mentions — heading text, button label, link text, role descriptions — must correspond to an element actually present in one of those captured blueprints.
|
|
82
|
+
|
|
83
|
+
Write the \`reasoning\` field in **natural prose** that names the elements as a human would describe them ("the Notifications heading", "the disabled Mark all as read button"). Do NOT use internal-identifier syntax like \`role=button, logicalName=...\` — that jargon leaks builder internals into a user-facing report.
|
|
84
|
+
|
|
85
|
+
Self-check before submitting: for each UI recommendation's \`reasoning\`, every element you mention by name should appear in one of the captured blueprints. If an element name doesn't appear in any blueprint, either rewrite the reasoning around an element that IS captured, or drop the element reference and describe the test target in higher-level terms ("the empty state of the notifications page"). Do not invent element names from the PR description, source diff, or component name.
|
|
86
|
+
|
|
87
|
+
**Non-UI entries (contract / integration / e2e / batch-scenario) are unaffected.** Their \`reasoning\` fields use the pre-existing formats — endpoint paths, request/response schemas, fixture chains. Do not reformat them.
|
|
88
|
+
|
|
89
|
+
**No upstream captures available?** If step 1 produced no candidate URLs or \`browser_blueprint\` failed on every candidate, all UI recommendations fall back to source-grounded prose drawn from the diff alone. Log the failure mode once in \`issuesFound\`. Non-UI work is unaffected.
|
|
82
90
|
|
|
83
91
|
---`;
|
|
84
92
|
const serviceContext = services?.length ? buildServiceContext(services) : '';
|
|
@@ -323,7 +331,7 @@ Call \`skyramp_submit_report\` with \`summaryOutputFile\`: "${summaryOutputFile}
|
|
|
323
331
|
- **additionalRecommendations**: AT MOST ${maxRecommendations - maxGenerate} items.
|
|
324
332
|
- For \`testType: "contract"\` entries: **\`primaryEndpoint\` is required** (e.g. \`"GET /api/v1/users/{user_id}"\`). The tool will reject the submission without it — do not omit it or you will be forced to resubmit.
|
|
325
333
|
- For \`testType: "integration"\` or \`"e2e"\` entries: omit \`primaryEndpoint\` — use \`description\` to list the endpoints involved instead.
|
|
326
|
-
- **testMaintenance**: Use \`[]\` **only** if no existing Skyramp tests were found in the repository. If existing tests were found (any score), include one entry per test. For UPDATE/REGENERATE/DELETE tests that were modified and executed, populate all fields from real before/after execution results. For IGNORE
|
|
334
|
+
- **testMaintenance**: Use \`[]\` **only** if no existing Skyramp tests were found in the repository. If existing tests were found (any score), include one entry per test. Set \`action\` to the exact drift action you chose from the Action Decision Matrix (\`UPDATE\`, \`REGENERATE\`, \`DELETE\`, \`VERIFY\`, or \`IGNORE\`). For UPDATE/REGENERATE/DELETE tests that were modified and executed, populate all fields from real before/after execution results. For VERIFY/IGNORE tests (not modified), derive \`beforeStatus\` from the \`skyramp_analyze_test_health\` health score (typically \`"Pass"\` if drift score is 0 and no health issues were flagged), set \`afterStatus\` to \`"Skipped"\`, and use \`afterDetails\` to explain why (e.g. "IGNORE: drift score 0 — endpoint not modified in this PR"). Do **not** add entries for tests that were not returned by the health analysis.
|
|
327
335
|
|
|
328
336
|
---
|
|
329
337
|
|
|
@@ -2,7 +2,7 @@ import { pushToolEvent } from "@skyramp/skyramp";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { getEntryPoint, getCIPlatform } from "../utils/telemetry.js";
|
|
5
|
+
import { getEntryPoint, getCIPlatform, getRepositoryInfo, } from "../utils/telemetry.js";
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
7
|
export class AnalyticsService {
|
|
8
8
|
static async pushTestGenerationToolEvent(toolName, result, params) {
|
|
@@ -29,6 +29,10 @@ export class AnalyticsService {
|
|
|
29
29
|
if (ciPlatform) {
|
|
30
30
|
params.ciPlatform = ciPlatform;
|
|
31
31
|
}
|
|
32
|
+
// Prefer a repo path the tool already supplied — the MCP server's own
|
|
33
|
+
// process.cwd() is set by the IDE and may not be the user's repo.
|
|
34
|
+
const repoPath = params.repositoryPath || params.workspacePath;
|
|
35
|
+
Object.assign(params, await getRepositoryInfo(repoPath));
|
|
32
36
|
await pushToolEvent(getEntryPoint(), toolName, errorMessage, params);
|
|
33
37
|
}
|
|
34
38
|
catch (error) {
|
|
@@ -5,6 +5,15 @@ import * as path from "path";
|
|
|
5
5
|
import { AnalyticsService } from "../services/AnalyticsService.js";
|
|
6
6
|
import { TEST_CATEGORIES, externalCategory } from "../types/TestRecommendation.js";
|
|
7
7
|
import { TestType, HttpMethod } from "../types/TestTypes.js";
|
|
8
|
+
import { DriftAction } from "../types/TestAnalysis.js";
|
|
9
|
+
// Drift actions that actually modify a test file. VERIFY and IGNORE are
|
|
10
|
+
// no-ops (the test was assessed but left unchanged), so they must not count
|
|
11
|
+
// toward "tests maintained" telemetry.
|
|
12
|
+
const MAINTENANCE_CHANGE_ACTIONS = new Set([
|
|
13
|
+
DriftAction.Update,
|
|
14
|
+
DriftAction.Regenerate,
|
|
15
|
+
DriftAction.Delete,
|
|
16
|
+
]);
|
|
8
17
|
const TOOL_NAME = "skyramp_submit_report";
|
|
9
18
|
const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
|
|
10
19
|
const testResultSchema = z.object({
|
|
@@ -73,12 +82,58 @@ const testMaintenanceSchema = z.object({
|
|
|
73
82
|
testType: z.nativeEnum(TestType).describe("Type of test."),
|
|
74
83
|
endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
|
|
75
84
|
fileName: z.string().describe("Test file that was maintained, e.g. 'products_smoke_test.py'"),
|
|
85
|
+
action: z.nativeEnum(DriftAction).optional().describe("The drift action taken for this test, exactly as decided by the Action Decision Matrix: UPDATE, REGENERATE, or DELETE modify the test; VERIFY or IGNORE leave it unchanged (no-op)."),
|
|
76
86
|
description: z.string().describe("What was changed and why"),
|
|
77
87
|
beforeStatus: z.enum(["Pass", "Fail", "Error"]).describe("Test result BEFORE modification"),
|
|
78
88
|
beforeDetails: z.string().describe("Execution output/timing before modification, or 'baseline from CI workflow <name>' if a parallel workflow provided the baseline"),
|
|
79
89
|
afterStatus: z.enum(["Pass", "Fail", "Error", "Skipped"]).describe("Test result AFTER modification"),
|
|
80
90
|
afterDetails: z.string().describe("Execution output/timing after modification"),
|
|
81
91
|
});
|
|
92
|
+
/**
|
|
93
|
+
* Derive per-run analytics counts from a submitted report. These power the
|
|
94
|
+
* alpha-launch dashboards (tests generated/maintained, suite growth, bugs vs
|
|
95
|
+
* test failures, flakiness recovery). All values are stringified for Amplitude.
|
|
96
|
+
*
|
|
97
|
+
* Bug-vs-failure distinction:
|
|
98
|
+
* - issuesFound* = bugs/issues the agent flagged (real defects + severity)
|
|
99
|
+
* - testsFailed = test executions that returned "Fail" (test-level outcome)
|
|
100
|
+
* These are intentionally separate metrics — a failing test is not always a bug.
|
|
101
|
+
*
|
|
102
|
+
* maintenanceRecovered approximates flakiness/regression fixes: tests that were
|
|
103
|
+
* Fail/Error before maintenance and Pass afterward.
|
|
104
|
+
*
|
|
105
|
+
* testsMaintained counts only entries that actually changed a test file
|
|
106
|
+
* (action UPDATE/REGENERATE/DELETE). VERIFY/IGNORE entries are reported for
|
|
107
|
+
* transparency but are no-ops, so they are excluded. When `action` is absent
|
|
108
|
+
* (older reports), we fall back to the status heuristic: an IGNORE no-op sets
|
|
109
|
+
* afterStatus to "Skipped", so anything else is treated as a real change.
|
|
110
|
+
*/
|
|
111
|
+
function isMaintenanceChange(m) {
|
|
112
|
+
if (m.action) {
|
|
113
|
+
return MAINTENANCE_CHANGE_ACTIONS.has(m.action);
|
|
114
|
+
}
|
|
115
|
+
return m.afterStatus !== "Skipped";
|
|
116
|
+
}
|
|
117
|
+
function computeReportMetrics(params) {
|
|
118
|
+
const recommendations = params.additionalRecommendations ?? [];
|
|
119
|
+
const countBy = (items, pred) => items.filter(pred).length;
|
|
120
|
+
const changedMaintenance = params.testMaintenance.filter(isMaintenanceChange);
|
|
121
|
+
const maintenanceRecovered = countBy(changedMaintenance, (m) => m.beforeStatus !== "Pass" && m.afterStatus === "Pass");
|
|
122
|
+
return {
|
|
123
|
+
testsGenerated: String(params.newTestsCreated.length),
|
|
124
|
+
testsMaintained: String(changedMaintenance.length),
|
|
125
|
+
recommendationsCount: String(recommendations.length),
|
|
126
|
+
maintenanceRecovered: String(maintenanceRecovered),
|
|
127
|
+
testsPassed: String(countBy(params.testResults, (t) => t.status === "Pass")),
|
|
128
|
+
testsFailed: String(countBy(params.testResults, (t) => t.status === "Fail")),
|
|
129
|
+
testsSkipped: String(countBy(params.testResults, (t) => t.status === "Skipped")),
|
|
130
|
+
issuesFound: String(params.issuesFound.length),
|
|
131
|
+
issuesCritical: String(countBy(params.issuesFound, (i) => i.severity === "critical")),
|
|
132
|
+
issuesHigh: String(countBy(params.issuesFound, (i) => i.severity === "high")),
|
|
133
|
+
issuesMedium: String(countBy(params.issuesFound, (i) => i.severity === "medium")),
|
|
134
|
+
issuesLow: String(countBy(params.issuesFound, (i) => i.severity === "low")),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
82
137
|
export function registerSubmitReportTool(server) {
|
|
83
138
|
server.registerTool(TOOL_NAME, {
|
|
84
139
|
description: "Submit the final testbot report. Call this tool once after completing all test analysis, generation, and execution. " +
|
|
@@ -201,6 +256,7 @@ export function registerSubmitReportTool(server) {
|
|
|
201
256
|
summary_output_file: params.summaryOutputFile,
|
|
202
257
|
testResultCount: String(params.testResults.length),
|
|
203
258
|
payloadBytes: String(reportJson.length),
|
|
259
|
+
...computeReportMetrics(params),
|
|
204
260
|
}).catch(() => { });
|
|
205
261
|
}
|
|
206
262
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-ignore
|
|
2
2
|
import { registerSubmitReportTool, additionalRecommendationSchema } from "./submitReportTool.js";
|
|
3
3
|
import { TestType } from "../types/TestTypes.js";
|
|
4
|
+
import { AnalyticsService } from "../services/AnalyticsService.js";
|
|
4
5
|
import * as fs from "fs/promises";
|
|
5
6
|
import * as path from "path";
|
|
6
7
|
import * as os from "os";
|
|
@@ -10,6 +11,7 @@ jest.mock("../utils/logger.js", () => ({
|
|
|
10
11
|
jest.mock("../services/AnalyticsService.js", () => ({
|
|
11
12
|
AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
|
|
12
13
|
}));
|
|
14
|
+
const pushMCPToolEventMock = AnalyticsService.pushMCPToolEvent;
|
|
13
15
|
function captureToolHandler() {
|
|
14
16
|
let handler;
|
|
15
17
|
const fakeServer = {
|
|
@@ -290,4 +292,108 @@ describe("registerSubmitReportTool", () => {
|
|
|
290
292
|
expect(result.data.primaryEndpoint).toBeUndefined();
|
|
291
293
|
}
|
|
292
294
|
});
|
|
295
|
+
describe("analytics metrics", () => {
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
pushMCPToolEventMock.mockClear();
|
|
298
|
+
});
|
|
299
|
+
function lastAnalyticsParams() {
|
|
300
|
+
expect(pushMCPToolEventMock).toHaveBeenCalled();
|
|
301
|
+
const calls = pushMCPToolEventMock.mock.calls;
|
|
302
|
+
return calls[calls.length - 1][2];
|
|
303
|
+
}
|
|
304
|
+
it("emits per-run count metrics derived from the report", async () => {
|
|
305
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
306
|
+
tmpDirs.push(tmpDir);
|
|
307
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
308
|
+
// sampleReportParams: 1 new test, 1 maintenance (Fail→Pass), 2 results
|
|
309
|
+
// (1 Pass + 1 Fail), 1 issue (no severity).
|
|
310
|
+
await handler(sampleReportParams(outputFile));
|
|
311
|
+
const params = lastAnalyticsParams();
|
|
312
|
+
expect(params.testsGenerated).toBe("1");
|
|
313
|
+
expect(params.testsMaintained).toBe("1");
|
|
314
|
+
expect(params.recommendationsCount).toBe("0");
|
|
315
|
+
expect(params.maintenanceRecovered).toBe("1");
|
|
316
|
+
expect(params.testsPassed).toBe("1");
|
|
317
|
+
expect(params.testsFailed).toBe("1");
|
|
318
|
+
expect(params.testsSkipped).toBe("0");
|
|
319
|
+
expect(params.issuesFound).toBe("1");
|
|
320
|
+
});
|
|
321
|
+
it("breaks issues down by severity", async () => {
|
|
322
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
323
|
+
tmpDirs.push(tmpDir);
|
|
324
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
325
|
+
await handler({
|
|
326
|
+
...sampleReportParams(outputFile),
|
|
327
|
+
issuesFound: [
|
|
328
|
+
{ description: "Data corruption on checkout", severity: "critical" },
|
|
329
|
+
{ description: "Wrong total returned", severity: "high" },
|
|
330
|
+
{ description: "Another wrong total", severity: "high" },
|
|
331
|
+
{ description: "Minor gap", severity: "medium" },
|
|
332
|
+
{ description: "Unlabeled issue" },
|
|
333
|
+
],
|
|
334
|
+
});
|
|
335
|
+
const params = lastAnalyticsParams();
|
|
336
|
+
expect(params.issuesFound).toBe("5");
|
|
337
|
+
expect(params.issuesCritical).toBe("1");
|
|
338
|
+
expect(params.issuesHigh).toBe("2");
|
|
339
|
+
expect(params.issuesMedium).toBe("1");
|
|
340
|
+
expect(params.issuesLow).toBe("0");
|
|
341
|
+
});
|
|
342
|
+
it("reports zero metrics for an empty report", async () => {
|
|
343
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
344
|
+
tmpDirs.push(tmpDir);
|
|
345
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
346
|
+
await handler({
|
|
347
|
+
summaryOutputFile: outputFile,
|
|
348
|
+
businessCaseAnalysis: "Config-only change.",
|
|
349
|
+
newTestsCreated: [],
|
|
350
|
+
testMaintenance: [],
|
|
351
|
+
testResults: [],
|
|
352
|
+
issuesFound: [],
|
|
353
|
+
});
|
|
354
|
+
const params = lastAnalyticsParams();
|
|
355
|
+
expect(params.testsGenerated).toBe("0");
|
|
356
|
+
expect(params.testsMaintained).toBe("0");
|
|
357
|
+
expect(params.maintenanceRecovered).toBe("0");
|
|
358
|
+
expect(params.testsPassed).toBe("0");
|
|
359
|
+
expect(params.issuesFound).toBe("0");
|
|
360
|
+
});
|
|
361
|
+
it("testsMaintained counts only change actions, not VERIFY/IGNORE no-ops", async () => {
|
|
362
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
363
|
+
tmpDirs.push(tmpDir);
|
|
364
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
365
|
+
await handler({
|
|
366
|
+
...sampleReportParams(outputFile),
|
|
367
|
+
testMaintenance: [
|
|
368
|
+
// Real change
|
|
369
|
+
{ action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched auth", beforeStatus: "Fail", beforeDetails: "401 (1.5s)", afterStatus: "Pass", afterDetails: "passed (2.3s)" },
|
|
370
|
+
{ action: "REGENERATE", testType: TestType.SMOKE, endpoint: "GET /api/v1/orders", fileName: "orders_smoke_test.py", description: "Rewrote for new shape", beforeStatus: "Fail", beforeDetails: "shape mismatch", afterStatus: "Pass", afterDetails: "passed (1.0s)" },
|
|
371
|
+
// No-ops — assessed but not modified
|
|
372
|
+
{ action: "IGNORE", testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift score 0", afterStatus: "Skipped", afterDetails: "IGNORE: endpoint not in PR" },
|
|
373
|
+
{ action: "VERIFY", testType: TestType.SMOKE, endpoint: "GET /api/v1/coupons", fileName: "coupons_smoke_test.py", description: "Verified still valid", beforeStatus: "Pass", beforeDetails: "minor drift", afterStatus: "Skipped", afterDetails: "VERIFY: assertions still hold" },
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
const params = lastAnalyticsParams();
|
|
377
|
+
// 2 change actions (UPDATE + REGENERATE), not 4
|
|
378
|
+
expect(params.testsMaintained).toBe("2");
|
|
379
|
+
// Both changes went Fail→Pass
|
|
380
|
+
expect(params.maintenanceRecovered).toBe("2");
|
|
381
|
+
});
|
|
382
|
+
it("falls back to afterStatus heuristic when action is absent", async () => {
|
|
383
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
384
|
+
tmpDirs.push(tmpDir);
|
|
385
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
386
|
+
await handler({
|
|
387
|
+
...sampleReportParams(outputFile),
|
|
388
|
+
testMaintenance: [
|
|
389
|
+
// No action field — afterStatus !== "Skipped" → counts as a change
|
|
390
|
+
{ testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched", beforeStatus: "Fail", beforeDetails: "401", afterStatus: "Pass", afterDetails: "passed" },
|
|
391
|
+
// No action field — afterStatus === "Skipped" → treated as no-op
|
|
392
|
+
{ testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift 0", afterStatus: "Skipped", afterDetails: "not in PR" },
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
const params = lastAnalyticsParams();
|
|
396
|
+
expect(params.testsMaintained).toBe("1");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
293
399
|
});
|
package/build/utils/telemetry.js
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* "testbot" when running as part of the GitHub Action test bot,
|
|
4
4
|
* "mcp" for regular IDE/MCP usage.
|
|
5
5
|
*/
|
|
6
|
+
import { execFile } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
6
8
|
import { isTestbotEnabled } from "./featureFlags.js";
|
|
9
|
+
import { logger } from "./logger.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
7
11
|
export function getEntryPoint() {
|
|
8
12
|
return isTestbotEnabled() ? "testbot" : "mcp";
|
|
9
13
|
}
|
|
@@ -22,3 +26,69 @@ export function getCIPlatform() {
|
|
|
22
26
|
return "circleci";
|
|
23
27
|
return undefined;
|
|
24
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Parses "owner/repo" out of a git remote URL. Handles both HTTPS
|
|
31
|
+
* (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git)
|
|
32
|
+
* forms, with or without the trailing ".git". Returns undefined if no match.
|
|
33
|
+
*/
|
|
34
|
+
export function parseRepositoryFromRemoteUrl(remoteUrl) {
|
|
35
|
+
const match = remoteUrl.trim().match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
36
|
+
if (!match)
|
|
37
|
+
return undefined;
|
|
38
|
+
const [, owner, repo] = match;
|
|
39
|
+
return { repository: `${owner}/${repo}`, repositoryOwner: owner };
|
|
40
|
+
}
|
|
41
|
+
// Memoize the local git lookup per working directory so we shell out at most
|
|
42
|
+
// once per cwd for the lifetime of the MCP server process. The value is the
|
|
43
|
+
// resolved RepositoryInfo (possibly empty when not a git repo / no remote).
|
|
44
|
+
const localRepoInfoCache = new Map();
|
|
45
|
+
async function getLocalRepositoryInfo(cwd) {
|
|
46
|
+
const cached = localRepoInfoCache.get(cwd);
|
|
47
|
+
if (cached)
|
|
48
|
+
return cached;
|
|
49
|
+
const lookup = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 5_000 });
|
|
52
|
+
return parseRepositoryFromRemoteUrl(stdout) ?? {};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// Not a git repo, no "origin" remote, or git not installed — attribute
|
|
56
|
+
// nothing rather than failing the telemetry call.
|
|
57
|
+
logger.debug("Could not resolve local git repository for telemetry", {
|
|
58
|
+
cwd,
|
|
59
|
+
error: error instanceof Error ? error.message : String(error),
|
|
60
|
+
});
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
localRepoInfoCache.set(cwd, lookup);
|
|
65
|
+
return lookup;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves source-repository attribution for telemetry, segmenting usage per
|
|
69
|
+
* customer/repo.
|
|
70
|
+
*
|
|
71
|
+
* - In GitHub Actions (testbot), reads GITHUB_REPOSITORY / GITHUB_REPOSITORY_OWNER
|
|
72
|
+
* which the MCP subprocess inherits — fast, no shelling out.
|
|
73
|
+
* - In local/IDE usage, derives "owner/repo" from `git remote get-url origin`.
|
|
74
|
+
* The lookup directory is the caller-supplied `cwd` (a tool's repositoryPath
|
|
75
|
+
* or workspacePath) when available, since the MCP server's own process.cwd()
|
|
76
|
+
* is set by the IDE and is not necessarily the user's repo. Falls back to
|
|
77
|
+
* process.cwd(). Cached per directory so git is invoked at most once each.
|
|
78
|
+
*
|
|
79
|
+
* Returns an empty object when attribution can't be determined (e.g. not a git
|
|
80
|
+
* repo, no origin remote).
|
|
81
|
+
*/
|
|
82
|
+
export async function getRepositoryInfo(cwd) {
|
|
83
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
84
|
+
const info = {};
|
|
85
|
+
if (process.env.GITHUB_REPOSITORY) {
|
|
86
|
+
info.repository = process.env.GITHUB_REPOSITORY;
|
|
87
|
+
}
|
|
88
|
+
if (process.env.GITHUB_REPOSITORY_OWNER) {
|
|
89
|
+
info.repositoryOwner = process.env.GITHUB_REPOSITORY_OWNER;
|
|
90
|
+
}
|
|
91
|
+
return info;
|
|
92
|
+
}
|
|
93
|
+
return getLocalRepositoryInfo(cwd || process.cwd());
|
|
94
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getRepositoryInfo, parseRepositoryFromRemoteUrl } from "./telemetry.js";
|
|
2
|
+
describe("parseRepositoryFromRemoteUrl", () => {
|
|
3
|
+
it("parses an HTTPS remote with .git suffix", () => {
|
|
4
|
+
expect(parseRepositoryFromRemoteUrl("https://github.com/acme-corp/their-api.git")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
5
|
+
});
|
|
6
|
+
it("parses an HTTPS remote without .git suffix", () => {
|
|
7
|
+
expect(parseRepositoryFromRemoteUrl("https://github.com/acme-corp/their-api")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
8
|
+
});
|
|
9
|
+
it("parses an SSH remote", () => {
|
|
10
|
+
expect(parseRepositoryFromRemoteUrl("git@github.com:acme-corp/their-api.git")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
11
|
+
});
|
|
12
|
+
it("trims trailing whitespace/newline from git output", () => {
|
|
13
|
+
expect(parseRepositoryFromRemoteUrl("git@github.com:acme-corp/their-api.git\n")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
14
|
+
});
|
|
15
|
+
it("returns undefined for an unparseable URL", () => {
|
|
16
|
+
expect(parseRepositoryFromRemoteUrl("not-a-remote")).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("getRepositoryInfo", () => {
|
|
20
|
+
const originalEnv = { ...process.env };
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.env = { ...originalEnv };
|
|
23
|
+
});
|
|
24
|
+
describe("in GitHub Actions", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
process.env.GITHUB_ACTIONS = "true";
|
|
27
|
+
});
|
|
28
|
+
it("returns repository and owner from env vars", async () => {
|
|
29
|
+
process.env.GITHUB_REPOSITORY = "acme-corp/their-api";
|
|
30
|
+
process.env.GITHUB_REPOSITORY_OWNER = "acme-corp";
|
|
31
|
+
await expect(getRepositoryInfo()).resolves.toEqual({
|
|
32
|
+
repository: "acme-corp/their-api",
|
|
33
|
+
repositoryOwner: "acme-corp",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it("includes only the fields that are present", async () => {
|
|
37
|
+
process.env.GITHUB_REPOSITORY = "acme-corp/their-api";
|
|
38
|
+
delete process.env.GITHUB_REPOSITORY_OWNER;
|
|
39
|
+
await expect(getRepositoryInfo()).resolves.toEqual({
|
|
40
|
+
repository: "acme-corp/their-api",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("local / IDE", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
delete process.env.GITHUB_ACTIONS;
|
|
47
|
+
});
|
|
48
|
+
it("derives repository info from the local git remote", async () => {
|
|
49
|
+
// The test suite itself runs inside the mcp git repo (origin = letsramp/mcp),
|
|
50
|
+
// so a local lookup should resolve owner=letsramp, repo=mcp.
|
|
51
|
+
const info = await getRepositoryInfo();
|
|
52
|
+
expect(info.repositoryOwner).toBe("letsramp");
|
|
53
|
+
expect(info.repository).toBe("letsramp/mcp");
|
|
54
|
+
});
|
|
55
|
+
it("uses the caller-supplied cwd when provided", async () => {
|
|
56
|
+
// Passing the repo path explicitly (as tools do via repositoryPath)
|
|
57
|
+
// resolves the same remote even if process.cwd() differs.
|
|
58
|
+
const info = await getRepositoryInfo(process.cwd());
|
|
59
|
+
expect(info.repository).toBe("letsramp/mcp");
|
|
60
|
+
});
|
|
61
|
+
it("ignores GITHUB_REPOSITORY env when not in GitHub Actions", async () => {
|
|
62
|
+
// A stray env var must not be trusted outside Actions; the git remote is
|
|
63
|
+
// the source of truth locally.
|
|
64
|
+
process.env.GITHUB_REPOSITORY = "spoofed/repo";
|
|
65
|
+
process.env.GITHUB_REPOSITORY_OWNER = "spoofed";
|
|
66
|
+
const info = await getRepositoryInfo();
|
|
67
|
+
expect(info.repository).not.toBe("spoofed/repo");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -278,11 +278,12 @@ async function domEvaluationScript() {
|
|
|
278
278
|
"option"
|
|
279
279
|
]);
|
|
280
280
|
function findDescendantIconName(el) {
|
|
281
|
-
const descendants = el.querySelectorAll(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
const descendants = el.querySelectorAll(
|
|
282
|
+
'[data-icon], svg > title, use[href*="#icon-"], use[*|href*="#icon-"]'
|
|
283
|
+
);
|
|
284
|
+
const cap = Math.min(descendants.length, 6);
|
|
285
|
+
for (let i = 0; i < cap; i++) {
|
|
286
|
+
const d = descendants[i];
|
|
286
287
|
const dataIcon = d.getAttribute("data-icon");
|
|
287
288
|
if (dataIcon && dataIcon.trim())
|
|
288
289
|
return dataIcon.trim();
|
|
@@ -18,9 +18,24 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
18
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
19
|
var blueprintCache_exports = {};
|
|
20
20
|
__export(blueprintCache_exports, {
|
|
21
|
-
BlueprintCache: () => BlueprintCache
|
|
21
|
+
BlueprintCache: () => BlueprintCache,
|
|
22
|
+
DEFAULT_BLUEPRINT_CACHE_SIZE: () => DEFAULT_BLUEPRINT_CACHE_SIZE,
|
|
23
|
+
resolveBlueprintCacheSize: () => resolveBlueprintCacheSize
|
|
22
24
|
});
|
|
23
25
|
module.exports = __toCommonJS(blueprintCache_exports);
|
|
26
|
+
const DEFAULT_BLUEPRINT_CACHE_SIZE = 10;
|
|
27
|
+
function resolveBlueprintCacheSize() {
|
|
28
|
+
const raw = process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
|
|
29
|
+
if (raw === void 0 || raw === "") return DEFAULT_BLUEPRINT_CACHE_SIZE;
|
|
30
|
+
const parsed = Number.parseInt(raw, 10);
|
|
31
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
32
|
+
console.warn(
|
|
33
|
+
`SKYRAMP_BLUEPRINT_CACHE_SIZE=${raw} is invalid (expected positive integer); using default ${DEFAULT_BLUEPRINT_CACHE_SIZE}`
|
|
34
|
+
);
|
|
35
|
+
return DEFAULT_BLUEPRINT_CACHE_SIZE;
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
24
39
|
class BlueprintCache {
|
|
25
40
|
constructor(max) {
|
|
26
41
|
this.map = /* @__PURE__ */ new Map();
|
|
@@ -53,5 +68,7 @@ class BlueprintCache {
|
|
|
53
68
|
}
|
|
54
69
|
// Annotate the CommonJS export names for ESM import in node:
|
|
55
70
|
0 && (module.exports = {
|
|
56
|
-
BlueprintCache
|
|
71
|
+
BlueprintCache,
|
|
72
|
+
DEFAULT_BLUEPRINT_CACHE_SIZE,
|
|
73
|
+
resolveBlueprintCacheSize
|
|
57
74
|
});
|
|
@@ -55,3 +55,50 @@ function bp(url, pageHash) {
|
|
|
55
55
|
(0, import_vitest.expect)(c.get("http://a/")).toBeUndefined();
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
(0, import_vitest.describe)("resolveBlueprintCacheSize", () => {
|
|
59
|
+
let originalEnv;
|
|
60
|
+
let warnSpy;
|
|
61
|
+
(0, import_vitest.beforeEach)(() => {
|
|
62
|
+
originalEnv = process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
|
|
63
|
+
warnSpy = import_vitest.vi.spyOn(console, "warn").mockImplementation(() => {
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
(0, import_vitest.afterEach)(() => {
|
|
67
|
+
if (originalEnv === void 0)
|
|
68
|
+
delete process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
|
|
69
|
+
else
|
|
70
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = originalEnv;
|
|
71
|
+
warnSpy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
(0, import_vitest.it)("returns the default when env var is unset", () => {
|
|
74
|
+
delete process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE;
|
|
75
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
|
|
76
|
+
});
|
|
77
|
+
(0, import_vitest.it)("returns the default when env var is empty", () => {
|
|
78
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "";
|
|
79
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
|
|
80
|
+
});
|
|
81
|
+
(0, import_vitest.it)("parses a valid positive integer override", () => {
|
|
82
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "25";
|
|
83
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(25);
|
|
84
|
+
});
|
|
85
|
+
(0, import_vitest.it)("parses minimum value 1", () => {
|
|
86
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "1";
|
|
87
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
(0, import_vitest.it)("warns and falls back to default for non-numeric values", () => {
|
|
90
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "not-a-number";
|
|
91
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
|
|
92
|
+
(0, import_vitest.expect)(warnSpy).toHaveBeenCalledOnce();
|
|
93
|
+
});
|
|
94
|
+
(0, import_vitest.it)("warns and falls back to default for zero", () => {
|
|
95
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "0";
|
|
96
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
|
|
97
|
+
(0, import_vitest.expect)(warnSpy).toHaveBeenCalledOnce();
|
|
98
|
+
});
|
|
99
|
+
(0, import_vitest.it)("warns and falls back to default for negative values", () => {
|
|
100
|
+
process.env.SKYRAMP_BLUEPRINT_CACHE_SIZE = "-5";
|
|
101
|
+
(0, import_vitest.expect)((0, import_blueprintCache.resolveBlueprintCacheSize)()).toBe(import_blueprintCache.DEFAULT_BLUEPRINT_CACHE_SIZE);
|
|
102
|
+
(0, import_vitest.expect)(warnSpy).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -37,11 +37,19 @@ const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
|
37
37
|
"dialog",
|
|
38
38
|
"alertdialog"
|
|
39
39
|
]);
|
|
40
|
+
const LIVE_REGION_ROLES = /* @__PURE__ */ new Set([
|
|
41
|
+
"alert",
|
|
42
|
+
"status",
|
|
43
|
+
"log",
|
|
44
|
+
"marquee",
|
|
45
|
+
"timer"
|
|
46
|
+
]);
|
|
40
47
|
function escapeForSingleQuote(s) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
48
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/[\r\n]+/g, " ");
|
|
49
|
+
}
|
|
50
|
+
function truncateForDisplay(s, max = 80) {
|
|
51
|
+
if (s.length <= max) return s;
|
|
52
|
+
return s.slice(0, max - 1) + "\u2026";
|
|
45
53
|
}
|
|
46
54
|
function buildPossibleAssertions(delta) {
|
|
47
55
|
const results = [];
|
|
@@ -53,14 +61,18 @@ function buildPossibleAssertions(delta) {
|
|
|
53
61
|
const code = `await expect(page.getByRole('${el.role}', { name: '${escapedName}' })).toBeVisible();`;
|
|
54
62
|
let rationale;
|
|
55
63
|
let tier;
|
|
64
|
+
const displayName = truncateForDisplay(el.accessibleName);
|
|
56
65
|
if (INTERACTIVE_ROLES.has(el.role)) {
|
|
57
|
-
rationale = `Element added to DOM after action: ${el.role} "${
|
|
66
|
+
rationale = `Element added to DOM after action: ${el.role} "${displayName}"`;
|
|
58
67
|
tier = "MEDIUM";
|
|
59
68
|
} else if (el.role === "heading") {
|
|
60
|
-
rationale = `Heading appeared after action: "${
|
|
69
|
+
rationale = `Heading appeared after action: "${displayName}"`;
|
|
61
70
|
tier = "MEDIUM";
|
|
71
|
+
} else if (LIVE_REGION_ROLES.has(el.role)) {
|
|
72
|
+
rationale = `${el.role} live region appeared: "${displayName}"`;
|
|
73
|
+
tier = "LOW";
|
|
62
74
|
} else {
|
|
63
|
-
rationale = `${el.role}
|
|
75
|
+
rationale = `${el.role} appeared after action: "${displayName}"`;
|
|
64
76
|
tier = "LOW";
|
|
65
77
|
}
|
|
66
78
|
results.push({ code, rationale, tier });
|
|
@@ -69,15 +81,10 @@ function buildPossibleAssertions(delta) {
|
|
|
69
81
|
if (!el.accessibleName.trim()) continue;
|
|
70
82
|
const escapedName = escapeForSingleQuote(el.accessibleName);
|
|
71
83
|
const code = `await expect(page.getByRole('${el.role}', { name: '${escapedName}' })).not.toBeVisible();`;
|
|
72
|
-
|
|
73
|
-
if (INTERACTIVE_ROLES.has(el.role)) {
|
|
74
|
-
tier = "MEDIUM";
|
|
75
|
-
} else {
|
|
76
|
-
tier = "LOW";
|
|
77
|
-
}
|
|
84
|
+
const tier = INTERACTIVE_ROLES.has(el.role) ? "MEDIUM" : "LOW";
|
|
78
85
|
results.push({
|
|
79
86
|
code,
|
|
80
|
-
rationale: `Element removed from DOM after action: ${el.role} "${el.accessibleName}"`,
|
|
87
|
+
rationale: `Element removed from DOM after action: ${el.role} "${truncateForDisplay(el.accessibleName)}"`,
|
|
81
88
|
tier
|
|
82
89
|
});
|
|
83
90
|
}
|
|
@@ -89,15 +96,15 @@ function buildPossibleAssertions(delta) {
|
|
|
89
96
|
const code = `await expect(page.getByRole('${tc.role}', { name: '${escapedAccessibleName}' })).toHaveText('${escapedAfter}');`;
|
|
90
97
|
results.push({
|
|
91
98
|
code,
|
|
92
|
-
rationale: `Text changed: "${tc.before}" \u2192 "${tc.after}"`,
|
|
99
|
+
rationale: `Text changed: "${truncateForDisplay(tc.before)}" \u2192 "${truncateForDisplay(tc.after)}"`,
|
|
93
100
|
tier: "HIGH"
|
|
94
101
|
});
|
|
95
102
|
}
|
|
96
103
|
for (const rc of delta.repeatingCountChanges) {
|
|
97
104
|
if (rc.before === rc.after) continue;
|
|
98
105
|
if (!rc.accessibleNameTemplate.trim()) continue;
|
|
99
|
-
const
|
|
100
|
-
const code = `await expect(page.getByRole('${rc.role}', { name: ${
|
|
106
|
+
const regexExpr = templateToRegex(rc.accessibleNameTemplate);
|
|
107
|
+
const code = `await expect(page.getByRole('${rc.role}', { name: ${regexExpr} })).toHaveCount(${rc.after});`;
|
|
101
108
|
results.push({
|
|
102
109
|
code,
|
|
103
110
|
rationale: `Repeating element count changed: ${rc.before} \u2192 ${rc.after}`,
|
|
@@ -139,9 +146,9 @@ function findFirstHeading(blueprint) {
|
|
|
139
146
|
return null;
|
|
140
147
|
}
|
|
141
148
|
function templateToRegex(template) {
|
|
142
|
-
let pattern = template.replace(/[.+*?^$()|[\]
|
|
149
|
+
let pattern = template.replace(/[.+*?^$()|[\]\\\/]/g, "\\$&");
|
|
143
150
|
pattern = pattern.replace(/\{[a-zA-Z0-9_]+\}/g, ".+");
|
|
144
|
-
return
|
|
151
|
+
return `new RegExp('${escapeForSingleQuote(`^${pattern}$`)}', 'i')`;
|
|
145
152
|
}
|
|
146
153
|
// Annotate the CommonJS export names for ESM import in node:
|
|
147
154
|
0 && (module.exports = {
|
|
@@ -104,8 +104,8 @@ test("repeatingCountChanges 12 \u2192 13 \u2192 toHaveCount(13), HIGH", () => {
|
|
|
104
104
|
assertEqual(assertions.length, 1);
|
|
105
105
|
if (!assertions[0].code.includes("toHaveCount(13)"))
|
|
106
106
|
throw new Error("code should include toHaveCount(13)");
|
|
107
|
-
if (!assertions[0].code.includes(
|
|
108
|
-
throw new Error("code should include
|
|
107
|
+
if (!assertions[0].code.includes(`new RegExp('^View details for order .+$', 'i')`))
|
|
108
|
+
throw new Error("code should include RegExp constructor with template-derived pattern");
|
|
109
109
|
assertEqual(assertions[0].tier, "HIGH");
|
|
110
110
|
if (!assertions[0].rationale.includes("12") || !assertions[0].rationale.includes("13"))
|
|
111
111
|
throw new Error("rationale should mention before and after counts");
|
|
@@ -450,6 +450,129 @@ test("Full capture escapes single-quotes in URL and heading", () => {
|
|
|
450
450
|
assertEqual(assertions[0].code.includes(`\\'`), true);
|
|
451
451
|
assertEqual(assertions[1].code.includes(`\\'`), true);
|
|
452
452
|
});
|
|
453
|
+
test("long accessibleName is preserved in generated code, not truncated", () => {
|
|
454
|
+
const longName = "a".repeat(150);
|
|
455
|
+
const delta = {
|
|
456
|
+
hasStructuralChange: true,
|
|
457
|
+
sectionsAdded: [],
|
|
458
|
+
sectionsRemoved: [],
|
|
459
|
+
elementsAdded: [{
|
|
460
|
+
logicalName: "long_btn",
|
|
461
|
+
sectionLogicalName: "main",
|
|
462
|
+
role: "button",
|
|
463
|
+
accessibleName: longName
|
|
464
|
+
}],
|
|
465
|
+
elementsRemoved: [],
|
|
466
|
+
repeatingCountChanges: [],
|
|
467
|
+
repeatingItemsChanged: [],
|
|
468
|
+
textChanges: [],
|
|
469
|
+
enrichmentChanges: []
|
|
470
|
+
};
|
|
471
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
472
|
+
assertEqual(assertions.length, 1);
|
|
473
|
+
if (!assertions[0].code.includes(longName))
|
|
474
|
+
throw new Error("full accessibleName must appear in generated code");
|
|
475
|
+
if (assertions[0].rationale.length > 200)
|
|
476
|
+
throw new Error("rationale should be truncated for display");
|
|
477
|
+
});
|
|
478
|
+
test("long text-change values preserved in toHaveText, truncated in rationale", () => {
|
|
479
|
+
const longAfter = "b".repeat(120);
|
|
480
|
+
const delta = {
|
|
481
|
+
hasStructuralChange: false,
|
|
482
|
+
sectionsAdded: [],
|
|
483
|
+
sectionsRemoved: [],
|
|
484
|
+
elementsAdded: [],
|
|
485
|
+
elementsRemoved: [],
|
|
486
|
+
repeatingCountChanges: [],
|
|
487
|
+
repeatingItemsChanged: [],
|
|
488
|
+
textChanges: [{
|
|
489
|
+
logicalName: "msg",
|
|
490
|
+
sectionLogicalName: "main",
|
|
491
|
+
role: "status",
|
|
492
|
+
accessibleName: "Status",
|
|
493
|
+
before: "short",
|
|
494
|
+
after: longAfter
|
|
495
|
+
}],
|
|
496
|
+
enrichmentChanges: []
|
|
497
|
+
};
|
|
498
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
499
|
+
assertEqual(assertions.length, 1);
|
|
500
|
+
if (!assertions[0].code.includes(longAfter))
|
|
501
|
+
throw new Error("full after-text must appear in toHaveText() argument");
|
|
502
|
+
});
|
|
503
|
+
test("templateToRegex emits new RegExp(...) so slashes in the template do not break the literal", () => {
|
|
504
|
+
const delta = {
|
|
505
|
+
hasStructuralChange: true,
|
|
506
|
+
sectionsAdded: [],
|
|
507
|
+
sectionsRemoved: [],
|
|
508
|
+
elementsAdded: [],
|
|
509
|
+
elementsRemoved: [],
|
|
510
|
+
repeatingCountChanges: [{
|
|
511
|
+
logicalName: "page_link",
|
|
512
|
+
sectionLogicalName: "main",
|
|
513
|
+
role: "link",
|
|
514
|
+
accessibleNameTemplate: "A/B page {n}",
|
|
515
|
+
before: 0,
|
|
516
|
+
after: 3,
|
|
517
|
+
delta: 3
|
|
518
|
+
}],
|
|
519
|
+
repeatingItemsChanged: [],
|
|
520
|
+
textChanges: [],
|
|
521
|
+
enrichmentChanges: []
|
|
522
|
+
};
|
|
523
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
524
|
+
assertEqual(assertions.length, 1);
|
|
525
|
+
if (!assertions[0].code.includes(`new RegExp('^A\\\\/B page .+$', 'i')`))
|
|
526
|
+
throw new Error(
|
|
527
|
+
"code should use new RegExp(...) constructor with escaped slash; got:\n" + assertions[0].code
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
test('non-interactive non-heading roles get role-agnostic rationale (not "live region")', () => {
|
|
531
|
+
const delta = {
|
|
532
|
+
hasStructuralChange: true,
|
|
533
|
+
sectionsAdded: [],
|
|
534
|
+
sectionsRemoved: [],
|
|
535
|
+
elementsAdded: [{
|
|
536
|
+
logicalName: "embed",
|
|
537
|
+
sectionLogicalName: "main",
|
|
538
|
+
role: "figure",
|
|
539
|
+
accessibleName: "Embedded preview"
|
|
540
|
+
}],
|
|
541
|
+
elementsRemoved: [],
|
|
542
|
+
repeatingCountChanges: [],
|
|
543
|
+
repeatingItemsChanged: [],
|
|
544
|
+
textChanges: [],
|
|
545
|
+
enrichmentChanges: []
|
|
546
|
+
};
|
|
547
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
548
|
+
assertEqual(assertions.length, 1);
|
|
549
|
+
if (assertions[0].rationale.includes("live region"))
|
|
550
|
+
throw new Error('rationale should not call non-live-region roles "live region"');
|
|
551
|
+
if (!assertions[0].rationale.startsWith("figure appeared"))
|
|
552
|
+
throw new Error('rationale should be role-agnostic ("figure appeared..."); got: ' + assertions[0].rationale);
|
|
553
|
+
});
|
|
554
|
+
test('genuine live-region roles (status/alert) keep the "live region" rationale', () => {
|
|
555
|
+
const delta = {
|
|
556
|
+
hasStructuralChange: true,
|
|
557
|
+
sectionsAdded: [],
|
|
558
|
+
sectionsRemoved: [],
|
|
559
|
+
elementsAdded: [{
|
|
560
|
+
logicalName: "toast",
|
|
561
|
+
sectionLogicalName: "main",
|
|
562
|
+
role: "status",
|
|
563
|
+
accessibleName: "Saved"
|
|
564
|
+
}],
|
|
565
|
+
elementsRemoved: [],
|
|
566
|
+
repeatingCountChanges: [],
|
|
567
|
+
repeatingItemsChanged: [],
|
|
568
|
+
textChanges: [],
|
|
569
|
+
enrichmentChanges: []
|
|
570
|
+
};
|
|
571
|
+
const assertions = (0, import_possibleAssertions.buildPossibleAssertions)(delta);
|
|
572
|
+
assertEqual(assertions.length, 1);
|
|
573
|
+
if (!assertions[0].rationale.includes("live region"))
|
|
574
|
+
throw new Error('status role should keep the "live region" rationale; got: ' + assertions[0].rationale);
|
|
575
|
+
});
|
|
453
576
|
let passed = 0;
|
|
454
577
|
let failed = 0;
|
|
455
578
|
const failures = [];
|
|
@@ -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((0, import_blueprintCache.resolveBlueprintCacheSize)());
|
|
63
63
|
this.context = context;
|
|
64
64
|
this.page = page;
|
|
65
65
|
this._onPageClose = onPageClose;
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyramp/mcp",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./build/index.js",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"js-yaml": "^4.1.1",
|
|
62
62
|
"playwright": "file:vendor/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz",
|
|
63
63
|
"simple-git": "^3.30.0",
|
|
64
|
+
"typescript": "^5.8.3",
|
|
64
65
|
"zod": "^3.25.3"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
@@ -73,8 +74,7 @@
|
|
|
73
74
|
"@typescript-eslint/parser": "^8.0.0",
|
|
74
75
|
"eslint": "^9.0.0",
|
|
75
76
|
"jest": "^29.7.0",
|
|
76
|
-
"ts-jest": "^29.3.4"
|
|
77
|
-
"typescript": "^5.8.3"
|
|
77
|
+
"ts-jest": "^29.3.4"
|
|
78
78
|
},
|
|
79
79
|
"engines": {
|
|
80
80
|
"node": ">=18"
|