@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
@@ -4,8 +4,10 @@ import { ScenarioCategory } from "../types/TestRecommendation.js";
4
4
  export interface RankOptions {
5
5
  /**
6
6
  * Categories that take the top carve-out tier ahead of everything else.
7
- * Defaults to the `CATEGORY_PRIORITY === "CRITICAL"` categories (bug_caught only —
8
- * new_endpoint is MEDIUM, not carved out). Exposed so phase 2 can tune the carve-out
7
+ * Defaults to the `CATEGORY_PRIORITY === "CRITICAL"` categories (bug_caught and
8
+ * requirement_conflict — new_endpoint is MEDIUM, not carved out). The two CRITICAL
9
+ * categories are carved out independently, so a requirement conflict never competes
10
+ * with a code-review bug for one slot. Exposed so phase 2 can tune the carve-out
9
11
  * WITHOUT reintroducing the agent's priority tag as a ranking input.
10
12
  */
11
13
  carveOutCategories?: ScenarioCategory[];
@@ -32,7 +34,7 @@ export interface SelectPlanContext extends BudgetContext {
32
34
  * independent of input order (the final tiebreak is the stable `candidateId`).
33
35
  *
34
36
  * Ordering (highest first):
35
- * 1. Carve-out — CRITICAL-category scenarios (bug_caught), preserving the
37
+ * 1. Carve-out — CRITICAL-category scenarios (bug_caught, requirement_conflict), preserving the
36
38
  * protected-first convention of `roundRobinByType` /
37
39
  * `prioritizeAttackSurfaceBundles`.
38
40
  * 2. Verified discriminators — candidates whose declared discriminator survived
@@ -14,7 +14,7 @@ const DEFAULT_CARVE_OUT_CATEGORIES = Object.keys(CATEGORY_PRIORITY).filter((cate
14
14
  * independent of input order (the final tiebreak is the stable `candidateId`).
15
15
  *
16
16
  * Ordering (highest first):
17
- * 1. Carve-out — CRITICAL-category scenarios (bug_caught), preserving the
17
+ * 1. Carve-out — CRITICAL-category scenarios (bug_caught, requirement_conflict), preserving the
18
18
  * protected-first convention of `roundRobinByType` /
19
19
  * `prioritizeAttackSurfaceBundles`.
20
20
  * 2. Verified discriminators — candidates whose declared discriminator survived
@@ -22,4 +22,4 @@ export declare function isValidEnvVarName(name: string): boolean;
22
22
  /**
23
23
  * Build the environment variable array for the Docker executor container.
24
24
  */
25
- export declare function buildContainerEnv(options: Pick<TestExecutionOptions, "token" | "language" | "useHostNetwork">, saveStoragePath?: string, hostEnv?: Record<string, string | undefined>, passthroughNames?: string[]): string[];
25
+ export declare function buildContainerEnv(options: Pick<TestExecutionOptions, "token" | "language" | "useHostNetwork" | "rebaselineSnapshots">, saveStoragePath?: string, hostEnv?: Record<string, string | undefined>, passthroughNames?: string[]): string[];
@@ -77,6 +77,18 @@ export function buildContainerEnv(options, saveStoragePath, hostEnv = process.en
77
77
  ...(options.token ? [`SKYRAMP_TEST_TOKEN=${options.token}`] : []),
78
78
  "SKYRAMP_IN_DOCKER=true",
79
79
  ];
80
+ // Visual-snapshot baselines this run replaces (SKYR-4298). SmartPlaywright
81
+ // reads SKYRAMP_UPDATE_SNAPSHOTS and re-captures each named baseline through
82
+ // its first-run path instead of comparing. Set here, first-class, because the
83
+ // SKYRAMP_ prefix is reserved from workspace passthrough below — nothing else
84
+ // can put it in the container. Omitted entirely when nothing is to refresh, so
85
+ // the default stays "compare".
86
+ const rebaseline = (options.rebaselineSnapshots ?? [])
87
+ .map((n) => n.trim())
88
+ .filter(Boolean);
89
+ if (rebaseline.length > 0) {
90
+ env.push(`SKYRAMP_UPDATE_SNAPSHOTS=${rebaseline.join(",")}`);
91
+ }
80
92
  // Skyramp-generated tests are standalone HTTP tests that never need host repo
81
93
  // conftest.py files or pytest configuration. --noconftest prevents loading any
82
94
  // conftest in the test directory tree (avoids missing deps like boto3, django).
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { TestExecutionResult } from "../types/TestExecution.js";
3
3
  import { TestType } from "../types/TestTypes.js";
4
+ import { MaintenanceActionCore, TestAnalysisResult } from "../types/TestAnalysis.js";
4
5
  export declare const CONTRACT_EXECUTION_MODES: readonly ["provider", "consumer"];
5
6
  export type ContractExecutionMode = (typeof CONTRACT_EXECUTION_MODES)[number];
6
7
  /**
@@ -20,6 +21,16 @@ export declare function shouldInjectSkyrampBaseUrl(testType: TestType, contractM
20
21
  * its own attachment line.
21
22
  */
22
23
  export declare function withVideoInfo(output: string, videoPath?: string): string;
24
+ /**
25
+ * Normalize the `rebaselineSnapshots` request (SKYR-4298): dedupe and drop blanks,
26
+ * and refuse it on the pre-edit run. A refresh there would overwrite the very
27
+ * evidence that the baseline is stale, and `beforeStatus` would read Pass for a
28
+ * test that failed against the committed image.
29
+ */
30
+ export declare function resolveRebaselineSnapshots(requested: string[] | undefined, phase: "before" | "after" | undefined): {
31
+ snapshots: string[];
32
+ error?: string;
33
+ };
23
34
  /**
24
35
  * The failure text the agent receives. Everything it needs has to be in here:
25
36
  * only a tool's return value reaches the transcript, and Claude Code does not
@@ -37,5 +48,74 @@ export declare function withVideoInfo(output: string, videoPath?: string): strin
37
48
  * agent at authentication on a run whose output was empty, and then it keyed off
38
49
  * the bare number, which pointed it there on a test count or a line number.
39
50
  */
51
+ /** The on-disk files a requested baseline name resolves to; empty when none matches. */
52
+ export type BaselineFileState = Array<{
53
+ file: string;
54
+ size: number;
55
+ mtimeMs: number;
56
+ }>;
57
+ /**
58
+ * Snapshot of the requested baselines under `<spec>-snapshots/` (SKYR-4298): for
59
+ * each requested name, every PNG whose name matches the stem (a spec may hold both
60
+ * `<stem>-linux.png` and `<stem>-chromium-linux.png`; latching onto one of them would
61
+ * misreport the other) with its size and mtime. Taken before and after the run so
62
+ * the tool can tell the agent which baselines were actually rewritten — SmartPlaywright
63
+ * is the only party that knows the exact filename, and an executor image that lacks
64
+ * SKYRAMP_UPDATE_SNAPSHOTS (or a name matching no toHaveScreenshot call) leaves
65
+ * every file untouched.
66
+ */
67
+ export declare function readBaselineState(specFile: string, requested: string[]): Record<string, BaselineFileState>;
68
+ /**
69
+ * Which requested baselines changed on disk between two readBaselineState calls, and
70
+ * which files carried the change (the ones to stage).
71
+ */
72
+ export declare function diffBaselineState(before: Record<string, BaselineFileState>, after: Record<string, BaselineFileState>): {
73
+ refreshed: string[];
74
+ notRefreshed: string[];
75
+ refreshedFiles: Record<string, string[]>;
76
+ };
77
+ /**
78
+ * Authorize a refresh against the persisted maintenance state (SKYR-4298). The
79
+ * parameter description tells the agent to pass exactly what skyramp_actions
80
+ * returned, but prose cannot tell an authorized list from an invented one, so the
81
+ * tool checks: an UPDATE verdict for this spec must list every requested name, and
82
+ * the spec's phase:"before" run must already be recorded — a refresh before that
83
+ * run would overwrite the very evidence that the baseline is stale, whatever
84
+ * `phase` says or omits. A newly generated test has no verdict and no committed
85
+ * baseline, so it can never be refreshed here.
86
+ */
87
+ export declare function authorizeRebaseline(stateData: {
88
+ existingTests?: TestAnalysisResult[];
89
+ maintenanceVerdicts?: MaintenanceActionCore[];
90
+ } | null | undefined, testFile: string, requested: string[]): {
91
+ error?: string;
92
+ };
93
+ /**
94
+ * Reconcile the persisted verdict with what the executor actually did (SKYR-4298).
95
+ * An executor image whose @skyramp/skyramp predates SKYRAMP_UPDATE_SNAPSHOTS rewrites
96
+ * nothing; left alone, the verdict would still promise a refresh, the report gate
97
+ * would refuse the report, and nothing in the prompt makes the agent's way out
98
+ * deterministic. So: names that were not refreshed are dropped from the verdict; a
99
+ * rebaseline-only UPDATE with nothing left becomes VERIFY (the test stays red, the
100
+ * rationale says why), and an UPDATE that also carried edits keeps UPDATE and is
101
+ * held to its edit. The report then reflects what happened, not what was asked.
102
+ */
103
+ export declare function applyRefreshOutcomeToVerdicts(verdicts: MaintenanceActionCore[], testFile: string, outcome: {
104
+ refreshed: string[];
105
+ notRefreshed: string[];
106
+ }, executorImage: string): {
107
+ verdicts: MaintenanceActionCore[];
108
+ note?: string;
109
+ };
110
+ /**
111
+ * The refresh outcome line appended to the execution result, on pass and on fail
112
+ * alike. Without it a stale-baseline run that could not refresh reads as a bare
113
+ * "Screenshot comparison failed", and the report gate then tells the agent to run
114
+ * with rebaselineSnapshots set — which it already did.
115
+ */
116
+ export declare function describeRefreshOutcome(outcome: {
117
+ refreshed: string[];
118
+ notRefreshed: string[];
119
+ }): string;
40
120
  export declare function buildExecutionFailureText(result: TestExecutionResult): string;
41
121
  export declare function registerExecuteSkyrampTestTool(server: McpServer): void;
@@ -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
- * The failure text the agent receives. Everything it needs has to be in here:
101
- * only a tool's return value reaches the transcript, and Claude Code does not
102
- * capture an MCP server's stderr, so anything this omits is unrecoverable once
103
- * the run ends.
104
- *
105
- * `output` alone was not enough. When the executor produced none, the message
106
- * read "Test execution failed:" and stopped — so the agent inferred a cause and
107
- * reported it as fact. Measured in eval run 32220788735: it blamed an
108
- * unreachable backend while the app was answering 200 on both the host and the
109
- * docker bridge, and that invented cause reached `issuesFound`.
110
- *
111
- * The 401 hint is emitted only when the output carries a 401 in a status-shaped
112
- * position — see HTTP_401_SHAPES. It used to be unconditional, which pointed the
113
- * agent at authentication on a run whose output was empty, and then it keyed off
114
- * the bare number, which pointed it there on a test count or a line number.
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
- .optional()
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;
@@ -13,6 +13,7 @@ import { toolError, testFileMatches } from "../utils/utils.js";
13
13
  import { matchesApprovedPlan } from "../utils/planMatchKeys.js";
14
14
  import { isTestbotEnabled } from "../utils/featureFlags.js";
15
15
  import { findInvalidSourceCitations, findUnchangedFileClaims, listChangedFiles, listChangedFilesAcross, listChangedFilesAbs, } from "../utils/reportVerification.js";
16
+ import { isPlanOnlyMode } from "../utils/planOnlyMode.js";
16
17
  import { getReportLanguage, isEnforcedReportLanguage, findLanguageViolations, findLanguageNearMisses, reportLanguageDisplayName, } from "../utils/reportLanguage.js";
17
18
  import { canonicalTestPath, findAssertionRecordByFileName, rederiveAssertionOutcome, } from "./code-refactor/assertion-state.js";
18
19
  import { rederiveReuseOutcome, reuseChainSkipped, samePath, } from "./code-refactor/reuse-state.js";
@@ -343,7 +344,8 @@ const issueFoundSchema = z
343
344
  .optional()
344
345
  .describe("Issue severity. critical = feature broken/unusable (e.g. page doesn't load, data corruption). " +
345
346
  "high = incorrect behavior (e.g. wrong calculation, stale data returned). " +
346
- "medium = minor functional gap. low = cosmetic or informational."),
347
+ "medium = minor functional gap. low = cosmetic or informational. " +
348
+ "Floor: a requirement conflict — the PR title/description (or a requirements file it references) states a requirement the implemented behavior contradicts — is 'high' or 'critical', never 'medium' or below."),
347
349
  category: z
348
350
  .nativeEnum(IssueFoundCategory)
349
351
  .describe("Issue classification. bug = a product/code defect, e.g. found by a test or in the diff. " +
@@ -1019,7 +1021,11 @@ export function registerSubmitReportTool(server) {
1019
1021
  testType: v.testType,
1020
1022
  endpoint: v.endpoint,
1021
1023
  action: v.action,
1022
- description: v.rationale,
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,
1023
1029
  beforeDetails: "",
1024
1030
  afterDetails: "",
1025
1031
  }));
@@ -1109,6 +1115,9 @@ export function registerSubmitReportTool(server) {
1109
1115
  newTests: dedupedNewTests,
1110
1116
  verdicts: stateData.maintenanceVerdicts ?? [],
1111
1117
  primaryRepository: fullState?.metadata?.repository,
1118
+ // SKYR-4250: captured at prompt-render time, since planOnly is a
1119
+ // prompt-only argument (mirrors the SKYR-4185 language capture).
1120
+ planOnly: isPlanOnlyMode(),
1112
1121
  });
1113
1122
  if (unbacked.length > 0) {
1114
1123
  // Offer the attribution-correction path explicitly and show what DID change: