@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.
Files changed (49) 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 +1 -0
  7. package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
  8. package/build/prompts/testbot/testbot-prompts.js +10 -7
  9. package/build/services/containerEnv.d.ts +1 -1
  10. package/build/services/containerEnv.js +12 -0
  11. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  12. package/build/tools/executeSkyrampTestTool.js +246 -19
  13. package/build/tools/submitReportTool.d.ts +5 -5
  14. package/build/tools/submitReportTool.js +5 -1
  15. package/build/tools/test-management/actionsTool.js +71 -3
  16. package/build/tools/test-management/analyzeChangesTool.d.ts +10 -0
  17. package/build/tools/test-management/analyzeChangesTool.js +73 -0
  18. package/build/types/TestAnalysis.d.ts +12 -0
  19. package/build/types/TestExecution.d.ts +4 -0
  20. package/build/utils/AnalysisStateManager.d.ts +7 -0
  21. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  22. package/build/utils/assertion-verify/ui-lints.js +32 -0
  23. package/build/utils/frontendSelectors.d.ts +33 -0
  24. package/build/utils/frontendSelectors.js +196 -5
  25. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  26. package/build/utils/rebaselineSnapshots.js +65 -0
  27. package/build/utils/removedUiElements.d.ts +34 -0
  28. package/build/utils/removedUiElements.js +153 -0
  29. package/build/utils/reportVerification.d.ts +2 -6
  30. package/build/utils/reportVerification.js +61 -2
  31. package/build/utils/versions.d.ts +3 -3
  32. package/build/utils/versions.js +1 -1
  33. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  34. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  35. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  36. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  37. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  38. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  39. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  40. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  41. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  42. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  43. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  44. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  45. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  46. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  47. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  48. package/node_modules/playwright/package.json +1 -1
  49. package/package.json +3 -3
@@ -33,6 +33,7 @@ import { detectProjectMetadata } from "../../utils/projectMetadata.js";
33
33
  import { draftScenariosFromEndpoints, draftResponseShapeScenarios, capScenarios } from "../../utils/scenarioDrafting.js";
34
34
  import { mapUnmatchedFilesToImporterEndpoints, buildHopResponseShapeInputs, deriveResourceTokensForFile, deriveResourceToken, extractChangedSymbols, sliceDiffByFile } from "../../utils/importerHop.js";
35
35
  import { extractChangedSelectors, MAX_CHANGED_SELECTORS } from "../../utils/frontendSelectors.js";
36
+ import { collectRemovedUiElements, MAX_REMOVED_UI_ELEMENTS } from "../../utils/removedUiElements.js";
36
37
  import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
37
38
  import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
38
39
  import { TestSource } from "../../types/TestAnalysis.js";
@@ -359,6 +360,39 @@ If a candidate URL 404s or redirects unexpectedly, navigate from the frontend ba
359
360
 
360
361
  If \`browser_blueprint\` fails on every candidate URL (app unreachable, all 404s), proceed and log an \`issuesFound\` info entry. Recommendations will be source-grounded; non-UI work is unaffected.`;
361
362
  }
363
+ /**
364
+ * Instruction block for data-testids the diff removed from pages that survive
365
+ * (SKYR-4305). The UI analog of the `[removed]` endpoint 404 guard: one UI
366
+ * candidate per element, asserting the element is gone plus a positive check on a
367
+ * retained element of the same page. Whole-feature removals never reach here —
368
+ * collectRemovedUiElements drops elements whose file was deleted or whose page
369
+ * could not be resolved.
370
+ */
371
+ export function buildRemovedElementsInstructions(elements) {
372
+ const byPage = new Map();
373
+ for (const e of elements) {
374
+ for (const url of e.pageUrls) {
375
+ if (!byPage.has(url))
376
+ byPage.set(url, []);
377
+ byPage.get(url).push(e);
378
+ }
379
+ }
380
+ const target = (e) => e.attribute === "data-testid" ? `testId: "${e.value}"` : `attribute: "${e.attribute}", value: "${e.value}"`;
381
+ const pageLines = [...byPage.entries()]
382
+ .map(([url, els], i) => ` ${i + 1}. ${url}\n` + els.map((e) => ` - \`${e.attribute}="${e.value}"\` (removed from ${e.file}; browser_assert_hidden target: ${target(e)})`).join("\n"))
383
+ .join("\n");
384
+ const capNote = elements.length >= MAX_REMOVED_UI_ELEMENTS
385
+ ? `\n(The list is capped at ${MAX_REMOVED_UI_ELEMENTS} elements; the analyze log records how many were dropped.)`
386
+ : "";
387
+ return `**Removed UI elements — grounding for the removal test.** This diff deletes the elements below (identified by a \`data-*\` attribute) from pages that still exist after the change. The budget rule already requires a test asserting a removed element is absent; this list tells you WHICH elements and on WHICH page, which no blueprint can (the elements are gone). Renames, moved/extracted elements, elements in deleted files, files with no production importer, and values an existing test already references were filtered out server-side; the page is attributed through the import graph (the file, or one hop via the files that import it), not observed.
388
+
389
+ **Removed elements by page:**
390
+ ${pageLines}${capNote}
391
+
392
+ **How to record (Task 2), per page — not per element:** if the plan already has a UI candidate on that page, record the guard inside that trace; otherwise register ONE UI candidate \`verify-removed-ui-<page-slug>\` (\`testType: "ui"\`, category \`breaking_change\`; \`skyramp_register_test_plan\` requires at least one step, so use a placeholder \`GET <page path>\` → 200). \`browser_navigate\` to the page and let the app render, then one \`browser_assert_hidden\` call per element using the target shown above — \`testId\` when the attribute is \`data-testid\`, otherwise \`attribute\` + \`value\` (the generated test then uses \`getByTestId(...)\` or \`page.locator('[data-cy="..."]')\` respectively; no \`ref\` — a removed element has no snapshot ref; the tool checks the live page and refuses to record on a blank or un-rendered page), then at least one \`browser_assert\` on a RETAINED element of the same page verifying its text, value or state — the generated \`toBeHidden()\` assertions must never stand alone. Then \`skyramp_export_zip\` and \`skyramp_ui_test_generation\` as usual.
393
+
394
+ **Blueprint Citation for these recommendations:** the removed elements will not appear in any blueprint you capture — that is expected. For a \`data-testid\` element set \`targetElements[].testId\` to the value; for any other attribute leave \`testId\` null and name the \`attribute="value"\` pair in \`reasoning\`. Take \`role\`/\`accessibleName\` from the deleted JSX in the diff, set \`pageContext.url\` to the page above, and prefix the recommendation's \`reasoning\` with \`[removed-element]\`.`;
395
+ }
362
396
  /**
363
397
  * Instruction block for changed frontend files the server determined have no
364
398
  * production importer (SKYR-3855) — dead/orphaned components. Used both to
@@ -775,6 +809,7 @@ export function registerAnalyzeChangesTool(server) {
775
809
  }
776
810
  let existingTests = [];
777
811
  let discoveredRelevantExternalPaths = [];
812
+ let testDiscoveryFailed = false;
778
813
  try {
779
814
  const testDiscoveryService = new TestDiscoveryService();
780
815
  // Run-scoped: only the primary call carries testsRepoDir, so never
@@ -799,6 +834,7 @@ export function registerAnalyzeChangesTool(server) {
799
834
  });
800
835
  }
801
836
  catch (err) {
837
+ testDiscoveryFailed = true;
802
838
  logger.warning("Test discovery failed, continuing with empty list", {
803
839
  error: err instanceof Error ? err.message : String(err),
804
840
  });
@@ -1379,10 +1415,44 @@ export function registerAnalyzeChangesTool(server) {
1379
1415
  // the pages that import them.
1380
1416
  const frontendFileIntegration = checkFrontendFileIntegration(params.repositoryPath, changedFrontendFiles);
1381
1417
  const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, changedFrontendFiles, frontendFileIntegration);
1418
+ // SKYR-4305: data-testids the diff removed from a page that survives —
1419
+ // grounding for the removal test the budget rule requires (a removed
1420
+ // element appears in no blueprint). Purely additive: a failure here must
1421
+ // never cost the analyze call.
1422
+ let removedElements = [];
1423
+ if (diffData?.diffContent) {
1424
+ try {
1425
+ removedElements = collectRemovedUiElements({
1426
+ changedFrontendFiles,
1427
+ deletedFiles: diffData.deletedFiles ?? [],
1428
+ diffByFile: getDiffByFile(),
1429
+ frontendFileIntegration,
1430
+ candidateUiPages,
1431
+ existingTestFiles: [
1432
+ ...existingTests.map((t) => t.testFile),
1433
+ ...discoveredRelevantExternalPaths,
1434
+ ],
1435
+ discoveryFailed: testDiscoveryFailed,
1436
+ });
1437
+ }
1438
+ catch (err) {
1439
+ logger.warning("removedUiElements: collection failed, continuing without removal-guard grounding", {
1440
+ error: err instanceof Error ? err.message : String(err),
1441
+ });
1442
+ removedElements = [];
1443
+ }
1444
+ }
1445
+ if (removedElements.length > 0) {
1446
+ logger.info("Removed UI elements detected (removal-guard grounding)", {
1447
+ count: removedElements.length,
1448
+ removed: removedElements.map((e) => `${e.attribute}="${e.value}"`),
1449
+ });
1450
+ }
1382
1451
  return {
1383
1452
  changedFrontendFiles,
1384
1453
  candidateUiPages,
1385
1454
  frontendFileIntegration,
1455
+ ...(removedElements.length > 0 ? { removedElements } : {}),
1386
1456
  };
1387
1457
  })();
1388
1458
  // Derive the UI blueprint-capture instructions the agent acts on after
@@ -1407,6 +1477,9 @@ export function registerAnalyzeChangesTool(server) {
1407
1477
  : buildCaptureInstructions(uiContext.candidateUiPages)) +
1408
1478
  (unintegratedFrontendFiles.length > 0
1409
1479
  ? `\n\n${buildUnintegratedInstructions(unintegratedFrontendFiles)}`
1480
+ : "") +
1481
+ (uiContext.removedElements?.length
1482
+ ? `\n\n${buildRemovedElementsInstructions(uiContext.removedElements)}`
1410
1483
  : "");
1411
1484
  // ── SKYR-3879 Path B: budget context + pre-seeded approved plan ──
1412
1485
  // Computed BEFORE the state write (not after, alongside the
@@ -41,6 +41,18 @@ export interface MaintenanceActionCore {
41
41
  * downstream stage that reasons about the edit needs both — notably the report-time
42
42
  * working-tree check, which otherwise calls a POM-backed UPDATE unbacked (SKYR-4129). */
43
43
  pomFile?: string;
44
+ /** Visual-snapshot baselines (toHaveScreenshot filenames, e.g. "page-001.png") an UPDATE
45
+ * refreshes because the diff changed how the captured page/element looks (SKYR-4298).
46
+ * The edit lands in the PNG under `<spec>-snapshots/`, not in the spec, so the
47
+ * report-time working-tree check must accept that PNG as the UPDATE's backing, and
48
+ * the final `skyramp_execute_test` must receive the list as `rebaselineSnapshots`. */
49
+ rebaselineSnapshots?: string[];
50
+ /** True when the UPDATE carries rebaselineSnapshots and no updateInstructions: the
51
+ * refresh is its whole maintenance, so no edit to the spec/POM is expected. When false
52
+ * (or absent) with baselines listed, the report-time check requires BOTH the edit and
53
+ * the rewritten PNG — a listed baseline must never exempt the selector edit the same
54
+ * verdict claimed (SKYR-3883 stays in force). */
55
+ rebaselineOnly?: boolean;
44
56
  }
45
57
  /** Normalized internal recommendation built from LLM-supplied args.recommendations. */
46
58
  export interface DriftRecommendation extends MaintenanceActionCore {
@@ -60,6 +60,10 @@ export interface TestExecutionOptions {
60
60
  playwrightSaveStoragePath?: string;
61
61
  dockerNetwork?: string;
62
62
  useHostNetwork?: boolean;
63
+ /** Visual-snapshot baselines (toHaveScreenshot filenames, e.g. "page-001.png") this run
64
+ * replaces instead of comparing against — forwarded to SmartPlaywright as
65
+ * SKYRAMP_UPDATE_SNAPSHOTS (SKYR-4298). Only for an intended UI change the diff explains. */
66
+ rebaselineSnapshots?: string[];
63
67
  }
64
68
  /**
65
69
  * Progress callback for reporting execution status
@@ -5,6 +5,7 @@ import { RepositoryAnalysis, AnalysisScope } from "../types/RepositoryAnalysis.j
5
5
  import { PRTestContext } from "./pr-comment-parser.js";
6
6
  import { ParsedDiffEndpoint } from "./routeParsers.js";
7
7
  import type { CandidateUiPage } from "./uiPageEnumerator.js";
8
+ import type { RemovedUiElement } from "./removedUiElements.js";
8
9
  import type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
9
10
  import type { ApprovedPlanItem } from "../types/Recommendation.js";
10
11
  import type { ExternalTestRunRecord } from "../types/ExternalTestExecution.js";
@@ -135,6 +136,12 @@ export interface UiAnalysisContext {
135
136
  * the agent running its own grep mid-task. Same order as changedFrontendFiles.
136
137
  */
137
138
  frontendFileIntegration?: FrontendFileIntegration[];
139
+ /**
140
+ * data-testids the diff removed from files whose page survives (SKYR-4305).
141
+ * Present only when non-empty. The testbot records a removal guard
142
+ * (`toBeHidden()` on the id + a positive sibling assertion) for each entry.
143
+ */
144
+ removedElements?: RemovedUiElement[];
138
145
  }
139
146
  /**
140
147
  * SKYR-3879 Path B — the server-side selection checkpoint's persisted result.
@@ -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 = /\bdata-(?:test-?id|test|cy|qa)\b\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
78
- const GET_BY_TEST_ID = /getByTestId\(\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
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[1], true))
100
- out.add(m[1]);
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
+ }
@@ -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[];