@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
|
@@ -1,8 +1,3 @@
|
|
|
1
1
|
import type { AssertionLanguage } from "./metrics.js";
|
|
2
2
|
import { type LintFinding, type LintOptions } from "./lint-types.js";
|
|
3
|
-
/** UI (Playwright) lints. Python (playwright-python) UI specs deliberately get
|
|
4
|
-
* the site-scoped lints only: the structural rules' patterns and accept-lists
|
|
5
|
-
* are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
|
|
6
|
-
* forms), and porting them without a python golden corpus to validate against
|
|
7
|
-
* would recreate the false-block class review round 2 removed. Pinned by test. */
|
|
8
3
|
export declare function lintUiSpec(raw: string, language: AssertionLanguage, opts?: LintOptions): LintFinding[];
|
|
@@ -222,6 +222,36 @@ function lintUnassertedTrailingAction(stripped) {
|
|
|
222
222
|
* are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
|
|
223
223
|
* forms), and porting them without a python golden corpus to validate against
|
|
224
224
|
* would recreate the false-block class review round 2 removed. Pinned by test. */
|
|
225
|
+
// SKYR-4305 — a removal guard (`toBeHidden()` / `not.toBeVisible()` on an element a
|
|
226
|
+
// PR removed) is only meaningful next to a positive assertion on the same page: alone
|
|
227
|
+
// it passes on a blank page, a 404, a typo'd testid, or a page the element never
|
|
228
|
+
// rendered on. `toBeHidden` is a PARTIAL_MATCHER (weight 2), so the strength gate does
|
|
229
|
+
// not catch a negative-only spec; this lint does.
|
|
230
|
+
const ABSENCE_ASSERT_RE = /\.\s*(?:toBeHidden|to_be_hidden)\s*\(|\.\s*not\s*\.\s*(?:toBeVisible|toBeAttached)\s*\(|\.\s*not_to_be_(?:visible|attached)\s*\(/g;
|
|
231
|
+
// Only Playwright LOCATOR/page matchers count as the positive half of the pair —
|
|
232
|
+
// they prove something rendered. Generic Jest matchers (toBe/toEqual/toHaveLength)
|
|
233
|
+
// are deliberately absent: `expect(resp.status()).toBe(200)` next to a lone
|
|
234
|
+
// toBeHidden() proves nothing about the page.
|
|
235
|
+
const POSITIVE_ASSERT_RE = /\.\s*(?:toHaveText|toContainText|toHaveValue|toHaveValues|toHaveAttribute|toHaveCount|toHaveURL|toHaveTitle|toHaveId|toHaveClass|toHaveCSS|toHaveJSProperty|toHaveAccessibleName|toHaveAccessibleDescription|toHaveRole|toHaveScreenshot|toBeVisible|toBeChecked|toBeEnabled|toBeDisabled|toBeEditable|toBeEmpty|toBeFocused|toBeInViewport|toContainClass|to_have_text|to_contain_text|to_have_value|to_have_values|to_have_attribute|to_have_count|to_have_url|to_have_title|to_have_id|to_have_class|to_have_css|to_have_js_property|to_have_accessible_name|to_have_accessible_description|to_have_role|to_have_screenshot|to_be_visible|to_be_checked|to_be_enabled|to_be_disabled|to_be_editable|to_be_empty|to_be_focused|to_be_in_viewport|to_contain_class)\s*\(/;
|
|
236
|
+
function lintNegativeOnlySpec(commentless) {
|
|
237
|
+
ABSENCE_ASSERT_RE.lastIndex = 0;
|
|
238
|
+
const first = ABSENCE_ASSERT_RE.exec(commentless);
|
|
239
|
+
if (!first)
|
|
240
|
+
return [];
|
|
241
|
+
// Strip the absence assertions themselves, then look for a Playwright matcher
|
|
242
|
+
// on a rendered element (generic Jest matchers do not count — see POSITIVE_ASSERT_RE).
|
|
243
|
+
const remainder = commentless.replace(ABSENCE_ASSERT_RE, "");
|
|
244
|
+
ABSENCE_ASSERT_RE.lastIndex = 0;
|
|
245
|
+
if (POSITIVE_ASSERT_RE.test(remainder))
|
|
246
|
+
return [];
|
|
247
|
+
return [{
|
|
248
|
+
rule: "negative-only-spec",
|
|
249
|
+
severity: "hard",
|
|
250
|
+
line: lineOfOffset(commentless, first.index),
|
|
251
|
+
message: "Every locator assertion in this spec asserts absence (toBeHidden / not.toBeVisible); nothing proves the page rendered, so it passes on a blank page, a 404 or a typo'd selector.",
|
|
252
|
+
remediation: "Add at least one positive assertion on a retained element of the same page (toHaveText / toHaveValue / toHaveCount / toHaveURL) next to the absence assertions.",
|
|
253
|
+
}];
|
|
254
|
+
}
|
|
225
255
|
export function lintUiSpec(raw, language, opts) {
|
|
226
256
|
if (language === "java")
|
|
227
257
|
return [];
|
|
@@ -240,5 +270,7 @@ export function lintUiSpec(raw, language, opts) {
|
|
|
240
270
|
findings.push(...lintUnassertedTrailingAction(stripped));
|
|
241
271
|
}
|
|
242
272
|
findings.push(...lintTautologies(commentless, opts));
|
|
273
|
+
if (!maintenance)
|
|
274
|
+
findings.push(...lintNegativeOnlySpec(commentless));
|
|
243
275
|
return findings;
|
|
244
276
|
}
|
|
@@ -32,3 +32,36 @@ export declare const MAX_CHANGED_SELECTORS = 80;
|
|
|
32
32
|
* `ads-button--primary`).
|
|
33
33
|
*/
|
|
34
34
|
export declare function extractChangedSelectors(fileDiff: string): string[];
|
|
35
|
+
/** A removed element's identity: the `data-*` attribute and its literal value. */
|
|
36
|
+
export interface RemovedAttribute {
|
|
37
|
+
/** Lower-cased attribute name, e.g. `data-testid`, `data-cy`, `data-section`. */
|
|
38
|
+
attribute: string;
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
/** Identifying attribute VALUES that appear on `+` lines of a diff (any number of files). */
|
|
42
|
+
export declare function extractAddedAttributeValues(diff: string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Identifying `data-*` attributes one file's diff REMOVED: literals on `-` lines, minus the
|
|
45
|
+
* cases where the element still renders somewhere:
|
|
46
|
+
*
|
|
47
|
+
* - **moved / extracted** — the same value appears on a `+` line of this file or, via
|
|
48
|
+
* `addedValuesElsewhere`, anywhere else in the diff (a component extracted into a new
|
|
49
|
+
* file is the common shape);
|
|
50
|
+
* - **not an element** — a `-` line counts only when it opens a JSX/HTML tag or is a
|
|
51
|
+
* wrapped attribute line of a tag opened above it (the tag is carried until the `>`
|
|
52
|
+
* that closes it). A deleted comment, docs snippet, story arg or page-object
|
|
53
|
+
* `getByTestId(...)` call that merely mentions the attribute is not an element
|
|
54
|
+
* removal — the element itself may well survive on an unchanged context line;
|
|
55
|
+
* - **renamed** — a rename cannot be told apart by literal identity (old and new
|
|
56
|
+
* value differ by definition), so it is paired structurally: within the same hunk,
|
|
57
|
+
* each `-` line is paired with AT MOST ONE `+` line carrying a NEW identifying
|
|
58
|
+
* attribute — first by identical shape with the values blanked, else by the same
|
|
59
|
+
* opening tag — and a paired `+` line is consumed. So one added element suppresses
|
|
60
|
+
* one removal, not every removal in the hunk. This still errs toward suppressing a
|
|
61
|
+
* guard when one same-tag element is swapped for an unrelated new one.
|
|
62
|
+
*
|
|
63
|
+
* Feeds `uiContext.removedElements` (SKYR-4305) — the grounding for a removal guard
|
|
64
|
+
* (`toBeHidden()` on `getByTestId(value)` for `data-testid`, on
|
|
65
|
+
* `locator('[<attribute>="<value>"]')` for any other `data-*`).
|
|
66
|
+
*/
|
|
67
|
+
export declare function extractRemovedAttributes(fileDiff: string, addedValuesElsewhere?: Iterable<string>): RemovedAttribute[];
|
|
@@ -52,6 +52,28 @@ function isDistinctiveSelector(token, trusted) {
|
|
|
52
52
|
}
|
|
53
53
|
return hasBEM || (hyphens >= 2 && !COMMON_CSS_SELECTOR_TOKENS.test(token));
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The test-facing selector grammar, shared by every extractor in this module so the
|
|
57
|
+
* two cannot drift: `data-testid` / `data-test` / `data-test-id` / `data-cy` / `data-qa`
|
|
58
|
+
* attribute values (string literals only) and `getByTestId(...)` arguments.
|
|
59
|
+
* Case-insensitive on purpose: JSX authors write `data-testId` (demoshop) and the
|
|
60
|
+
* browser lowercases attribute names, so `getByTestId` matches either spelling.
|
|
61
|
+
*/
|
|
62
|
+
const TESTID_ATTR_RE = /\b(data-(?:test-?id|test|cy|qa))\b\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/gi;
|
|
63
|
+
const GET_BY_TEST_ID_RE = /getByTestId\(\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/gi;
|
|
64
|
+
/**
|
|
65
|
+
* Any other `data-*` attribute with a static string value (`data-section="session-id-debug"`).
|
|
66
|
+
* Untrusted: the value must pass the stricter distinctiveness rule, and attributes that carry
|
|
67
|
+
* component STATE rather than identity are excluded — they flip at runtime, so "absent" is
|
|
68
|
+
* meaningless for them.
|
|
69
|
+
*/
|
|
70
|
+
const GENERIC_DATA_ATTR_RE = /\b(data-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/gi;
|
|
71
|
+
const STATE_DATA_ATTRS = new Set([
|
|
72
|
+
"data-state", "data-theme", "data-orientation", "data-side", "data-align", "data-value",
|
|
73
|
+
"data-index", "data-slot", "data-disabled", "data-checked", "data-active", "data-open",
|
|
74
|
+
"data-loading", "data-size", "data-variant", "data-selected", "data-highlighted",
|
|
75
|
+
"data-focus", "data-hover", "data-pressed", "data-expanded", "data-placeholder", "data-status",
|
|
76
|
+
]);
|
|
55
77
|
/**
|
|
56
78
|
* Extract selector literals added or removed in a single file's diff slice.
|
|
57
79
|
*
|
|
@@ -74,8 +96,8 @@ export function extractChangedSelectors(fileDiff) {
|
|
|
74
96
|
// `data-testid="x"`, `data-testid={'x'}`, `data-testid={`x`}`. The closing delimiter
|
|
75
97
|
// deliberately rejects interpolated template literals (`data-testid={`members-${id}`}`),
|
|
76
98
|
// which would otherwise leak a partial prefix (`members-`) into the selector set.
|
|
77
|
-
const ATTR =
|
|
78
|
-
const GET_BY_TEST_ID =
|
|
99
|
+
const ATTR = TESTID_ATTR_RE;
|
|
100
|
+
const GET_BY_TEST_ID = GET_BY_TEST_ID_RE;
|
|
79
101
|
const KEBAB = /[a-z][a-z0-9]*(?:-{1,2}[a-z0-9]+)+/g;
|
|
80
102
|
// BEM base whose modifier is composed DYNAMICALLY, so no concrete class literal exists
|
|
81
103
|
// in the diff to grep: `ads-button--${kind}`, "ads-button--" + kind, clsx(base && `x--${y}`).
|
|
@@ -92,12 +114,12 @@ export function extractChangedSelectors(fileDiff) {
|
|
|
92
114
|
if (raw.startsWith("+++") || raw.startsWith("---"))
|
|
93
115
|
continue;
|
|
94
116
|
const line = raw.slice(1);
|
|
95
|
-
for (const re of [ATTR, GET_BY_TEST_ID]) {
|
|
117
|
+
for (const [re, valueGroup] of [[ATTR, 2], [GET_BY_TEST_ID, 1]]) {
|
|
96
118
|
re.lastIndex = 0;
|
|
97
119
|
let m;
|
|
98
120
|
while ((m = re.exec(line)) !== null) {
|
|
99
|
-
if (isDistinctiveSelector(m[
|
|
100
|
-
out.add(m[
|
|
121
|
+
if (isDistinctiveSelector(m[valueGroup], true))
|
|
122
|
+
out.add(m[valueGroup]);
|
|
101
123
|
if (out.size >= MAX_CHANGED_SELECTORS)
|
|
102
124
|
return [...out];
|
|
103
125
|
}
|
|
@@ -121,3 +143,172 @@ export function extractChangedSelectors(fileDiff) {
|
|
|
121
143
|
}
|
|
122
144
|
return [...out];
|
|
123
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Identifying `data-*` attributes on one source line: the trusted test-facing set (any
|
|
148
|
+
* casing), `getByTestId(...)` arguments (attributed to `data-testid`), and any other
|
|
149
|
+
* static `data-*` whose value is distinctive and whose attribute is not a state carrier.
|
|
150
|
+
*/
|
|
151
|
+
function attributesOnLine(line) {
|
|
152
|
+
const out = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const push = (attribute, value) => {
|
|
155
|
+
const key = `${attribute}=${value}`;
|
|
156
|
+
if (!seen.has(key)) {
|
|
157
|
+
seen.add(key);
|
|
158
|
+
out.push({ attribute, value });
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
let m;
|
|
162
|
+
TESTID_ATTR_RE.lastIndex = 0;
|
|
163
|
+
while ((m = TESTID_ATTR_RE.exec(line)) !== null) {
|
|
164
|
+
if (isDistinctiveSelector(m[2], true))
|
|
165
|
+
push(m[1].toLowerCase(), m[2]);
|
|
166
|
+
}
|
|
167
|
+
GET_BY_TEST_ID_RE.lastIndex = 0;
|
|
168
|
+
while ((m = GET_BY_TEST_ID_RE.exec(line)) !== null) {
|
|
169
|
+
if (isDistinctiveSelector(m[1], true))
|
|
170
|
+
push("data-testid", m[1]);
|
|
171
|
+
}
|
|
172
|
+
GENERIC_DATA_ATTR_RE.lastIndex = 0;
|
|
173
|
+
while ((m = GENERIC_DATA_ATTR_RE.exec(line)) !== null) {
|
|
174
|
+
const attribute = m[1].toLowerCase();
|
|
175
|
+
if (STATE_DATA_ATTRS.has(attribute))
|
|
176
|
+
continue;
|
|
177
|
+
if (seen.has(`${attribute}=${m[2]}`))
|
|
178
|
+
continue; // already captured by the trusted pass
|
|
179
|
+
if (isDistinctiveSelector(m[2], false))
|
|
180
|
+
push(attribute, m[2]);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Tag name of the JSX/HTML element a diff line opens, or "" — also "" for a comment
|
|
186
|
+
* line (`//`, `/*`, `*`, `{/*`, `<!--`), whose `<div …>` is text, not an element.
|
|
187
|
+
*/
|
|
188
|
+
function openingTagOf(line) {
|
|
189
|
+
const t = line.trim();
|
|
190
|
+
if (/^(?:\/\/|\/\*|\*|\{\s*\/\*|<!--)/.test(t))
|
|
191
|
+
return "";
|
|
192
|
+
return /<\s*([A-Za-z][\w.-]*)/.exec(t)?.[1]?.toLowerCase() ?? "";
|
|
193
|
+
}
|
|
194
|
+
/** Identifying attribute VALUES that appear on `+` lines of a diff (any number of files). */
|
|
195
|
+
export function extractAddedAttributeValues(diff) {
|
|
196
|
+
const out = new Set();
|
|
197
|
+
for (const raw of diff.split("\n")) {
|
|
198
|
+
if (!raw.startsWith("+") || raw.startsWith("+++"))
|
|
199
|
+
continue;
|
|
200
|
+
for (const a of attributesOnLine(raw.slice(1)))
|
|
201
|
+
out.add(a.value);
|
|
202
|
+
}
|
|
203
|
+
return [...out];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Identifying `data-*` attributes one file's diff REMOVED: literals on `-` lines, minus the
|
|
207
|
+
* cases where the element still renders somewhere:
|
|
208
|
+
*
|
|
209
|
+
* - **moved / extracted** — the same value appears on a `+` line of this file or, via
|
|
210
|
+
* `addedValuesElsewhere`, anywhere else in the diff (a component extracted into a new
|
|
211
|
+
* file is the common shape);
|
|
212
|
+
* - **not an element** — a `-` line counts only when it opens a JSX/HTML tag or is a
|
|
213
|
+
* wrapped attribute line of a tag opened above it (the tag is carried until the `>`
|
|
214
|
+
* that closes it). A deleted comment, docs snippet, story arg or page-object
|
|
215
|
+
* `getByTestId(...)` call that merely mentions the attribute is not an element
|
|
216
|
+
* removal — the element itself may well survive on an unchanged context line;
|
|
217
|
+
* - **renamed** — a rename cannot be told apart by literal identity (old and new
|
|
218
|
+
* value differ by definition), so it is paired structurally: within the same hunk,
|
|
219
|
+
* each `-` line is paired with AT MOST ONE `+` line carrying a NEW identifying
|
|
220
|
+
* attribute — first by identical shape with the values blanked, else by the same
|
|
221
|
+
* opening tag — and a paired `+` line is consumed. So one added element suppresses
|
|
222
|
+
* one removal, not every removal in the hunk. This still errs toward suppressing a
|
|
223
|
+
* guard when one same-tag element is swapped for an unrelated new one.
|
|
224
|
+
*
|
|
225
|
+
* Feeds `uiContext.removedElements` (SKYR-4305) — the grounding for a removal guard
|
|
226
|
+
* (`toBeHidden()` on `getByTestId(value)` for `data-testid`, on
|
|
227
|
+
* `locator('[<attribute>="<value>"]')` for any other `data-*`).
|
|
228
|
+
*/
|
|
229
|
+
export function extractRemovedAttributes(fileDiff, addedValuesElsewhere = []) {
|
|
230
|
+
const addedAnywhere = new Set(addedValuesElsewhere);
|
|
231
|
+
for (const v of extractAddedAttributeValues(fileDiff))
|
|
232
|
+
addedAnywhere.add(v);
|
|
233
|
+
// Split into hunks so rename pairing stays local.
|
|
234
|
+
const hunks = [];
|
|
235
|
+
for (const raw of fileDiff.split("\n")) {
|
|
236
|
+
if (raw.startsWith("@@")) {
|
|
237
|
+
hunks.push([]);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (hunks.length === 0)
|
|
241
|
+
hunks.push([]);
|
|
242
|
+
hunks[hunks.length - 1].push(raw);
|
|
243
|
+
}
|
|
244
|
+
const shapeOf = (line, attrs) => {
|
|
245
|
+
let shape = line.trim();
|
|
246
|
+
for (const a of attrs)
|
|
247
|
+
shape = shape.split(a.value).join("\u0000");
|
|
248
|
+
return shape;
|
|
249
|
+
};
|
|
250
|
+
const out = [];
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
for (const hunk of hunks) {
|
|
253
|
+
const removedLines = [];
|
|
254
|
+
const addedNew = [];
|
|
255
|
+
// A JSX attribute wrapped onto its own line (`+ data-testid="x"`) belongs to the
|
|
256
|
+
// element opened on an earlier line of the same side; carry that tag forward
|
|
257
|
+
// until the `>` that closes the opening tag, so a later comment or unrelated
|
|
258
|
+
// line in the hunk does not inherit it.
|
|
259
|
+
let lastMinusTag = "";
|
|
260
|
+
let lastPlusTag = "";
|
|
261
|
+
for (const raw of hunk) {
|
|
262
|
+
if (raw.startsWith("+++") || raw.startsWith("---"))
|
|
263
|
+
continue;
|
|
264
|
+
if (!(raw.startsWith("+") || raw.startsWith("-")))
|
|
265
|
+
continue;
|
|
266
|
+
const line = raw.slice(1);
|
|
267
|
+
const ownTag = openingTagOf(line);
|
|
268
|
+
const minus = raw.startsWith("-");
|
|
269
|
+
if (ownTag) {
|
|
270
|
+
if (minus)
|
|
271
|
+
lastMinusTag = ownTag;
|
|
272
|
+
else
|
|
273
|
+
lastPlusTag = ownTag;
|
|
274
|
+
}
|
|
275
|
+
const tag = ownTag || (minus ? lastMinusTag : lastPlusTag);
|
|
276
|
+
if (line.includes(">")) {
|
|
277
|
+
if (minus)
|
|
278
|
+
lastMinusTag = "";
|
|
279
|
+
else
|
|
280
|
+
lastPlusTag = "";
|
|
281
|
+
}
|
|
282
|
+
const attrs = attributesOnLine(line);
|
|
283
|
+
if (attrs.length === 0)
|
|
284
|
+
continue;
|
|
285
|
+
// Only an element (a tag opened on this line or carried from above) can be a
|
|
286
|
+
// removed element; a bare mention in a comment / docs / page object is not.
|
|
287
|
+
if (minus && tag === "")
|
|
288
|
+
continue;
|
|
289
|
+
if (minus)
|
|
290
|
+
removedLines.push({ attrs, tag, shape: shapeOf(line, attrs) });
|
|
291
|
+
else
|
|
292
|
+
addedNew.push({ tag, shape: shapeOf(line, attrs), consumed: false });
|
|
293
|
+
}
|
|
294
|
+
// Pair each removed line with at most ONE new-side line: identical blanked shape
|
|
295
|
+
// first, else the same opening tag. A paired + line is consumed, so one added
|
|
296
|
+
// element suppresses one removal (a moved value is handled by addedAnywhere).
|
|
297
|
+
for (const r of removedLines) {
|
|
298
|
+
const partner = addedNew.find((a) => !a.consumed && a.shape === r.shape)
|
|
299
|
+
?? addedNew.find((a) => !a.consumed && a.tag === r.tag);
|
|
300
|
+
if (partner) {
|
|
301
|
+
partner.consumed = true;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const a of r.attrs) {
|
|
305
|
+
const key = `${a.attribute}=${a.value}`;
|
|
306
|
+
if (!addedAnywhere.has(a.value) && !seen.has(key)) {
|
|
307
|
+
seen.add(key);
|
|
308
|
+
out.push(a);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
@@ -22,7 +22,10 @@ export declare function isIndexModule(file: string): boolean;
|
|
|
22
22
|
* (`@/x`, `~/x`, `~~/x`, `#/x`, `src/x`) are accepted when the module directory
|
|
23
23
|
* ends with the aliased path AND the importer lives under the same root that the
|
|
24
24
|
* alias maps to — `packages/store/Page.tsx` importing `@/app/shop/_constants` does
|
|
25
|
-
* not match `packages/admin/app/shop/_constants/index.ts`.
|
|
25
|
+
* not match `packages/admin/app/shop/_constants/index.ts`. Prefix-less
|
|
26
|
+
* multi-segment specifiers (`Components/Foo` under tsconfig `baseUrl: "src"` —
|
|
27
|
+
* SKYR-4287) go through the same suffix + shared-root check; only single-segment
|
|
28
|
+
* prefix-less specifiers (`react`) are treated as npm packages. All paths are
|
|
26
29
|
* repo-relative with forward slashes.
|
|
27
30
|
*/
|
|
28
31
|
export declare function importsIndexModule(importerFile: string, importerContent: string, indexFile: string): boolean;
|
|
@@ -60,7 +60,10 @@ export function isIndexModule(file) {
|
|
|
60
60
|
* (`@/x`, `~/x`, `~~/x`, `#/x`, `src/x`) are accepted when the module directory
|
|
61
61
|
* ends with the aliased path AND the importer lives under the same root that the
|
|
62
62
|
* alias maps to — `packages/store/Page.tsx` importing `@/app/shop/_constants` does
|
|
63
|
-
* not match `packages/admin/app/shop/_constants/index.ts`.
|
|
63
|
+
* not match `packages/admin/app/shop/_constants/index.ts`. Prefix-less
|
|
64
|
+
* multi-segment specifiers (`Components/Foo` under tsconfig `baseUrl: "src"` —
|
|
65
|
+
* SKYR-4287) go through the same suffix + shared-root check; only single-segment
|
|
66
|
+
* prefix-less specifiers (`react`) are treated as npm packages. All paths are
|
|
64
67
|
* repo-relative with forward slashes.
|
|
65
68
|
*/
|
|
66
69
|
export function importsIndexModule(importerFile, importerContent, indexFile) {
|
|
@@ -76,7 +79,16 @@ export function importsIndexModule(importerFile, importerContent, indexFile) {
|
|
|
76
79
|
continue;
|
|
77
80
|
}
|
|
78
81
|
const aliased = spec.replace(/^(@\/|~~\/|~\/|#\/|src\/)/, "");
|
|
79
|
-
|
|
82
|
+
// A prefix-less specifier is only a bare npm package when it has a single
|
|
83
|
+
// segment ('react'). Multi-segment prefix-less specifiers are how tsconfig
|
|
84
|
+
// `baseUrl` repos import internal modules ('Components/Foo' with
|
|
85
|
+
// baseUrl: "src" — SKYR-4287: every folder-per-component index.tsx in such
|
|
86
|
+
// repos was misreported as no-importers). Those flow into the same
|
|
87
|
+
// suffix + shared-root check as aliases; a deep npm import ('lodash/get')
|
|
88
|
+
// is unlikely to match unless the repo actually contains a `lodash/get`
|
|
89
|
+
// directory under the same root, and a scoped package ('@scope/pkg') would
|
|
90
|
+
// require a source directory literally named '@scope'.
|
|
91
|
+
if (aliased === spec && !spec.includes("/"))
|
|
80
92
|
continue; // bare package specifier — not a repo path
|
|
81
93
|
if (moduleDir === aliased)
|
|
82
94
|
return true; // alias maps to the repo root
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-only mode capture for `skyramp_submit_report` (SKYR-4250).
|
|
3
|
+
*
|
|
4
|
+
* A plan-only eval run (SKYR-3879) writes no test files: `newTestsCreated`
|
|
5
|
+
* DECLARES the approved GENERATE selection instead of recording generated
|
|
6
|
+
* files, and the declaration itself is the deliverable. The SKYR-3883 guard
|
|
7
|
+
* (`findUnchangedFileClaims`) verifies every `newTestsCreated` claim against
|
|
8
|
+
* the working tree, so in that lane EVERY declaration is unbacked by
|
|
9
|
+
* construction and the report is rejected.
|
|
10
|
+
*
|
|
11
|
+
* That rejection is not evenly distributed. `findUnchangedFileClaims` exempts a
|
|
12
|
+
* claim attributed to a non-primary repo, so on a cross-repo fixture the
|
|
13
|
+
* related repo's declarations pass and only the primary repo's are checked.
|
|
14
|
+
* On a frontend-primary pair that makes the mandatory UI item (SKYR-4204's
|
|
15
|
+
* floor) the one claim that always fails, and the agent's only compliant move
|
|
16
|
+
* is to demote it to `additionalRecommendations` — which is exactly the
|
|
17
|
+
* under-declaration SKYR-4250 exists to fix (run 32903633247).
|
|
18
|
+
*
|
|
19
|
+
* `planOnly` reaches the server only as a testbot prompt/resource arg, so this
|
|
20
|
+
* mirrors the SKYR-4185 report-language capture: the prompt render is the
|
|
21
|
+
* single capture point, and the report tool reads the captured mode later in
|
|
22
|
+
* the same process.
|
|
23
|
+
*
|
|
24
|
+
* Capture is fail-open by design: if the testbot prompt was never served, the
|
|
25
|
+
* mode stays false and the guard behaves exactly as it does today.
|
|
26
|
+
*/
|
|
27
|
+
/** Capture plan-only mode at prompt-serve time. Last render wins: a later
|
|
28
|
+
* non-plan-only render disarms a mode captured earlier in a long-lived server
|
|
29
|
+
* process, so an ordinary run never silently skips the guard. */
|
|
30
|
+
export declare function setPlanOnlyMode(planOnly: boolean): void;
|
|
31
|
+
export declare function isPlanOnlyMode(): boolean;
|
|
32
|
+
/** Test isolation only — module state persists across tests in one process. */
|
|
33
|
+
export declare function resetPlanOnlyMode(): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-only mode capture for `skyramp_submit_report` (SKYR-4250).
|
|
3
|
+
*
|
|
4
|
+
* A plan-only eval run (SKYR-3879) writes no test files: `newTestsCreated`
|
|
5
|
+
* DECLARES the approved GENERATE selection instead of recording generated
|
|
6
|
+
* files, and the declaration itself is the deliverable. The SKYR-3883 guard
|
|
7
|
+
* (`findUnchangedFileClaims`) verifies every `newTestsCreated` claim against
|
|
8
|
+
* the working tree, so in that lane EVERY declaration is unbacked by
|
|
9
|
+
* construction and the report is rejected.
|
|
10
|
+
*
|
|
11
|
+
* That rejection is not evenly distributed. `findUnchangedFileClaims` exempts a
|
|
12
|
+
* claim attributed to a non-primary repo, so on a cross-repo fixture the
|
|
13
|
+
* related repo's declarations pass and only the primary repo's are checked.
|
|
14
|
+
* On a frontend-primary pair that makes the mandatory UI item (SKYR-4204's
|
|
15
|
+
* floor) the one claim that always fails, and the agent's only compliant move
|
|
16
|
+
* is to demote it to `additionalRecommendations` — which is exactly the
|
|
17
|
+
* under-declaration SKYR-4250 exists to fix (run 32903633247).
|
|
18
|
+
*
|
|
19
|
+
* `planOnly` reaches the server only as a testbot prompt/resource arg, so this
|
|
20
|
+
* mirrors the SKYR-4185 report-language capture: the prompt render is the
|
|
21
|
+
* single capture point, and the report tool reads the captured mode later in
|
|
22
|
+
* the same process.
|
|
23
|
+
*
|
|
24
|
+
* Capture is fail-open by design: if the testbot prompt was never served, the
|
|
25
|
+
* mode stays false and the guard behaves exactly as it does today.
|
|
26
|
+
*/
|
|
27
|
+
let sessionPlanOnly = false;
|
|
28
|
+
/** Capture plan-only mode at prompt-serve time. Last render wins: a later
|
|
29
|
+
* non-plan-only render disarms a mode captured earlier in a long-lived server
|
|
30
|
+
* process, so an ordinary run never silently skips the guard. */
|
|
31
|
+
export function setPlanOnlyMode(planOnly) {
|
|
32
|
+
sessionPlanOnly = planOnly;
|
|
33
|
+
}
|
|
34
|
+
export function isPlanOnlyMode() {
|
|
35
|
+
return sessionPlanOnly;
|
|
36
|
+
}
|
|
37
|
+
/** Test isolation only — module state persists across tests in one process. */
|
|
38
|
+
export function resetPlanOnlyMode() {
|
|
39
|
+
sessionPlanOnly = false;
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Visual-snapshot baseline names as a Skyramp test passes them to
|
|
4
|
+
* `toHaveScreenshot()` — e.g. `page-001.png` — and as `skyramp_actions` and
|
|
5
|
+
* `skyramp_execute_test` accept them in `rebaselineSnapshots` (SKYR-4298).
|
|
6
|
+
*
|
|
7
|
+
* Bare filename only: no path separators, and no commas or whitespace, because the
|
|
8
|
+
* list is joined with commas into `SKYRAMP_UPDATE_SNAPSHOTS` and split again in the
|
|
9
|
+
* executor, where `a,b.png` would read as two baselines. One definition shared by
|
|
10
|
+
* both tools so they can never drift apart.
|
|
11
|
+
*/
|
|
12
|
+
export declare const REBASELINE_SNAPSHOT_NAME_RE: RegExp;
|
|
13
|
+
export declare const rebaselineSnapshotNameSchema: z.ZodString;
|
|
14
|
+
/** `page-001.png` → `page-001`. */
|
|
15
|
+
export declare function baselineStem(name: string): string;
|
|
16
|
+
export declare function baselineFileMatchesStem(fileBasename: string, stem: string): boolean;
|
|
17
|
+
/** The directory Playwright keeps a spec's baselines in under the default layout. */
|
|
18
|
+
export declare function snapshotDirFor(specFile: string): string;
|
|
19
|
+
/** Looks like an on-disk baseline filename (`page-001-chromium-linux.png`) rather than the
|
|
20
|
+
* name the test passes (`page-001.png`) — the likeliest wrong input, and one the name
|
|
21
|
+
* schema accepts. Advisory only: `hero-linux.png` is also a perfectly valid name a test
|
|
22
|
+
* can pass, so callers warn and keep the entry; execution authorizes it against the
|
|
23
|
+
* verdict and reports whether anything was actually rewritten. */
|
|
24
|
+
export declare function looksLikeOnDiskBaselineName(name: string): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Visual-snapshot baseline names as a Skyramp test passes them to
|
|
5
|
+
* `toHaveScreenshot()` — e.g. `page-001.png` — and as `skyramp_actions` and
|
|
6
|
+
* `skyramp_execute_test` accept them in `rebaselineSnapshots` (SKYR-4298).
|
|
7
|
+
*
|
|
8
|
+
* Bare filename only: no path separators, and no commas or whitespace, because the
|
|
9
|
+
* list is joined with commas into `SKYRAMP_UPDATE_SNAPSHOTS` and split again in the
|
|
10
|
+
* executor, where `a,b.png` would read as two baselines. One definition shared by
|
|
11
|
+
* both tools so they can never drift apart.
|
|
12
|
+
*/
|
|
13
|
+
export const REBASELINE_SNAPSHOT_NAME_RE = /^[^/\\,\s]+\.png$/i;
|
|
14
|
+
export const rebaselineSnapshotNameSchema = z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.regex(REBASELINE_SNAPSHOT_NAME_RE, {
|
|
18
|
+
message: 'rebaselineSnapshots entries must be bare .png filenames as passed to toHaveScreenshot(), e.g. "page-001.png" — no path, commas, or whitespace',
|
|
19
|
+
});
|
|
20
|
+
/** `page-001.png` → `page-001`. */
|
|
21
|
+
export function baselineStem(name) {
|
|
22
|
+
return path.basename(name).replace(/\.png$/i, "");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Playwright's default snapshot layout, which is what the executor produces: the
|
|
26
|
+
* baseline for `<stem>.png` lives in `<spec>-snapshots/` as `<stem>-<project>-<platform>.png`
|
|
27
|
+
* (or `<stem>-<platform>.png` with no project, or `<stem>.png` under a custom
|
|
28
|
+
* `snapshotPathTemplate`). Safe to assume here because TestExecutionService mounts
|
|
29
|
+
* its own generated Playwright config over any the repo carries, so a repo cannot
|
|
30
|
+
* relocate its snapshots out from under this rule.
|
|
31
|
+
*
|
|
32
|
+
* The executor's generated config declares no `projects`, but runner.sh passes
|
|
33
|
+
* `--browser=chromium`, so Playwright names the implicit project "chromium" and a
|
|
34
|
+
* refresh writes `<stem>-chromium-linux.png` (observed end to end on demoshop).
|
|
35
|
+
* Baselines the Skyramp executor produced therefore round-trip exactly. A baseline
|
|
36
|
+
* a repo's own CI wrote under another project name (`<stem>-Desktop-Chrome-linux.png`)
|
|
37
|
+
* is a different file: the executor's refresh adds its own and leaves that one as
|
|
38
|
+
* it was — the tool reports what it rewrote, and the repo's own suite is out of
|
|
39
|
+
* scope for skyramp_execute_test (which never runs it).
|
|
40
|
+
*
|
|
41
|
+
* Anchored so a longer baseline cannot vouch for a shorter one: `page-001-wide-chromium-linux.png`
|
|
42
|
+
* belongs to `page-001-wide.png` and must not satisfy a claim on `page-001.png`.
|
|
43
|
+
*/
|
|
44
|
+
const SNAPSHOT_PLATFORMS = "(?:darwin|linux|win32)";
|
|
45
|
+
export function baselineFileMatchesStem(fileBasename, stem) {
|
|
46
|
+
// A degenerate stem ("" or dots only, from a name like "..png") would match a wide
|
|
47
|
+
// set of files; nothing legitimate has one.
|
|
48
|
+
if (!stem || /^\.+$/.test(stem))
|
|
49
|
+
return false;
|
|
50
|
+
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
const re = new RegExp(`^${escaped}(?:-[^-]+-${SNAPSHOT_PLATFORMS}|-${SNAPSHOT_PLATFORMS})?\\.png$`, "i");
|
|
52
|
+
return re.test(fileBasename);
|
|
53
|
+
}
|
|
54
|
+
/** The directory Playwright keeps a spec's baselines in under the default layout. */
|
|
55
|
+
export function snapshotDirFor(specFile) {
|
|
56
|
+
return `${specFile}-snapshots`;
|
|
57
|
+
}
|
|
58
|
+
/** Looks like an on-disk baseline filename (`page-001-chromium-linux.png`) rather than the
|
|
59
|
+
* name the test passes (`page-001.png`) — the likeliest wrong input, and one the name
|
|
60
|
+
* schema accepts. Advisory only: `hero-linux.png` is also a perfectly valid name a test
|
|
61
|
+
* can pass, so callers warn and keep the entry; execution authorizes it against the
|
|
62
|
+
* verdict and reports whether anything was actually rewritten. */
|
|
63
|
+
export function looksLikeOnDiskBaselineName(name) {
|
|
64
|
+
return /-(?:[^-]+-)?(?:darwin|linux|win32)\.png$/i.test(name);
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CandidateUiPage } from "./uiPageEnumerator.js";
|
|
2
|
+
import type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
|
|
3
|
+
export interface RemovedUiElement {
|
|
4
|
+
/** Lower-cased identifying attribute: `data-testid`, `data-cy`, `data-qa`, `data-section`, … */
|
|
5
|
+
attribute: string;
|
|
6
|
+
/** Its literal value. `attribute` + `value` is unique across the result. */
|
|
7
|
+
value: string;
|
|
8
|
+
/** Repo-relative frontend file the diff removed it from. */
|
|
9
|
+
file: string;
|
|
10
|
+
/** Candidate UI pages attributed to that file (directly, or via its importers). */
|
|
11
|
+
pageUrls: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Bound the list: each entry is a guard assertion the agent records on one page, and
|
|
15
|
+
* the plan budget is small (`MAX_TESTS_TO_GENERATE`), so a large cleanup PR gets its
|
|
16
|
+
* first few ids, not a flood. Truncation is logged at info with the dropped count.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_REMOVED_UI_ELEMENTS = 5;
|
|
19
|
+
export interface CollectRemovedUiElementsInput {
|
|
20
|
+
changedFrontendFiles: string[];
|
|
21
|
+
/** Files with `+++ /dev/null` in the diff — their page is gone, no guard. */
|
|
22
|
+
deletedFiles: string[];
|
|
23
|
+
/** Per-file diff slices for the WHOLE diff keyed by repo-relative path (see sliceDiffByFile). */
|
|
24
|
+
diffByFile: Map<string, string>;
|
|
25
|
+
frontendFileIntegration?: FrontendFileIntegration[];
|
|
26
|
+
candidateUiPages: CandidateUiPage[];
|
|
27
|
+
/** Absolute paths of discovered existing tests (skyramp + relevant external). */
|
|
28
|
+
existingTestFiles: string[];
|
|
29
|
+
/** True when test discovery threw — the existing-test check cannot run, so report nothing. */
|
|
30
|
+
discoveryFailed?: boolean;
|
|
31
|
+
/** Injectable for tests; defaults to a tolerant fs read that logs failures. */
|
|
32
|
+
readFile?: (absPath: string) => string | undefined;
|
|
33
|
+
}
|
|
34
|
+
export declare function collectRemovedUiElements(input: CollectRemovedUiElementsInput): RemovedUiElement[];
|