@skyramp/mcp 0.3.7 → 0.3.9-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.
Files changed (69) hide show
  1. package/build/playwright/registerPlaywrightTools.js +1 -0
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +22 -1
  3. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  4. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  5. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +4 -1
  6. package/build/prompts/test-recommendation/diffExecutionPlan.js +41 -9
  7. package/build/prompts/test-recommendation/fullRepoCatalog.js +3 -2
  8. package/build/prompts/test-recommendation/recommendationSections.js +3 -3
  9. package/build/prompts/test-recommendation/scopeAssessment.d.ts +2 -2
  10. package/build/prompts/test-recommendation/scopeAssessment.js +58 -76
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.js +14 -1
  12. package/build/prompts/testbot/testbot-prompts.js +35 -15
  13. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +39 -5
  14. package/build/recommendation/budgeters/shared.d.ts +21 -8
  15. package/build/recommendation/budgeters/shared.js +134 -56
  16. package/build/recommendation/planRanker.d.ts +5 -3
  17. package/build/recommendation/planRanker.js +1 -1
  18. package/build/services/containerEnv.d.ts +1 -1
  19. package/build/services/containerEnv.js +12 -0
  20. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  21. package/build/tools/executeSkyrampTestTool.js +246 -19
  22. package/build/tools/submitReportTool.d.ts +5 -5
  23. package/build/tools/submitReportTool.js +11 -2
  24. package/build/tools/test-management/actionsTool.js +71 -3
  25. package/build/tools/test-management/analyzeChangesTool.d.ts +10 -0
  26. package/build/tools/test-management/analyzeChangesTool.js +73 -0
  27. package/build/tools/test-management/registerTestPlanTool.d.ts +3 -3
  28. package/build/tools/test-management/registerTestPlanTool.js +29 -8
  29. package/build/types/RepositoryAnalysis.d.ts +10 -10
  30. package/build/types/TestAnalysis.d.ts +12 -0
  31. package/build/types/TestExecution.d.ts +4 -0
  32. package/build/types/TestRecommendation.d.ts +11 -1
  33. package/build/types/TestRecommendation.js +34 -0
  34. package/build/utils/AnalysisStateManager.d.ts +7 -0
  35. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  36. package/build/utils/assertion-verify/ui-lints.js +32 -0
  37. package/build/utils/frontendSelectors.d.ts +33 -0
  38. package/build/utils/frontendSelectors.js +196 -5
  39. package/build/utils/pathSignatures.d.ts +4 -1
  40. package/build/utils/pathSignatures.js +14 -2
  41. package/build/utils/planOnlyMode.d.ts +33 -0
  42. package/build/utils/planOnlyMode.js +40 -0
  43. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  44. package/build/utils/rebaselineSnapshots.js +65 -0
  45. package/build/utils/removedUiElements.d.ts +34 -0
  46. package/build/utils/removedUiElements.js +153 -0
  47. package/build/utils/reportVerification.d.ts +7 -6
  48. package/build/utils/reportVerification.js +68 -4
  49. package/build/utils/scenarioDrafting.d.ts +1 -1
  50. package/build/utils/scenarioDrafting.js +1 -1
  51. package/build/utils/versions.d.ts +3 -3
  52. package/build/utils/versions.js +1 -1
  53. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  54. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  55. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  56. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  57. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  58. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  59. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  60. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  61. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  62. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  63. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  64. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  65. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  67. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  68. package/node_modules/playwright/package.json +1 -1
  69. package/package.json +3 -3
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Removed UI elements (SKYR-4305) — grounding for the removal guard.
3
+ *
4
+ * The recommendation prompt already requires a candidate for every element a PR
5
+ * removes ("the removal is what this PR ships, so the removal is what gets tested").
6
+ * A blueprint can never contain a removed element, so this module supplies what the
7
+ * agent otherwise has to guess: WHICH identifying `data-*` attributes (`data-testid`,
8
+ * `data-cy`, `data-qa`, `data-test-id`, or another static `data-*`) the diff removed and
9
+ * on WHICH page they used to render. `data-testid` is asserted with `getByTestId`; every
10
+ * other attribute with `page.locator('[<attribute>="<value>"]')`, so the guard never
11
+ * silently resolves against the wrong attribute. It drafts nothing and gates nothing — it reduces noise:
12
+ *
13
+ * - an id is reported only when its file survives the change (not in
14
+ * `deletedFiles`) — a deleted route/component is maintenance of the tests that
15
+ * covered it, not a page to assert on;
16
+ * - a file the integration check marked `integrated: false` is skipped; when the
17
+ * check produced no record for a file (it did not run, or failed), the file is
18
+ * kept — fail-open, logged at debug;
19
+ * - renames, moves and extractions to another file are excluded by
20
+ * `extractRemovedAttributes` (added values are collected across the WHOLE diff);
21
+ * - a value an existing discovered test already references is skipped — that test is
22
+ * the one to update or delete, a new guard would duplicate it. The match is on the
23
+ * VALUE regardless of attribute (a test naming `user-menu-row` as a testid also
24
+ * suppresses a `data-section="user-menu-row"` guard) — deliberate over-suppression,
25
+ * the safe direction, even though entries are keyed on attribute + value elsewhere. When discovery
26
+ * itself failed the list is empty and nothing is reported (fail-closed), because
27
+ * "no test references it" cannot be known;
28
+ * - the page comes from `candidateUiPages` sourced from the file, or one hop via
29
+ * the files that import it (a shared navbar maps to no route itself). This is a
30
+ * static import-graph attribution, not evidence the element rendered there —
31
+ * which is why the playbook demands a positive assertion on a retained element
32
+ * of the same page next to every `toBeHidden()`.
33
+ *
34
+ * Every skip is logged at debug with its reason so a missing guard is diagnosable.
35
+ */
36
+ import * as fs from "fs";
37
+ import { extractAddedAttributeValues, extractRemovedAttributes } from "./frontendSelectors.js";
38
+ import { escapeRegExp } from "./regex.js";
39
+ import { logger } from "./logger.js";
40
+ /**
41
+ * Bound the list: each entry is a guard assertion the agent records on one page, and
42
+ * the plan budget is small (`MAX_TESTS_TO_GENERATE`), so a large cleanup PR gets its
43
+ * first few ids, not a flood. Truncation is logged at info with the dropped count.
44
+ */
45
+ export const MAX_REMOVED_UI_ELEMENTS = 5;
46
+ function defaultReadFile(absPath) {
47
+ try {
48
+ return fs.readFileSync(absPath, "utf8");
49
+ }
50
+ catch (err) {
51
+ logger.warning("removedUiElements: could not read existing test (treated as not referencing any removed id)", {
52
+ file: absPath,
53
+ error: err instanceof Error ? err.message : String(err),
54
+ });
55
+ return undefined;
56
+ }
57
+ }
58
+ /**
59
+ * Whole-literal match: `order-total` must not be satisfied by `order-total-row`, and
60
+ * `foo:bar` not by `foo:bar:baz`. The identifier alphabet mirrors the extractor's
61
+ * value grammar (`[\w:-]`), so every character a value may contain is a
62
+ * non-boundary here.
63
+ */
64
+ function referencesLiteral(content, value) {
65
+ return new RegExp(`(^|[^\\w:-])${escapeRegExp(value)}(?![\\w:-])`).test(content);
66
+ }
67
+ export function collectRemovedUiElements(input) {
68
+ if (input.discoveryFailed) {
69
+ logger.warning("removedUiElements: test discovery failed — cannot tell whether an existing test covers a removed id; reporting none");
70
+ return [];
71
+ }
72
+ const readFile = input.readFile ?? defaultReadFile;
73
+ const deleted = new Set(input.deletedFiles);
74
+ const integration = new Map((input.frontendFileIntegration ?? []).map((r) => [r.file, r]));
75
+ const skip = (file, reason, extra = {}) => logger.debug(`removedUiElements: skipped ${file} — ${reason}`, extra);
76
+ // Added values across the ENTIRE diff, so an element extracted into another file is a
77
+ // move, not a removal.
78
+ const addedAcrossDiff = new Set();
79
+ for (const slice of input.diffByFile.values()) {
80
+ for (const v of extractAddedAttributeValues(slice))
81
+ addedAcrossDiff.add(v);
82
+ }
83
+ const pagesSourcedFrom = (files) => {
84
+ const urls = new Set();
85
+ for (const p of input.candidateUiPages) {
86
+ if (files.some((f) => p.sourcedFrom.includes(f)))
87
+ urls.add(p.url);
88
+ }
89
+ return [...urls];
90
+ };
91
+ // Existing-test contents are read lazily and once: most PRs remove nothing.
92
+ let testContents;
93
+ const referencedByExistingTest = (value) => {
94
+ if (!testContents) {
95
+ testContents = input.existingTestFiles
96
+ .map((f) => readFile(f) ?? "")
97
+ .filter((c) => c.length > 0);
98
+ }
99
+ return testContents.some((c) => referencesLiteral(c, value));
100
+ };
101
+ const out = [];
102
+ const seen = new Set();
103
+ let dropped = 0;
104
+ for (const file of input.changedFrontendFiles) {
105
+ if (deleted.has(file)) {
106
+ skip(file, "file deleted by the diff (whole surface removed)");
107
+ continue;
108
+ }
109
+ const integ = integration.get(file);
110
+ if (integ && !integ.integrated) {
111
+ skip(file, "no production importer", { reason: integ.reason });
112
+ continue;
113
+ }
114
+ if (!integ && input.frontendFileIntegration)
115
+ skip(file, "no integration record — kept (fail-open)");
116
+ const slice = input.diffByFile.get(file);
117
+ if (!slice) {
118
+ skip(file, "no diff slice");
119
+ continue;
120
+ }
121
+ const attrs = extractRemovedAttributes(slice, addedAcrossDiff);
122
+ if (attrs.length === 0)
123
+ continue;
124
+ let pageUrls = pagesSourcedFrom([file]);
125
+ if (pageUrls.length === 0 && integ?.importers?.length)
126
+ pageUrls = pagesSourcedFrom(integ.importers);
127
+ if (pageUrls.length === 0) {
128
+ skip(file, "no candidate page sourced from the file or its importers", { removed: attrs.map((a) => `${a.attribute}="${a.value}"`) });
129
+ continue;
130
+ }
131
+ for (const { attribute, value } of attrs) {
132
+ const key = `${attribute}=${value}`;
133
+ if (seen.has(key))
134
+ continue;
135
+ if (referencedByExistingTest(value)) {
136
+ skip(file, `${attribute}="${value}" already referenced by an existing test (maintenance, not a guard)`);
137
+ continue;
138
+ }
139
+ // Mark the key seen whether reported or dropped, so a repeat of a dropped
140
+ // element is not counted twice in the cap log.
141
+ seen.add(key);
142
+ if (out.length >= MAX_REMOVED_UI_ELEMENTS) {
143
+ dropped++;
144
+ continue;
145
+ }
146
+ out.push({ attribute, value, file, pageUrls });
147
+ }
148
+ }
149
+ if (dropped > 0) {
150
+ logger.info(`removedUiElements: cap reached — ${dropped} removed element(s) not reported`, { cap: MAX_REMOVED_UI_ELEMENTS });
151
+ }
152
+ return out;
153
+ }
@@ -1,4 +1,4 @@
1
- import { DriftAction } from "../types/TestAnalysis.js";
1
+ import { MaintenanceActionCore } from "../types/TestAnalysis.js";
2
2
  /**
3
3
  * Enumerate every path that differs from HEAD in the working tree — staged,
4
4
  * unstaged, or untracked — as repo-relative paths, via `git status --porcelain`.
@@ -42,14 +42,15 @@ export interface UnchangedFileClaimsInput {
42
42
  }[];
43
43
  /** Maintenance verdicts (server-derived from skyramp_actions). `pomFile`, when set, is
44
44
  * the file the edit actually lands in — see the UPDATE rule below. */
45
- verdicts: {
46
- action: DriftAction;
47
- testFilePath: string;
48
- pomFile?: string;
49
- }[];
45
+ verdicts: Pick<MaintenanceActionCore, "action" | "testFilePath" | "pomFile" | "rebaselineSnapshots" | "rebaselineOnly">[];
50
46
  /** owner/repo of the primary repo — used to exempt cross-repo newTest claims
51
47
  * whose files live in a different checkout. */
52
48
  primaryRepository?: string;
49
+ /** SKYR-4250: a plan-only eval run generates nothing — `newTestsCreated`
50
+ * declares the approved GENERATE list — so no declaration can be backed by
51
+ * a working-tree change. Exempts the declaration half only; a maintenance
52
+ * UPDATE still claims a real edit and is still verified. */
53
+ planOnly?: boolean;
53
54
  }
54
55
  /**
55
56
  * Cross-check report claims against files that actually changed in the working
@@ -1,9 +1,11 @@
1
1
  import path from "path";
2
2
  import * as fs from "fs/promises";
3
+ import { existsSync } from "fs";
3
4
  import { execFile } from "child_process";
4
5
  import { promisify } from "util";
5
6
  import { testFileMatches } from "./utils.js";
6
7
  import { DriftAction } from "../types/TestAnalysis.js";
8
+ import { baselineFileMatchesStem, baselineStem } from "./rebaselineSnapshots.js";
7
9
  const execFileAsync = promisify(execFile);
8
10
  /**
9
11
  * Enumerate every path that differs from HEAD in the working tree — staged,
@@ -124,7 +126,7 @@ export async function listChangedFiles(repoRoot) {
124
126
  * absolute path resolving outside `repoRoot`) — a different checkout owns them.
125
127
  */
126
128
  export function findUnchangedFileClaims(input) {
127
- const { repoRoot, changedFiles, newTests, verdicts, primaryRepository } = input;
129
+ const { repoRoot, changedFiles, newTests, verdicts, primaryRepository, planOnly, } = input;
128
130
  const unbacked = [];
129
131
  const isBacked = (claimedPath) => {
130
132
  let candidate = claimedPath;
@@ -137,7 +139,46 @@ export function findUnchangedFileClaims(input) {
137
139
  }
138
140
  return changedFiles.some((c) => testFileMatches(c, candidate));
139
141
  };
140
- for (const t of newTests) {
142
+ // A visual-baseline UPDATE (SKYR-4298) edits no source: SmartPlaywright rewrites
143
+ // the PNG Playwright keeps beside the spec, `<spec>-snapshots/<name>-<project>-
144
+ // <platform>.png` (the default layout — see baselineFileMatchesStem for why that
145
+ // is safe to assume). Backed when one of the named baselines changed there AND
146
+ // still exists: `git status` lists a deleted file as changed too, and a destroyed
147
+ // baseline is the opposite of a refreshed one. Scoped to the spec's own snapshot
148
+ // directory so a refreshed baseline of some other spec cannot vouch for this one.
149
+ const isBackedBySnapshot = (specPath, baselines) => {
150
+ let specRel = specPath;
151
+ if (path.isAbsolute(specPath)) {
152
+ const rel = path.relative(repoRoot, specPath);
153
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
154
+ return true;
155
+ specRel = rel;
156
+ }
157
+ // git reports paths with "/" on every platform; path.relative uses the host
158
+ // separator, so a Windows run would otherwise never match its own snapshot dir.
159
+ specRel = specRel.split(path.sep).join("/");
160
+ const snapshotDir = `${specRel}-snapshots/`;
161
+ const stems = baselines.map(baselineStem).filter(Boolean);
162
+ return changedFiles.some((c) => {
163
+ // Same anchoring as testFileMatches: a path-qualified spec must match as a
164
+ // path segment; a bare basename can only match by directory basename.
165
+ const inDir = specRel.includes("/")
166
+ ? c.startsWith(snapshotDir) || c.includes("/" + snapshotDir)
167
+ : path.basename(path.dirname(c)) === snapshotDir.slice(0, -1);
168
+ if (!inDir)
169
+ return false;
170
+ const base = path.basename(c);
171
+ if (!stems.some((stem) => baselineFileMatchesStem(base, stem)))
172
+ return false;
173
+ return existsSync(path.resolve(repoRoot, c));
174
+ });
175
+ };
176
+ // A plan-only run declares its GENERATE list instead of writing files, so
177
+ // skip the claim scan wholesale rather than per-claim: checking it rejects
178
+ // every declaration the guard can see, and the ones it CANNOT see (cross-repo
179
+ // claims, exempted below) would pass — demoting the primary repo's items
180
+ // alone. Uneven enforcement is worse than none here.
181
+ for (const t of planOnly ? [] : newTests) {
141
182
  if (!t.fileName)
142
183
  continue;
143
184
  // Cross-repo finding — its file lives in a different checkout, not this tree.
@@ -160,9 +201,32 @@ export function findUnchangedFileClaims(input) {
160
201
  // The edit target, not the spec: for a POM-based test the page object is what
161
202
  // gets patched while testFilePath stays the spec (SKYR-4129).
162
203
  const editTarget = v.pomFile ?? v.testFilePath;
163
- if (!isBacked(editTarget)) {
164
- unbacked.push(`testMaintenance ${v.action} claims an edit to "${path.basename(editTarget)}", but that file has no working-tree change`);
204
+ // Baseline refresh (SKYR-4298): the verdict promises a replaced PNG next to the
205
+ // spec, and the report says "visual baseline refreshed" on its strength. Two rules:
206
+ // - The PNG must have been rewritten. An edit to the spec does not stand in for
207
+ // it, otherwise a run that forgot rebaselineSnapshots (or loosened
208
+ // maxDiffPixelRatio instead) would report a refresh that never happened.
209
+ // - Listing a baseline never exempts the edit the same verdict claimed. Unless the
210
+ // verdict is rebaselineOnly (no updateInstructions), the edit target — the POM
211
+ // when named, else the spec — still needs its own working-tree change; a
212
+ // baseline attached to a selector-rename UPDATE cannot wave the rename through.
213
+ // A plan-only run executes nothing, so no PNG can be rewritten there; the edit
214
+ // half is still verified, as for every other UPDATE (SKYR-4250).
215
+ const baselines = v.rebaselineSnapshots ?? [];
216
+ if (baselines.length > 0) {
217
+ const editOk = v.rebaselineOnly === true || isBacked(editTarget);
218
+ const pngOk = planOnly || isBackedBySnapshot(v.testFilePath, baselines);
219
+ if (!pngOk) {
220
+ unbacked.push(`testMaintenance ${v.action} claims a refreshed visual baseline (${baselines.join(", ")}) for "${path.basename(v.testFilePath)}", but no matching PNG was rewritten under its -snapshots directory — the final skyramp_execute_test run must pass rebaselineSnapshots and report it as refreshed; editing the spec does not stand in for the refresh`);
221
+ }
222
+ if (!editOk) {
223
+ unbacked.push(`testMaintenance ${v.action} claims an edit to "${path.basename(editTarget)}" alongside a baseline refresh, but that file has no working-tree change — listing a baseline does not exempt the edit`);
224
+ }
225
+ continue;
165
226
  }
227
+ if (isBacked(editTarget))
228
+ continue;
229
+ unbacked.push(`testMaintenance ${v.action} claims an edit to "${path.basename(editTarget)}", but that file has no working-tree change`);
166
230
  }
167
231
  return unbacked;
168
232
  }
@@ -46,7 +46,7 @@ export declare function draftScenariosFromEndpoints(endpoints: Array<EndpointInp
46
46
  /**
47
47
  * Enforce a global cap on drafted scenarios while preserving category diversity.
48
48
  *
49
- * 1. CRITICAL (bug_caught) scenarios prioritized first.
49
+ * 1. CRITICAL (bug_caught, requirement_conflict) scenarios prioritized first.
50
50
  * 2. One scenario per non-empty category guaranteed (breadth).
51
51
  * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
52
52
  * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
@@ -226,7 +226,7 @@ const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
226
226
  /**
227
227
  * Enforce a global cap on drafted scenarios while preserving category diversity.
228
228
  *
229
- * 1. CRITICAL (bug_caught) scenarios prioritized first.
229
+ * 1. CRITICAL (bug_caught, requirement_conflict) scenarios prioritized first.
230
230
  * 2. One scenario per non-empty category guaranteed (breadth).
231
231
  * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
232
232
  * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
@@ -1,4 +1,4 @@
1
- export declare const SKYRAMP_IMAGE_VERSION = "v1.3.40";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.40";
3
- export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.40";
1
+ export declare const SKYRAMP_IMAGE_VERSION = "v1.3.42";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.42";
3
+ export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.42";
4
4
  export declare const WORKER_CONTROL_PORT = 35142;
@@ -1,4 +1,4 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.40";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.42";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
4
4
  // Control port the Skyramp worker listens on (SDK `CONTAINER_PORT`).
@@ -0,0 +1,56 @@
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 assertHiddenTool_exports = {};
20
+ __export(assertHiddenTool_exports, {
21
+ assertHiddenMcpTool: () => assertHiddenMcpTool,
22
+ assertHiddenToolSchema: () => assertHiddenToolSchema
23
+ });
24
+ module.exports = __toCommonJS(assertHiddenTool_exports);
25
+ var import_mcpBundle = require("playwright-core/lib/mcpBundle");
26
+ var import_tool = require("../sdk/tool");
27
+ const assertHiddenToolSchema = {
28
+ name: "browser_assert_hidden",
29
+ title: "Assert element absent or hidden",
30
+ description: [
31
+ "Assert that an element is absent from the page or not visible \u2014 the recorded form of expect(locator).toBeHidden().",
32
+ "Use it for an element a change removed, after navigating to the page that used to show it.",
33
+ "Takes exactly one target: testId, or role + name, or text, or attribute + value. There is no snapshot ref (a removed element has none).",
34
+ "role + name and text match EXACTLY (case-sensitive, whole string): the values come verbatim from the removed source, not from snapshot text.",
35
+ "The tool refuses to record on a page that is not a loaded http(s) application page (about:blank, an error page, a data: URL),",
36
+ "where every absence would be vacuously true. Only a passing assertion is recorded in the trace."
37
+ ].join(" "),
38
+ inputSchema: import_mcpBundle.z.object({
39
+ element: import_mcpBundle.z.string().describe("Human-readable description of the element that must be absent or not visible"),
40
+ testId: import_mcpBundle.z.string().optional().describe("The data-testid of the element that must be absent or not visible. Pass exactly one of testId, role (+ name), text, or attribute (+ value)."),
41
+ role: import_mcpBundle.z.string().optional().describe('ARIA role of the element that must be absent or not visible (button, link, heading, textbox, \u2026). Requires "name".'),
42
+ name: import_mcpBundle.z.string().optional().describe('Accessible name that goes with "role"; matched exactly (case-sensitive, whole string).'),
43
+ text: import_mcpBundle.z.string().optional().describe("Exact visible text of the element that must be absent or not visible (whole string, case-sensitive)."),
44
+ attribute: import_mcpBundle.z.string().optional().describe("A `data-*` attribute name (e.g. `data-cy`, `data-qa`, `data-test-id`) identifying the removed element; pair with `value`. Use `testId` instead when the attribute is `data-testid`."),
45
+ value: import_mcpBundle.z.string().optional().describe("The literal value of `attribute`.")
46
+ }),
47
+ type: "readOnly"
48
+ };
49
+ function assertHiddenMcpTool() {
50
+ return (0, import_tool.toMcpTool)(assertHiddenToolSchema);
51
+ }
52
+ // Annotate the CommonJS export names for ESM import in node:
53
+ 0 && (module.exports = {
54
+ assertHiddenMcpTool,
55
+ assertHiddenToolSchema
56
+ });
@@ -35,7 +35,8 @@ const assertToolSchema = {
35
35
  '"checked" checks checkbox/radio state, "disabled" checks disabled/enabled state,',
36
36
  '"visible" checks the element is visible,',
37
37
  `"count" counts elements sharing the referenced element's role (or exact text)`,
38
- "across the page, e.g. ref one option of an open dropdown to assert the number of options."
38
+ "across the page, e.g. ref one option of an open dropdown to assert the number of options.",
39
+ "To assert an element is absent or not visible, use `browser_assert_hidden`."
39
40
  ].join(" "),
40
41
  inputSchema: import_mcpBundle.z.object({
41
42
  type: import_mcpBundle.z.enum(["text", "value", "checked", "disabled", "visible", "count"]).describe(`Type of assertion: "text" for text content, "value" for input field value, "checked" for checkbox/radio state, "disabled" for disabled/enabled state ("false" asserts the element is enabled), "visible" for element visibility, "count" for the number of elements with the referenced element's role`),
@@ -378,6 +378,16 @@ async function performClientAction(page, actionInContext) {
378
378
  throw new Error(`assertDisabled failed: expected disabled=${action.disabled}, got ${disabled}`);
379
379
  return;
380
380
  }
381
+ case "assertHidden": {
382
+ try {
383
+ await locator.waitFor({ ...opts, state: "hidden" });
384
+ } catch (e) {
385
+ if (e?.name === "TimeoutError")
386
+ throw new Error(`assertHidden failed: element is still visible (${e.message})`);
387
+ throw e;
388
+ }
389
+ return;
390
+ }
381
391
  case "assertCount": {
382
392
  await locator.first().waitFor(opts).catch(() => {
383
393
  });
@@ -63,6 +63,7 @@ const ACTION_NAMES = /* @__PURE__ */ new Set([
63
63
  "assertVisible",
64
64
  "assertDisabled",
65
65
  "assertCount",
66
+ "assertHidden",
66
67
  "assertSnapshot",
67
68
  "visualSnapshot",
68
69
  "assertTableCell",
@@ -138,7 +139,9 @@ function parseLine(line) {
138
139
  }
139
140
  if (!entry.name || !isActionName(entry.name))
140
141
  return null;
141
- const { pageGuid, pageAlias, framePath, locator: _locator, ...actionFields } = entry;
142
+ const { pageGuid, pageAlias, framePath, locator, ...actionFields } = entry;
143
+ if (entry.name === "assertHidden" && locator && typeof locator === "object")
144
+ actionFields.locator = locator;
142
145
  const frame = {
143
146
  pageGuid: pageGuid ?? "",
144
147
  pageAlias: pageAlias ?? "page",
@@ -45,6 +45,7 @@ var import_log = require("../log");
45
45
  var import_skyRampExport = require("../test/skyRampExport");
46
46
  var import_exportTool = require("./exportTool");
47
47
  var import_assertTool = require("./assertTool");
48
+ var import_assertHiddenTool = require("./assertHiddenTool");
48
49
  var import_assertApiRequestTool = require("./assertApiRequestTool");
49
50
  var import_loadTraceTool = require("./loadTraceTool");
50
51
  var import_skyRampImport = require("./skyRampImport");
@@ -186,7 +187,7 @@ class TraceRecordingBackend {
186
187
  }
187
188
  async listTools() {
188
189
  const browserTools = await this._browserBackend.listTools();
189
- return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)(), (0, import_mouseActionTool.mouseActionMcpTool)(), (0, import_tableAssertTool.tableAssertMcpTool)(), (0, import_visualSnapshotTool.visualSnapshotMcpTool)(), (0, import_gojsTool.gojsAddNodeMcpTool)(), (0, import_gojsTool.gojsLinkMcpTool)()];
190
+ return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertHiddenTool.assertHiddenMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)(), (0, import_mouseActionTool.mouseActionMcpTool)(), (0, import_tableAssertTool.tableAssertMcpTool)(), (0, import_visualSnapshotTool.visualSnapshotMcpTool)(), (0, import_gojsTool.gojsAddNodeMcpTool)(), (0, import_gojsTool.gojsLinkMcpTool)()];
190
191
  }
191
192
  async callTool(name, args, progress) {
192
193
  if (!this._initialized)
@@ -238,6 +239,10 @@ class TraceRecordingBackend {
238
239
  const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
239
240
  return this._handleAssert(parsed);
240
241
  }
242
+ if (name === import_assertHiddenTool.assertHiddenToolSchema.name) {
243
+ const parsed = import_assertHiddenTool.assertHiddenToolSchema.inputSchema.parse(args || {});
244
+ return this._handleAssertHidden(parsed, Date.now());
245
+ }
241
246
  if (name === import_assertApiRequestTool.assertApiRequestSchema.name) {
242
247
  this._trackedActions.push({
243
248
  toolName: "browser_assert_api_request",
@@ -855,6 +860,14 @@ Continue recording with browser_* tools, then call skyramp_export_zip to write t
855
860
  return { toolName: "browser_assert", code: `assertChecked:${a.selector}:${!!a.checked}`, args: { type: "checked", selector: a.selector, checked: !!a.checked } };
856
861
  case "assertVisible":
857
862
  return { toolName: "browser_assert", code: `assertVisible:${a.selector}`, args: { type: "visible", selector: a.selector } };
863
+ case "assertHidden": {
864
+ if (typeof a.selector !== "string" || !a.selector)
865
+ return null;
866
+ const args = { type: "hidden", selector: a.selector };
867
+ if (a.locator && typeof a.locator === "object")
868
+ args.locator = a.locator;
869
+ return { toolName: "browser_assert", code: `assertHidden:${a.selector}`, args };
870
+ }
858
871
  case "assertDisabled":
859
872
  if (typeof a.disabled !== "boolean")
860
873
  return null;
@@ -1365,6 +1378,9 @@ Role "${refRole}" is not countable \u2014 reference an exemplar with a semantic
1365
1378
  if (!tab) {
1366
1379
  return { content: [{ type: "text", text: "### Assertion Failed\nNo active page." }], isError: true };
1367
1380
  }
1381
+ const notReady = await this._requireLoadedPage(tab, "domcontentloaded");
1382
+ if (notReady)
1383
+ return notReady;
1368
1384
  countSelector = `internal:role=${refRole}`;
1369
1385
  let countActual;
1370
1386
  try {
@@ -1520,6 +1536,149 @@ Cell row ${params.row}, column ${params.column} equals "${params.expected}".` :
1520
1536
  Cell row ${params.row}, column ${params.column} has "${actual}", expected "${params.expected}".` }]
1521
1537
  };
1522
1538
  }
1539
+ static {
1540
+ /**
1541
+ * How long a still-present browser_assert_hidden target may take to disappear
1542
+ * before the check reports "still visible". Short on purpose: the agent
1543
+ * asserts after the page has settled, so a longer wait only delays a genuine
1544
+ * failure verdict.
1545
+ */
1546
+ this.HIDDEN_WAIT_TIMEOUT_MS = 2e3;
1547
+ }
1548
+ /**
1549
+ * Page-readiness guard shared by the checks whose live query passes
1550
+ * vacuously on a page that has not loaded the application:
1551
+ * browser_assert_hidden (waitFor({ state: 'hidden' }) resolves instantly for
1552
+ * zero matches) and browser_assert type:"count" (getByRole().count() is 0 on
1553
+ * an empty page).
1554
+ *
1555
+ * What it checks: only the URL scheme. about:blank, chrome-error:// and
1556
+ * data: URLs are refused with an error; an http(s)/file: page is then given
1557
+ * up to 5 s to reach the load event (best-effort — a slow page is not an
1558
+ * error, the caller's own wait/timeout decides).
1559
+ *
1560
+ * What it does NOT catch: a typo'd or stale target on a correctly loaded
1561
+ * page still passes here — this guard cannot tell "removed" from "never
1562
+ * existed". There is deliberately no rendered-element check either: a
1563
+ * text-only page or a canvas/Flutter app has few or no elements under <body>
1564
+ * and is still a loaded page. The defence against a wrong target lives on the
1565
+ * mcp side, which enforces (as a lint) that every absence assertion is paired
1566
+ * with a positive assertion on the same page.
1567
+ *
1568
+ * `loadState` is the readiness bar after the URL check: `hidden` waits for
1569
+ * `load` (an absence verdict wants the page fully settled), `count` only for
1570
+ * `domcontentloaded` — a streaming or long-polling SPA may never fire `load`,
1571
+ * and a live count should not pay up to 5 s of latency for that.
1572
+ *
1573
+ * Returns the isError result to hand back, or null when the page is ready.
1574
+ */
1575
+ async _requireLoadedPage(tab, loadState = "load") {
1576
+ const url = tab.page.url();
1577
+ if (!/^(?:https?|file):/.test(url))
1578
+ return { content: [{ type: "text", text: `### Error
1579
+ The current page (${url}) is not a loaded application page; navigate to the app before asserting on it.` }], isError: true };
1580
+ await tab.page.waitForLoadState(loadState, { timeout: 5e3 }).catch(() => {
1581
+ });
1582
+ return null;
1583
+ }
1584
+ /**
1585
+ * browser_assert_hidden (SKYR-4305): the element is absent from the page or
1586
+ * not visible — the recorded form of expect(locator).toBeHidden().
1587
+ * Targets are named (testId | role+name | text | attribute+value) rather than
1588
+ * referenced by a snapshot ref because the typical subject no longer exists
1589
+ * at all (a change removed it), and the browser_assert path resolves refs via
1590
+ * browser_hover, which fails actionability on a hidden element anyway.
1591
+ *
1592
+ * Exactness: role+name and text are both recorded EXACT (`s` suffix /
1593
+ * exact: true) — one rule for this tool. The other producers
1594
+ * (_codeToLocator, _extractLocatorForRef) record `i` because their values come
1595
+ * from snapshot text, which may be trimmed or partially rendered; here the
1596
+ * values come verbatim from the deleted source, so exact is both safe and
1597
+ * needed: a substring "Session" match would keep failing until every
1598
+ * "Session…" occurrence on the page is gone.
1599
+ *
1600
+ * The locator object is built here next to the selector and recorded on the
1601
+ * tracked action (args.locator), so the exporter never has to regex-parse a
1602
+ * JSON-quoted value back out of the selector string.
1603
+ *
1604
+ * Verification is live: locator.waitFor({ state: 'hidden' }) has exactly
1605
+ * toBeHidden's contract — resolves for zero matches or one non-visible match,
1606
+ * times out while the match is visible, and strict-fails on multiple matches
1607
+ * (no `.first()`, so the recorder can never record a pass the generated
1608
+ * expect(locator).toBeHidden() would throw on). Because zero matches passes,
1609
+ * _requireLoadedPage runs first.
1610
+ *
1611
+ * Recorded as toolName 'browser_assert' with args.type 'hidden', so
1612
+ * assertActionToJsonl and the re-seed path share one shape with the other
1613
+ * assertions.
1614
+ */
1615
+ async _handleAssertHidden(params, timestamp) {
1616
+ const err = (text) => ({ content: [{ type: "text", text: `### Error
1617
+ ${text}` }], isError: true });
1618
+ const given = [params.testId, params.role, params.text, params.attribute].filter((v) => typeof v === "string" && v.trim() !== "");
1619
+ if (given.length !== 1)
1620
+ return err('Pass exactly one target: "testId", or "role" together with "name", or "text", or "attribute" together with "value". There is no "ref" \u2014 a removed element has no snapshot ref.');
1621
+ if (!params.role?.trim() && typeof params.name === "string")
1622
+ return err('"name" only goes with "role" (the ARIA role of the element it names).');
1623
+ if (!params.attribute?.trim() && typeof params.value === "string")
1624
+ return err('"value" only goes with "attribute" (the data-* attribute name it is the value of).');
1625
+ let selector;
1626
+ let locator;
1627
+ if (params.testId?.trim()) {
1628
+ const testId = params.testId.trim();
1629
+ selector = `internal:testid=[data-testid=${JSON.stringify(testId)}s]`;
1630
+ locator = { kind: "test-id", body: testId, options: {} };
1631
+ } else if (params.role?.trim()) {
1632
+ const role = params.role.trim();
1633
+ if (!TraceRecordingBackend.ASSERTABLE_ROLES.has(role))
1634
+ return err(`Role "${role}" is not assertable \u2014 use a semantic role (button, link, heading, textbox, \u2026) or pass "testId"/"text" instead.`);
1635
+ const name = params.name?.trim() ?? "";
1636
+ if (!name)
1637
+ return err('By role, "name" is required \u2014 a bare role would assert that no such element exists anywhere on the page.');
1638
+ selector = `internal:role=${role}[name=${JSON.stringify(name)}s]`;
1639
+ locator = { kind: "role", body: role, options: { attrs: [], exact: true, name } };
1640
+ } else if (params.attribute?.trim()) {
1641
+ const attribute = params.attribute.trim().toLowerCase();
1642
+ if (!/^data-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(attribute))
1643
+ return err(`Attribute "${params.attribute.trim()}" is not a data-* attribute. "attribute" accepts only data-* attributes (data-cy, data-qa, data-test-id, \u2026); target other elements by role + name or text instead.`);
1644
+ const value = params.value?.trim() ?? "";
1645
+ if (!value)
1646
+ return err('By attribute, a non-empty "value" is required \u2014 a bare attribute would assert that no element carries it anywhere on the page.');
1647
+ selector = `[${attribute}=${JSON.stringify(value)}]`;
1648
+ locator = { kind: "default", body: selector, options: {} };
1649
+ } else {
1650
+ const text = params.text.trim();
1651
+ selector = `internal:text=${JSON.stringify(text)}s`;
1652
+ locator = { kind: "text", body: text, options: { exact: true } };
1653
+ }
1654
+ const tab = this._browserBackend.context?.currentTab();
1655
+ if (!tab)
1656
+ return err("No active page.");
1657
+ const notReady = await this._requireLoadedPage(tab);
1658
+ if (notReady)
1659
+ return notReady;
1660
+ let passed = true;
1661
+ try {
1662
+ await tab.page.locator(selector).waitFor({ state: "hidden", timeout: TraceRecordingBackend.HIDDEN_WAIT_TIMEOUT_MS });
1663
+ } catch (e) {
1664
+ if (/strict mode violation/i.test(String(e?.message ?? "")))
1665
+ return err(`Target ${selector} matches multiple elements, so toBeHidden() would fail strict mode. Use a more specific target (a data-testid, or role + exact name). ${String(e.message).split("\n")[0]}`);
1666
+ if (e?.name !== "TimeoutError")
1667
+ return err(`Could not verify absence of "${params.element}": ${e?.message ?? e}`);
1668
+ passed = false;
1669
+ }
1670
+ let details;
1671
+ if (passed) {
1672
+ details = `Hidden assertion passed: "${params.element}" is absent or not visible.`;
1673
+ this._trackedActions.push({ toolName: "browser_assert", args: { type: "hidden", selector, locator }, code: `assertHidden:${selector}`, timestamp });
1674
+ traceDebug(`Assert: assertHidden with selector ${selector}`);
1675
+ } else {
1676
+ const count = await tab.page.locator(selector).count().catch(() => -1);
1677
+ details = `Hidden assertion FAILED: "${params.element}" is still visible (${count < 0 ? "unknown" : count} element(s) match ${selector}).`;
1678
+ }
1679
+ return { content: [{ type: "text", text: `### ${passed ? "Assertion Passed" : "Assertion Failed"}
1680
+ ${details}` }] };
1681
+ }
1523
1682
  /** Convert a Playwright locator expression to a selector + locator object for JSONL. */
1524
1683
  _codeToLocator(expr) {
1525
1684
  const testidMatch = expr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);