@skyramp/mcp 0.3.8 → 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 +1 -0
- package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
- package/build/prompts/testbot/testbot-prompts.js +10 -7
- 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 +5 -1
- 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/types/TestAnalysis.d.ts +12 -0
- package/build/types/TestExecution.d.ts +4 -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/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 +2 -6
- package/build/utils/reportVerification.js +61 -2
- 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
|
@@ -11,10 +11,14 @@ import { TestExecutionStatus, } from "../types/TestExecution.js";
|
|
|
11
11
|
import { getWorkspaceBaseUrl } from "../utils/workspaceAuth.js";
|
|
12
12
|
import { ProgrammingLanguage, TestType } from "../types/TestTypes.js";
|
|
13
13
|
import { StateManager, getTestsRepoDir, } from "../utils/AnalysisStateManager.js";
|
|
14
|
-
import { TestSource } from "../types/TestAnalysis.js";
|
|
14
|
+
import { DriftAction, TestSource } from "../types/TestAnalysis.js";
|
|
15
15
|
import { logger } from "../utils/logger.js";
|
|
16
16
|
import { toolError } from "../utils/utils.js";
|
|
17
17
|
import { recordExecutionVideo } from "./execution-video-state.js";
|
|
18
|
+
import { stageGeneratedPaths } from "../utils/gitStaging.js";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import { baselineFileMatchesStem, baselineStem, rebaselineSnapshotNameSchema, snapshotDirFor, } from "../utils/rebaselineSnapshots.js";
|
|
21
|
+
import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
|
|
18
22
|
const TOOL_NAME = "skyramp_execute_test";
|
|
19
23
|
export const CONTRACT_EXECUTION_MODES = ["provider", "consumer"];
|
|
20
24
|
/**
|
|
@@ -97,22 +101,172 @@ const HTTP_401_SHAPES = [
|
|
|
97
101
|
/\bwhere\s+401\s*=/i,
|
|
98
102
|
];
|
|
99
103
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* the
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
* Normalize the `rebaselineSnapshots` request (SKYR-4298): dedupe and drop blanks,
|
|
105
|
+
* and refuse it on the pre-edit run. A refresh there would overwrite the very
|
|
106
|
+
* evidence that the baseline is stale, and `beforeStatus` would read Pass for a
|
|
107
|
+
* test that failed against the committed image.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveRebaselineSnapshots(requested, phase) {
|
|
110
|
+
const snapshots = [
|
|
111
|
+
...new Set((requested ?? []).map((s) => s.trim()).filter(Boolean)),
|
|
112
|
+
];
|
|
113
|
+
if (snapshots.length > 0 && phase === "before") {
|
|
114
|
+
return {
|
|
115
|
+
snapshots: [],
|
|
116
|
+
error: `rebaselineSnapshots cannot be combined with phase: "before". Run the pre-edit baseline without it to record the mismatch, then pass rebaselineSnapshots on the final run only.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { snapshots };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Snapshot of the requested baselines under `<spec>-snapshots/` (SKYR-4298): for
|
|
123
|
+
* each requested name, every PNG whose name matches the stem (a spec may hold both
|
|
124
|
+
* `<stem>-linux.png` and `<stem>-chromium-linux.png`; latching onto one of them would
|
|
125
|
+
* misreport the other) with its size and mtime. Taken before and after the run so
|
|
126
|
+
* the tool can tell the agent which baselines were actually rewritten — SmartPlaywright
|
|
127
|
+
* is the only party that knows the exact filename, and an executor image that lacks
|
|
128
|
+
* SKYRAMP_UPDATE_SNAPSHOTS (or a name matching no toHaveScreenshot call) leaves
|
|
129
|
+
* every file untouched.
|
|
115
130
|
*/
|
|
131
|
+
export function readBaselineState(specFile, requested) {
|
|
132
|
+
const dir = snapshotDirFor(specFile);
|
|
133
|
+
let entries = [];
|
|
134
|
+
try {
|
|
135
|
+
entries = fs.readdirSync(dir).sort();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
entries = [];
|
|
139
|
+
}
|
|
140
|
+
const state = {};
|
|
141
|
+
for (const name of requested) {
|
|
142
|
+
const stem = baselineStem(name);
|
|
143
|
+
const files = [];
|
|
144
|
+
for (const file of entries.filter((e) => baselineFileMatchesStem(e, stem))) {
|
|
145
|
+
try {
|
|
146
|
+
const st = fs.statSync(path.join(dir, file));
|
|
147
|
+
files.push({ file, size: st.size, mtimeMs: st.mtimeMs });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// vanished between readdir and stat — treat as absent
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
state[name] = files;
|
|
154
|
+
}
|
|
155
|
+
return state;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Which requested baselines changed on disk between two readBaselineState calls, and
|
|
159
|
+
* which files carried the change (the ones to stage).
|
|
160
|
+
*/
|
|
161
|
+
export function diffBaselineState(before, after) {
|
|
162
|
+
const refreshed = [];
|
|
163
|
+
const notRefreshed = [];
|
|
164
|
+
const refreshedFiles = {};
|
|
165
|
+
for (const name of Object.keys(after)) {
|
|
166
|
+
const prev = new Map((before[name] ?? []).map((f) => [f.file, f]));
|
|
167
|
+
const changed = after[name]
|
|
168
|
+
.filter((a) => {
|
|
169
|
+
const b = prev.get(a.file);
|
|
170
|
+
return !b || a.size !== b.size || a.mtimeMs !== b.mtimeMs;
|
|
171
|
+
})
|
|
172
|
+
.map((a) => a.file);
|
|
173
|
+
if (changed.length > 0) {
|
|
174
|
+
refreshed.push(name);
|
|
175
|
+
refreshedFiles[name] = changed;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
notRefreshed.push(name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { refreshed, notRefreshed, refreshedFiles };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Authorize a refresh against the persisted maintenance state (SKYR-4298). The
|
|
185
|
+
* parameter description tells the agent to pass exactly what skyramp_actions
|
|
186
|
+
* returned, but prose cannot tell an authorized list from an invented one, so the
|
|
187
|
+
* tool checks: an UPDATE verdict for this spec must list every requested name, and
|
|
188
|
+
* the spec's phase:"before" run must already be recorded — a refresh before that
|
|
189
|
+
* run would overwrite the very evidence that the baseline is stale, whatever
|
|
190
|
+
* `phase` says or omits. A newly generated test has no verdict and no committed
|
|
191
|
+
* baseline, so it can never be refreshed here.
|
|
192
|
+
*/
|
|
193
|
+
export function authorizeRebaseline(stateData, testFile, requested) {
|
|
194
|
+
if (requested.length === 0)
|
|
195
|
+
return {};
|
|
196
|
+
const verdict = (stateData?.maintenanceVerdicts ?? []).find((v) => v.testFilePath === testFile && v.action === DriftAction.Update);
|
|
197
|
+
if (!verdict) {
|
|
198
|
+
return {
|
|
199
|
+
error: `rebaselineSnapshots refused: no UPDATE verdict for ${testFile} in the stateFile. Only a maintained test whose skyramp_analyze_test_health verdict listed the baselines can be refreshed — a newly generated test writes its own baseline on its first run and has nothing to refresh.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const authorized = new Set(verdict.rebaselineSnapshots ?? []);
|
|
203
|
+
const unauthorized = requested.filter((n) => !authorized.has(n));
|
|
204
|
+
if (unauthorized.length > 0) {
|
|
205
|
+
return {
|
|
206
|
+
error: `rebaselineSnapshots refused: ${unauthorized.join(", ")} not in the UPDATE verdict for ${path.basename(testFile)} (authorized: ${authorized.size ? [...authorized].join(", ") : "none"}). Pass exactly the rebaseline_snapshots skyramp_actions returned, using the name the test passes to toHaveScreenshot (page-001.png), not the on-disk file (page-001-chromium-linux.png).`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const entry = (stateData?.existingTests ?? []).find((t) => t.testFile === testFile);
|
|
210
|
+
if (!entry?.executionBefore) {
|
|
211
|
+
return {
|
|
212
|
+
error: `rebaselineSnapshots refused: no phase: "before" execution is recorded for ${path.basename(testFile)}. Run the pre-edit baseline first (it is the evidence the baseline is stale), then pass rebaselineSnapshots on the final run.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Reconcile the persisted verdict with what the executor actually did (SKYR-4298).
|
|
219
|
+
* An executor image whose @skyramp/skyramp predates SKYRAMP_UPDATE_SNAPSHOTS rewrites
|
|
220
|
+
* nothing; left alone, the verdict would still promise a refresh, the report gate
|
|
221
|
+
* would refuse the report, and nothing in the prompt makes the agent's way out
|
|
222
|
+
* deterministic. So: names that were not refreshed are dropped from the verdict; a
|
|
223
|
+
* rebaseline-only UPDATE with nothing left becomes VERIFY (the test stays red, the
|
|
224
|
+
* rationale says why), and an UPDATE that also carried edits keeps UPDATE and is
|
|
225
|
+
* held to its edit. The report then reflects what happened, not what was asked.
|
|
226
|
+
*/
|
|
227
|
+
export function applyRefreshOutcomeToVerdicts(verdicts, testFile, outcome, executorImage) {
|
|
228
|
+
if (outcome.notRefreshed.length === 0)
|
|
229
|
+
return { verdicts };
|
|
230
|
+
let note;
|
|
231
|
+
const next = verdicts.map((v) => {
|
|
232
|
+
if (v.testFilePath !== testFile || v.action !== DriftAction.Update)
|
|
233
|
+
return v;
|
|
234
|
+
const remaining = (v.rebaselineSnapshots ?? []).filter((n) => !outcome.notRefreshed.includes(n));
|
|
235
|
+
const reason = `baseline refresh of ${outcome.notRefreshed.join(", ")} was not applied by ${executorImage} (its @skyramp/skyramp lacks SKYRAMP_UPDATE_SNAPSHOTS, or the name matches no toHaveScreenshot call)`;
|
|
236
|
+
if (remaining.length === 0 && v.rebaselineOnly) {
|
|
237
|
+
note = `Verdict for ${path.basename(testFile)} downgraded UPDATE → VERIFY: ${reason}. The test stays as it is; report it honestly.`;
|
|
238
|
+
const { rebaselineSnapshots: _dropped, rebaselineOnly: _only, ...rest } = v;
|
|
239
|
+
void _dropped;
|
|
240
|
+
void _only;
|
|
241
|
+
return { ...rest, action: DriftAction.Verify, rationale: `${v.rationale} [${reason}]` };
|
|
242
|
+
}
|
|
243
|
+
note = `Verdict for ${path.basename(testFile)}: ${reason}; the UPDATE is held to its spec/POM edit only.`;
|
|
244
|
+
const { rebaselineSnapshots: _dropped, ...rest } = v;
|
|
245
|
+
void _dropped;
|
|
246
|
+
return {
|
|
247
|
+
...rest,
|
|
248
|
+
...(remaining.length > 0 ? { rebaselineSnapshots: remaining } : {}),
|
|
249
|
+
rationale: `${v.rationale} [${reason}]`,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
return { verdicts: next, note };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The refresh outcome line appended to the execution result, on pass and on fail
|
|
256
|
+
* alike. Without it a stale-baseline run that could not refresh reads as a bare
|
|
257
|
+
* "Screenshot comparison failed", and the report gate then tells the agent to run
|
|
258
|
+
* with rebaselineSnapshots set — which it already did.
|
|
259
|
+
*/
|
|
260
|
+
export function describeRefreshOutcome(outcome) {
|
|
261
|
+
const parts = [];
|
|
262
|
+
if (outcome.refreshed.length > 0) {
|
|
263
|
+
parts.push(`Visual baselines refreshed: ${outcome.refreshed.join(", ")}.`);
|
|
264
|
+
}
|
|
265
|
+
if (outcome.notRefreshed.length > 0) {
|
|
266
|
+
parts.push(`Visual baselines NOT refreshed: ${outcome.notRefreshed.join(", ")} — the executor image may lack SKYRAMP_UPDATE_SNAPSHOTS support (needs @skyramp/skyramp with SKYR-4298), or the name matches no toHaveScreenshot() call in this spec. Do not report these as refreshed.`);
|
|
267
|
+
}
|
|
268
|
+
return parts.join(" ");
|
|
269
|
+
}
|
|
116
270
|
export function buildExecutionFailureText(result) {
|
|
117
271
|
const output = stripVTControlCharacters(result.output || "").trim();
|
|
118
272
|
const facts = [`status=${result.status}`];
|
|
@@ -199,8 +353,14 @@ export function registerExecuteSkyrampTestTool(server) {
|
|
|
199
353
|
.describe("Path to state file from skyramp_analyze_changes. Always pass when available — results are written back so skyramp_submit_report can override before/afterStatus with ground-truth pass/fail."),
|
|
200
354
|
phase: z
|
|
201
355
|
.enum(["before", "after"])
|
|
202
|
-
.
|
|
356
|
+
.default("after")
|
|
203
357
|
.describe("Execution phase for maintained tests: 'before' captures pre-edit baseline; 'after' (default) records post-edit result."),
|
|
358
|
+
rebaselineSnapshots: z
|
|
359
|
+
.array(rebaselineSnapshotNameSchema)
|
|
360
|
+
.optional()
|
|
361
|
+
.describe("UI tests only. toHaveScreenshot() baseline filenames (e.g. [\"page-001.png\"]) this run REPLACES instead of comparing against, because the PR intentionally changed how the captured page/element/region looks (SKYR-4298). " +
|
|
362
|
+
"Pass exactly the list skyramp_actions returned as rebaseline_snapshots for this spec — the tool checks it against the persisted UPDATE verdict in stateFile (so stateFile is required) and refuses names the verdict did not authorize, a spec with no such verdict, or a spec whose phase: 'before' run has not been recorded yet. Use the name as the test passes it (page-001.png), not the on-disk file (page-001-chromium-linux.png). " +
|
|
363
|
+
"The refreshed PNGs land beside the spec and are delivered with the Testbot PR as image diffs; the result names which baselines were refreshed and which were not. Never use this to silence a screenshot mismatch the diff does not explain."),
|
|
204
364
|
repository: z
|
|
205
365
|
.string()
|
|
206
366
|
.optional()
|
|
@@ -227,6 +387,24 @@ export function registerExecuteSkyrampTestTool(server) {
|
|
|
227
387
|
errorResult = toolError(`testFile must be an absolute path, got: ${params.testFile}`);
|
|
228
388
|
return errorResult;
|
|
229
389
|
}
|
|
390
|
+
const rebaseline = resolveRebaselineSnapshots(params.rebaselineSnapshots, params.phase);
|
|
391
|
+
if (rebaseline.error) {
|
|
392
|
+
errorResult = toolError(rebaseline.error);
|
|
393
|
+
return errorResult;
|
|
394
|
+
}
|
|
395
|
+
const rebaselineSnapshots = rebaseline.snapshots;
|
|
396
|
+
if (rebaselineSnapshots.length > 0) {
|
|
397
|
+
if (!params.stateFile) {
|
|
398
|
+
errorResult = toolError("rebaselineSnapshots requires stateFile: the refresh is authorized against the UPDATE verdict skyramp_actions persisted there.");
|
|
399
|
+
return errorResult;
|
|
400
|
+
}
|
|
401
|
+
const authState = await StateManager.fromStatePath(params.stateFile).readRepoData(params.repository);
|
|
402
|
+
const auth = authorizeRebaseline(authState, params.testFile, rebaselineSnapshots);
|
|
403
|
+
if (auth.error) {
|
|
404
|
+
errorResult = toolError(auth.error);
|
|
405
|
+
return errorResult;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
230
408
|
// Deterministic external-test guard (SKYR-3924): this tool runs Skyramp-generated
|
|
231
409
|
// tests in the executor and cannot run a repo's native (user-written) suite, so a
|
|
232
410
|
// run on an external test only errors (e.g. pytest import/collection failure).
|
|
@@ -343,7 +521,13 @@ export function registerExecuteSkyrampTestTool(server) {
|
|
|
343
521
|
playwrightSaveStoragePath: params.playwrightSaveStoragePath,
|
|
344
522
|
dockerNetwork,
|
|
345
523
|
useHostNetwork: false,
|
|
524
|
+
...(rebaselineSnapshots.length > 0 ? { rebaselineSnapshots } : {}),
|
|
346
525
|
};
|
|
526
|
+
// Identity of the requested baselines before the run, to report afterwards
|
|
527
|
+
// which ones SmartPlaywright actually rewrote (SKYR-4298).
|
|
528
|
+
const baselinesBefore = rebaselineSnapshots.length > 0
|
|
529
|
+
? readBaselineState(params.testFile, rebaselineSnapshots)
|
|
530
|
+
: {};
|
|
347
531
|
let result;
|
|
348
532
|
try {
|
|
349
533
|
result = await executionService.executeTest(execOptions, onExecutionProgress);
|
|
@@ -396,10 +580,53 @@ export function registerExecuteSkyrampTestTool(server) {
|
|
|
396
580
|
// populate testResults[].videoPath — testbot uploads only the video
|
|
397
581
|
// directories the report references, so an unrecorded video is never seen.
|
|
398
582
|
await recordExecutionVideo(result, params.stateFile);
|
|
583
|
+
// Which requested baselines were actually rewritten (SKYR-4298). Reported on
|
|
584
|
+
// pass and fail alike; a refreshed PNG is a deliverable like a generated
|
|
585
|
+
// spec, so stage the spec's snapshot directory whenever one was rewritten —
|
|
586
|
+
// even on a failing run, since the report gate checks the PNG, not the
|
|
587
|
+
// status — so the eval harness commit and the artifact collector see it
|
|
588
|
+
// (production delivery adds the whole test directory anyway). No-op outside
|
|
589
|
+
// a testbot run, like every other stageGeneratedPaths call; never fails the
|
|
590
|
+
// execution.
|
|
591
|
+
let refreshOutcomeText = "";
|
|
592
|
+
if (rebaselineSnapshots.length > 0) {
|
|
593
|
+
const after = readBaselineState(params.testFile, rebaselineSnapshots);
|
|
594
|
+
const outcome = diffBaselineState(baselinesBefore, after);
|
|
595
|
+
refreshOutcomeText = describeRefreshOutcome(outcome);
|
|
596
|
+
// Stage the rewritten files themselves, never the directory: `git add` on
|
|
597
|
+
// the directory would ship anything else sitting there under one
|
|
598
|
+
// authorized baseline's authority.
|
|
599
|
+
for (const name of outcome.refreshed) {
|
|
600
|
+
for (const file of outcome.refreshedFiles[name] ?? []) {
|
|
601
|
+
try {
|
|
602
|
+
await stageGeneratedPaths(path.join(snapshotDirFor(params.testFile), file));
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
logger.warning(`Could not stage refreshed visual baseline ${file} for ${params.testFile}: ${err.message}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (outcome.notRefreshed.length > 0 && params.stateFile) {
|
|
610
|
+
try {
|
|
611
|
+
const stateManager = StateManager.fromStatePath(params.stateFile);
|
|
612
|
+
const stateData = await stateManager.readRepoData(params.repository);
|
|
613
|
+
if (stateData?.maintenanceVerdicts) {
|
|
614
|
+
const reconciled = applyRefreshOutcomeToVerdicts(stateData.maintenanceVerdicts, params.testFile, outcome, EXECUTOR_DOCKER_IMAGE);
|
|
615
|
+
await stateManager.writeRepoData({ ...stateData, maintenanceVerdicts: reconciled.verdicts }, { repo: params.repository });
|
|
616
|
+
if (reconciled.note)
|
|
617
|
+
refreshOutcomeText += ` ${reconciled.note}`;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
logger.warning(`Could not reconcile the maintenance verdict with the refresh outcome: ${err.message}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const withRefreshOutcome = (text) => refreshOutcomeText ? `${text}\n\n${refreshOutcomeText}` : text;
|
|
399
626
|
// Progress is already reported by TestExecutionService
|
|
400
627
|
// Only report final status if not already at 100%
|
|
401
628
|
if (result.status !== TestExecutionStatus.Pass) {
|
|
402
|
-
errorResult = toolError(withVideoInfo(buildExecutionFailureText(result), result.videoPath));
|
|
629
|
+
errorResult = toolError(withRefreshOutcome(withVideoInfo(buildExecutionFailureText(result), result.videoPath)));
|
|
403
630
|
return errorResult;
|
|
404
631
|
}
|
|
405
632
|
// Success - progress already reported by TestExecutionService
|
|
@@ -407,7 +634,7 @@ export function registerExecuteSkyrampTestTool(server) {
|
|
|
407
634
|
content: [
|
|
408
635
|
{
|
|
409
636
|
type: "text",
|
|
410
|
-
text: withVideoInfo(`Test execution result: ${stripVTControlCharacters(result.output || "")}`, result.videoPath),
|
|
637
|
+
text: withRefreshOutcome(withVideoInfo(`Test execution result: ${stripVTControlCharacters(result.output || "")}`, result.videoPath)),
|
|
411
638
|
},
|
|
412
639
|
],
|
|
413
640
|
};
|
|
@@ -101,8 +101,8 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
|
|
|
101
101
|
description: string;
|
|
102
102
|
testType: TestType;
|
|
103
103
|
category: "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
|
|
104
|
-
fileName: string;
|
|
105
104
|
endpoint: string;
|
|
105
|
+
fileName: string;
|
|
106
106
|
testId: string;
|
|
107
107
|
reasoning: string;
|
|
108
108
|
repository?: string | undefined;
|
|
@@ -125,8 +125,8 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
|
|
|
125
125
|
}, {
|
|
126
126
|
description: string;
|
|
127
127
|
testType: TestType;
|
|
128
|
-
fileName: string;
|
|
129
128
|
endpoint: string;
|
|
129
|
+
fileName: string;
|
|
130
130
|
testId: string;
|
|
131
131
|
reasoning: string;
|
|
132
132
|
repository?: string | undefined;
|
|
@@ -151,8 +151,8 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
|
|
|
151
151
|
description: string;
|
|
152
152
|
testType: TestType;
|
|
153
153
|
category: "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
|
|
154
|
-
fileName: string;
|
|
155
154
|
endpoint: string;
|
|
155
|
+
fileName: string;
|
|
156
156
|
testId: string;
|
|
157
157
|
reasoning: string;
|
|
158
158
|
repository?: string | undefined;
|
|
@@ -175,8 +175,8 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
|
|
|
175
175
|
}, {
|
|
176
176
|
description: string;
|
|
177
177
|
testType: TestType;
|
|
178
|
-
fileName: string;
|
|
179
178
|
endpoint: string;
|
|
179
|
+
fileName: string;
|
|
180
180
|
testId: string;
|
|
181
181
|
reasoning: string;
|
|
182
182
|
repository?: string | undefined;
|
|
@@ -201,8 +201,8 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
|
|
|
201
201
|
description: string;
|
|
202
202
|
testType: TestType;
|
|
203
203
|
category: "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
|
|
204
|
-
fileName: string;
|
|
205
204
|
endpoint: string;
|
|
205
|
+
fileName: string;
|
|
206
206
|
testId: string;
|
|
207
207
|
reasoning: string;
|
|
208
208
|
repository?: string | undefined;
|
|
@@ -1021,7 +1021,11 @@ export function registerSubmitReportTool(server) {
|
|
|
1021
1021
|
testType: v.testType,
|
|
1022
1022
|
endpoint: v.endpoint,
|
|
1023
1023
|
action: v.action,
|
|
1024
|
-
|
|
1024
|
+
// A baseline refresh (SKYR-4298) leaves the spec untouched, so name the
|
|
1025
|
+
// replaced PNGs here — the reviewer's cue to open the image diff in the PR.
|
|
1026
|
+
description: v.rebaselineSnapshots?.length
|
|
1027
|
+
? `${v.rationale} (visual baseline refreshed: ${v.rebaselineSnapshots.join(", ")})`
|
|
1028
|
+
: v.rationale,
|
|
1025
1029
|
beforeDetails: "",
|
|
1026
1030
|
afterDetails: "",
|
|
1027
1031
|
}));
|
|
@@ -8,7 +8,8 @@ import * as path from "path";
|
|
|
8
8
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
9
9
|
import { dualChannelResult, toolError } from "../../utils/utils.js";
|
|
10
10
|
import { isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
|
|
11
|
-
import {
|
|
11
|
+
import { REBASELINE_SNAPSHOT_NAME_RE, looksLikeOnDiskBaselineName } from "../../utils/rebaselineSnapshots.js";
|
|
12
|
+
import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRebaselineStrategy, buildRegenerateStrategy, buildDeleteStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
|
|
12
13
|
/**
|
|
13
14
|
* Compute a suggested new filename when an endpoint is renamed.
|
|
14
15
|
*/
|
|
@@ -102,6 +103,15 @@ const recommendationSchema = z.object({
|
|
|
102
103
|
.optional()
|
|
103
104
|
.describe("For a large UPDATE file, the concrete per-site edits you already located while reading it — one entry per place that must change, with a verbatim anchor. " +
|
|
104
105
|
"Surfaced to the apply step as a checklist so it edits each site directly instead of re-grepping/re-paging the file. Provide this whenever an UPDATE touches more than a couple of sites."),
|
|
106
|
+
rebaselineSnapshots: z
|
|
107
|
+
// Loose here on purpose: one malformed name must not reject the whole batch and
|
|
108
|
+
// lose every other verdict. Entries are validated per item below; invalid ones
|
|
109
|
+
// are dropped and surfaced in the response.
|
|
110
|
+
.array(z.string())
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("UPDATE only — the toHaveScreenshot() baseline filenames (e.g. [\"page-001.png\"]) whose captured page/element/region the diff visibly changed, so the committed PNG is stale (SKYR-4298). " +
|
|
113
|
+
"List only the baselines the change reaches; leave unaffected ones out. These are refreshed at the final skyramp_execute_test run via its rebaselineSnapshots parameter, never by editing the assertion, its maxDiffPixelRatio, or the PNG. " +
|
|
114
|
+
"Omit when the diff does not explain a screenshot mismatch — that failure stays red."),
|
|
105
115
|
renamedEndpoints: z
|
|
106
116
|
.array(z.object({
|
|
107
117
|
oldPath: z.string().describe("Previous endpoint path"),
|
|
@@ -182,6 +192,7 @@ export function registerActionsTool(server) {
|
|
|
182
192
|
// and passes results here directly — analyzeTestHealthTool never writes assessment
|
|
183
193
|
// data back to the state file.
|
|
184
194
|
const recommendations = [];
|
|
195
|
+
const droppedRebaselineNotes = [];
|
|
185
196
|
(args.recommendations ?? []).forEach((rec) => {
|
|
186
197
|
// Schema requires absolute paths; resolve any relative paths defensively
|
|
187
198
|
// against repoRoot in case the LLM sends a relative path despite the schema.
|
|
@@ -294,6 +305,26 @@ export function registerActionsTool(server) {
|
|
|
294
305
|
resolvedPomFile = undefined;
|
|
295
306
|
}
|
|
296
307
|
}
|
|
308
|
+
if (rec.action !== DriftAction.Update && rec.rebaselineSnapshots?.length) {
|
|
309
|
+
droppedRebaselineNotes.push(`${rec.testFilePath}: rebaselineSnapshots [${rec.rebaselineSnapshots.join(", ")}] ignored on ${rec.action} — a baseline refresh is only meaningful on UPDATE.`);
|
|
310
|
+
}
|
|
311
|
+
// Per-entry validation (the schema is deliberately loose, see above).
|
|
312
|
+
const validBaselines = [];
|
|
313
|
+
for (const raw of rec.rebaselineSnapshots ?? []) {
|
|
314
|
+
const name = raw.trim();
|
|
315
|
+
if (!REBASELINE_SNAPSHOT_NAME_RE.test(name)) {
|
|
316
|
+
droppedRebaselineNotes.push(`${rec.testFilePath}: rebaselineSnapshots entry ${JSON.stringify(raw)} dropped — must be the bare .png name the test passes to toHaveScreenshot(), e.g. "page-001.png" (no path, commas, or whitespace).`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (looksLikeOnDiskBaselineName(name)) {
|
|
320
|
+
// Kept — a test can legitimately pass a name like this — but flagged: the
|
|
321
|
+
// on-disk file carries Playwright's project/platform suffix, and passing it
|
|
322
|
+
// by mistake refreshes nothing (the execution result will say NOT refreshed).
|
|
323
|
+
droppedRebaselineNotes.push(`${rec.testFilePath}: rebaselineSnapshots entry "${name}" kept but looks like an on-disk filename (Playwright's project/platform suffix). If the test passes "${name.replace(/-(?:[^-]+-)?(?:darwin|linux|win32)\.png$/i, ".png")}" to toHaveScreenshot(), pass that instead.`);
|
|
324
|
+
}
|
|
325
|
+
if (!validBaselines.includes(name))
|
|
326
|
+
validBaselines.push(name);
|
|
327
|
+
}
|
|
297
328
|
recommendations.push({
|
|
298
329
|
testFilePath: resolvedFile,
|
|
299
330
|
pomFile: resolvedPomFile,
|
|
@@ -306,6 +337,19 @@ export function registerActionsTool(server) {
|
|
|
306
337
|
updateInstructions: rec.updateInstructions ?? "",
|
|
307
338
|
renamedEndpoints: rec.renamedEndpoints ?? [],
|
|
308
339
|
...(rec.editSites?.length ? { editSites: rec.editSites } : {}),
|
|
340
|
+
// Only an UPDATE refreshes a baseline. A REGENERATEd spec that reuses a
|
|
341
|
+
// baseline name still compares against the committed PNG, and DELETE
|
|
342
|
+
// removes the spec — neither is a refresh, so the list is dropped (and
|
|
343
|
+
// surfaced in the response below rather than silently discarded).
|
|
344
|
+
...(rec.action === DriftAction.Update && validBaselines.length
|
|
345
|
+
? {
|
|
346
|
+
rebaselineSnapshots: validBaselines,
|
|
347
|
+
// No updateInstructions → the refresh is the whole maintenance and
|
|
348
|
+
// the report gate must not expect a spec/POM edit. With
|
|
349
|
+
// updateInstructions the gate holds the UPDATE to both.
|
|
350
|
+
rebaselineOnly: !rec.updateInstructions,
|
|
351
|
+
}
|
|
352
|
+
: {}),
|
|
309
353
|
});
|
|
310
354
|
});
|
|
311
355
|
// Derive the no-action set here instead of asking the LLM to enumerate it.
|
|
@@ -340,6 +384,11 @@ export function registerActionsTool(server) {
|
|
|
340
384
|
rationale: r.rationale,
|
|
341
385
|
testType: r.testType,
|
|
342
386
|
endpoint: r.endpoint,
|
|
387
|
+
// Persisted so the report-time working-tree check knows this UPDATE's
|
|
388
|
+
// change is the refreshed PNG, not the spec (SKYR-4298).
|
|
389
|
+
...(r.rebaselineSnapshots?.length
|
|
390
|
+
? { rebaselineSnapshots: r.rebaselineSnapshots, rebaselineOnly: r.rebaselineOnly === true }
|
|
391
|
+
: {}),
|
|
343
392
|
})),
|
|
344
393
|
}, { repo: args.repository, repositoryPath, step: "actions" });
|
|
345
394
|
}
|
|
@@ -449,6 +498,7 @@ export function registerActionsTool(server) {
|
|
|
449
498
|
suggestedNewFile,
|
|
450
499
|
updateInstructions: rec.updateInstructions,
|
|
451
500
|
rationale: rec.rationale,
|
|
501
|
+
rebaselineSnapshots: rec.rebaselineSnapshots,
|
|
452
502
|
}));
|
|
453
503
|
}
|
|
454
504
|
// ── REGENERATE: emit overwrite instructions ──
|
|
@@ -500,6 +550,10 @@ export function registerActionsTool(server) {
|
|
|
500
550
|
deleteRecommendations.length === 0) {
|
|
501
551
|
sections.push(`No action required. All existing tests appear healthy.`);
|
|
502
552
|
}
|
|
553
|
+
if (droppedRebaselineNotes.length > 0) {
|
|
554
|
+
sections.push(`## rebaselineSnapshots notes (${droppedRebaselineNotes.length})\n\n` +
|
|
555
|
+
droppedRebaselineNotes.map((n) => `- ${n}`).join("\n"));
|
|
556
|
+
}
|
|
503
557
|
sections.push(`**This tool is currently in Early Preview stage. Please verify the results.**`);
|
|
504
558
|
const responseText = sections.join("\n\n");
|
|
505
559
|
// ── Build LLM instructions ──
|
|
@@ -534,18 +588,32 @@ export function registerActionsTool(server) {
|
|
|
534
588
|
// Update context: per-file guidance + current content for the downstream LLM.
|
|
535
589
|
// Including file content avoids re-reads on each Edit turn, reducing token usage.
|
|
536
590
|
const updateContext = updateRecommendations
|
|
537
|
-
.filter((rec) => !!rec.updateInstructions)
|
|
591
|
+
.filter((rec) => !!rec.updateInstructions || !!rec.rebaselineSnapshots?.length)
|
|
538
592
|
.map((rec) => {
|
|
539
593
|
const editTarget = rec.pomFile ?? rec.testFilePath;
|
|
594
|
+
const hasEdit = !!rec.updateInstructions;
|
|
540
595
|
return {
|
|
541
596
|
file: editTarget,
|
|
542
|
-
|
|
597
|
+
// A rebaseline-only entry has nothing to edit: no context, and an
|
|
598
|
+
// explicit edit_required: false so the strategy's "make the changes
|
|
599
|
+
// described in context" cannot apply to it (rationale is always set,
|
|
600
|
+
// so it must not stand in for edit instructions here).
|
|
601
|
+
...(hasEdit ? { context: rec.updateInstructions } : { edit_required: false }),
|
|
543
602
|
...(rec.editSites?.length ? { edit_sites: rec.editSites } : {}),
|
|
603
|
+
// The baselines to pass as skyramp_execute_test's rebaselineSnapshots for
|
|
604
|
+
// the SPEC (testFilePath, which is what runs) — never `file`, which is
|
|
605
|
+
// the page object for a POM-backed UPDATE.
|
|
606
|
+
...(rec.rebaselineSnapshots?.length
|
|
607
|
+
? { spec_file: rec.testFilePath, rebaseline_snapshots: rec.rebaselineSnapshots }
|
|
608
|
+
: {}),
|
|
544
609
|
};
|
|
545
610
|
});
|
|
546
611
|
if (updateContext.length > 0) {
|
|
547
612
|
llmInstructionsObj.update_context = updateContext;
|
|
548
613
|
llmInstructionsObj.update_strategy = buildUpdateStrategy();
|
|
614
|
+
if (updateContext.some((u) => "rebaseline_snapshots" in u)) {
|
|
615
|
+
llmInstructionsObj.rebaseline_strategy = buildRebaselineStrategy();
|
|
616
|
+
}
|
|
549
617
|
}
|
|
550
618
|
// REGENERATE context: existing file content gives the generation tool the
|
|
551
619
|
// endpoint URL, auth pattern, test type, and language to replicate.
|
|
@@ -6,6 +6,7 @@ import type { FrontendFileIntegration } from "../../types/FrontendIntegration.js
|
|
|
6
6
|
import { TraceFile } from "../../types/RepositoryAnalysis.js";
|
|
7
7
|
import { BranchDiffData } from "../../utils/branchDiff.js";
|
|
8
8
|
import { ScannedEndpoint } from "../../utils/repoScanner.js";
|
|
9
|
+
import { RemovedUiElement } from "../../utils/removedUiElements.js";
|
|
9
10
|
import { TraceParseResult } from "../../utils/trace-parser.js";
|
|
10
11
|
/** Exported for testing: maps a parsed trace result to a TraceFile. */
|
|
11
12
|
export declare function buildTraceFileEntry(tracePath: string, result: TraceParseResult): TraceFile;
|
|
@@ -28,6 +29,15 @@ export declare const analyzeChangesInputSchema: {
|
|
|
28
29
|
export declare const NO_UI_INSTRUCTIONS = "No UI changes detected \u2014 no blueprint capture needed.";
|
|
29
30
|
export declare const NO_RESOLVABLE_URLS_INSTRUCTIONS = "Frontend changes detected but no candidate URLs could be resolved (no route files matched the changed files or their importers, and no frontend baseUrl to fall back to). UI recommendations will be source-grounded only.";
|
|
30
31
|
export declare function buildCaptureInstructions(pages: CandidateUiPage[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Instruction block for data-testids the diff removed from pages that survive
|
|
34
|
+
* (SKYR-4305). The UI analog of the `[removed]` endpoint 404 guard: one UI
|
|
35
|
+
* candidate per element, asserting the element is gone plus a positive check on a
|
|
36
|
+
* retained element of the same page. Whole-feature removals never reach here —
|
|
37
|
+
* collectRemovedUiElements drops elements whose file was deleted or whose page
|
|
38
|
+
* could not be resolved.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildRemovedElementsInstructions(elements: RemovedUiElement[]): string;
|
|
31
41
|
/**
|
|
32
42
|
* Instruction block for changed frontend files the server determined have no
|
|
33
43
|
* production importer (SKYR-3855) — dead/orphaned components. Used both to
|