@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.
- package/build/playwright/registerPlaywrightTools.js +1 -0
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +22 -1
- package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +4 -1
- package/build/prompts/test-recommendation/diffExecutionPlan.js +41 -9
- package/build/prompts/test-recommendation/fullRepoCatalog.js +3 -2
- package/build/prompts/test-recommendation/recommendationSections.js +3 -3
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +2 -2
- package/build/prompts/test-recommendation/scopeAssessment.js +58 -76
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +14 -1
- package/build/prompts/testbot/testbot-prompts.js +35 -15
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +39 -5
- package/build/recommendation/budgeters/shared.d.ts +21 -8
- package/build/recommendation/budgeters/shared.js +134 -56
- package/build/recommendation/planRanker.d.ts +5 -3
- package/build/recommendation/planRanker.js +1 -1
- package/build/services/containerEnv.d.ts +1 -1
- package/build/services/containerEnv.js +12 -0
- package/build/tools/executeSkyrampTestTool.d.ts +80 -0
- package/build/tools/executeSkyrampTestTool.js +246 -19
- package/build/tools/submitReportTool.d.ts +5 -5
- package/build/tools/submitReportTool.js +11 -2
- package/build/tools/test-management/actionsTool.js +71 -3
- package/build/tools/test-management/analyzeChangesTool.d.ts +10 -0
- package/build/tools/test-management/analyzeChangesTool.js +73 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +3 -3
- package/build/tools/test-management/registerTestPlanTool.js +29 -8
- package/build/types/RepositoryAnalysis.d.ts +10 -10
- package/build/types/TestAnalysis.d.ts +12 -0
- package/build/types/TestExecution.d.ts +4 -0
- package/build/types/TestRecommendation.d.ts +11 -1
- package/build/types/TestRecommendation.js +34 -0
- package/build/utils/AnalysisStateManager.d.ts +7 -0
- package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
- package/build/utils/assertion-verify/ui-lints.js +32 -0
- package/build/utils/frontendSelectors.d.ts +33 -0
- package/build/utils/frontendSelectors.js +196 -5
- package/build/utils/pathSignatures.d.ts +4 -1
- package/build/utils/pathSignatures.js +14 -2
- package/build/utils/planOnlyMode.d.ts +33 -0
- package/build/utils/planOnlyMode.js +40 -0
- package/build/utils/rebaselineSnapshots.d.ts +24 -0
- package/build/utils/rebaselineSnapshots.js +65 -0
- package/build/utils/removedUiElements.d.ts +34 -0
- package/build/utils/removedUiElements.js +153 -0
- package/build/utils/reportVerification.d.ts +7 -6
- package/build/utils/reportVerification.js +68 -4
- package/build/utils/scenarioDrafting.d.ts +1 -1
- package/build/utils/scenarioDrafting.js +1 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +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 {
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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.
|
|
2
|
-
export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.
|
|
3
|
-
export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.
|
|
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;
|
package/build/utils/versions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SKYRAMP_IMAGE_VERSION = "v1.3.
|
|
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
|
|
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*\)/);
|