@skyramp/mcp 0.4.2-rc.1 → 0.4.2-rc.2

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 (104) hide show
  1. package/build/commands/localDevTestChangesCommand.js +1 -1
  2. package/build/commands/recommendTestsAndExecuteCommand.js +10 -1
  3. package/build/commands/testThisEndpointCommand.js +19 -2
  4. package/build/execution/wrapperConfig.d.ts +56 -0
  5. package/build/execution/wrapperConfig.js +155 -0
  6. package/build/index.js +6 -6
  7. package/build/playwright/registerPlaywrightTools.js +14 -0
  8. package/build/playwright/traceExportStore.d.ts +22 -0
  9. package/build/playwright/traceExportStore.js +81 -0
  10. package/build/playwright/traceRecordingPrompt.js +2 -1
  11. package/build/prompts/code-reuse.js +24 -21
  12. package/build/prompts/local-dev/local-dev-plan.js +6 -23
  13. package/build/prompts/local-dev/local-dev-prompts.js +1 -1
  14. package/build/prompts/shared-helper-policy.d.ts +36 -0
  15. package/build/prompts/shared-helper-policy.js +33 -1
  16. package/build/prompts/startTraceCollectionPrompts.js +1 -1
  17. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +6 -7
  18. package/build/prompts/sut-setup/shared.d.ts +1 -1
  19. package/build/prompts/sut-setup/shared.js +5 -3
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +16 -8
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +90 -36
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +1 -1
  23. package/build/prompts/test-recommendation/recommendationShared.d.ts +1 -1
  24. package/build/prompts/test-recommendation/recommendationShared.js +0 -1
  25. package/build/prompts/testbot/testbot-prompts.js +11 -9
  26. package/build/services/TestDiscoveryService.js +32 -4
  27. package/build/skills/runTestSkill.d.ts +6 -0
  28. package/build/skills/runTestSkill.js +17 -0
  29. package/build/tool-phases.js +0 -1
  30. package/build/tools/budgetExcuse.d.ts +15 -0
  31. package/build/tools/budgetExcuse.js +113 -0
  32. package/build/tools/code-refactor/utils-verify-gates.js +17 -3
  33. package/build/tools/executeSkyrampTestTool.d.ts +97 -48
  34. package/build/tools/executeSkyrampTestTool.js +775 -449
  35. package/build/tools/submitReportTool.js +128 -0
  36. package/build/tools/test-management/actionsTool.js +31 -0
  37. package/build/tools/test-management/analyzeChangesTool.d.ts +4 -4
  38. package/build/tools/test-management/analyzeTestHealthTool.d.ts +0 -11
  39. package/build/tools/test-management/analyzeTestHealthTool.js +7 -63
  40. package/build/tools/test-management/testsOwedBeforeRun.d.ts +28 -0
  41. package/build/tools/test-management/testsOwedBeforeRun.js +53 -0
  42. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  43. package/build/types/RepositoryAnalysis.d.ts +32 -32
  44. package/build/types/ReuseOutcome.d.ts +4 -3
  45. package/build/types/TestExecution.d.ts +2 -2
  46. package/build/types/TestTypes.d.ts +3 -0
  47. package/build/types/TestTypes.js +6 -0
  48. package/build/utils/AnalysisStateManager.d.ts +0 -7
  49. package/build/utils/AnalysisStateManager.js +1 -1
  50. package/build/utils/connectionErrors.d.ts +10 -0
  51. package/build/utils/connectionErrors.js +10 -0
  52. package/build/utils/language-helper.js +24 -3
  53. package/build/utils/progress.d.ts +1 -1
  54. package/build/utils/progress.js +1 -1
  55. package/build/utils/rebaselineSnapshots.d.ts +1 -1
  56. package/build/utils/rebaselineSnapshots.js +6 -16
  57. package/build/utils/reuseRouting.d.ts +10 -0
  58. package/build/utils/reuseRouting.js +15 -0
  59. package/build/utils/runContextGauge.d.ts +27 -0
  60. package/build/utils/runContextGauge.js +181 -0
  61. package/build/utils/skyrampMdContent.d.ts +1 -1
  62. package/build/utils/skyrampMdContent.js +1 -1
  63. package/build/utils/skyrampSdkVersion.d.ts +9 -0
  64. package/build/utils/skyrampSdkVersion.js +16 -0
  65. package/build/utils/testDependencyPolicy.js +21 -0
  66. package/build/utils/testExecutionRecord.d.ts +5 -1
  67. package/build/utils/testExecutionRecord.js +3 -1
  68. package/build/utils/testFileClassification.d.ts +8 -0
  69. package/build/utils/testFileClassification.js +36 -3
  70. package/build/utils/utils-verify/action-key.d.ts +42 -0
  71. package/build/utils/utils-verify/action-key.js +118 -36
  72. package/build/utils/utils-verify/action-sites.d.ts +32 -0
  73. package/build/utils/utils-verify/action-sites.js +202 -0
  74. package/build/utils/utils-verify/body-reach.js +2 -4
  75. package/build/utils/utils-verify/call-sites.d.ts +25 -6
  76. package/build/utils/utils-verify/call-sites.js +8 -5
  77. package/build/utils/utils-verify/index.d.ts +1 -0
  78. package/build/utils/utils-verify/index.js +1 -0
  79. package/build/utils/utils-verify/language-spec.d.ts +25 -0
  80. package/build/utils/utils-verify/language-spec.js +16 -2
  81. package/build/utils/utils-verify/parse.d.ts +10 -1
  82. package/build/utils/utils-verify/parse.js +19 -2
  83. package/build/utils/utils-verify/verify.d.ts +3 -2
  84. package/build/utils/utils-verify/verify.js +16 -18
  85. package/build/workspace/workspace.d.ts +72 -52
  86. package/build/workspace/workspace.js +12 -8
  87. package/package.json +1 -1
  88. package/plugin/prompts/testbot-task1.md +0 -2
  89. package/plugin/skills/fix-test-import-errors/SKILL.md +2 -1
  90. package/plugin/skills/run-test/SKILL.md +16 -0
  91. package/build/adapters/jestAdapter.d.ts +0 -14
  92. package/build/adapters/jestAdapter.js +0 -131
  93. package/build/adapters/mochaAdapter.d.ts +0 -13
  94. package/build/adapters/mochaAdapter.js +0 -93
  95. package/build/adapters/playwrightAdapter.d.ts +0 -17
  96. package/build/adapters/playwrightAdapter.js +0 -184
  97. package/build/adapters/pytestAdapter.d.ts +0 -15
  98. package/build/adapters/pytestAdapter.js +0 -119
  99. package/build/tools/runExistingTestsTool.d.ts +0 -138
  100. package/build/tools/runExistingTestsTool.js +0 -666
  101. package/build/types/ExternalTestExecution.d.ts +0 -67
  102. package/build/types/ExternalTestExecution.js +0 -8
  103. package/build/workspace/testSuites.d.ts +0 -20
  104. package/build/workspace/testSuites.js +0 -17
@@ -231,6 +231,7 @@ fileHash) {
231
231
  * per-file testExecutions record (every file, keyed by canonical path — see
232
232
  * executionRecordFrom), plus the maintained-test executionBefore/After row
233
233
  * when `testFile` has one. No-op when the repo section doesn't exist yet.
234
+ * Returns what was written, so the caller can say what was not.
234
235
  *
235
236
  * Extracted from skyramp_execute_test's own state-write block so the
236
237
  * behavior that makes a file's result available to the post-execution
@@ -245,7 +246,7 @@ export async function persistTestExecutionResult(stateFile, repo, testType, phas
245
246
  const stateManager = StateManager.fromStatePath(stateFile);
246
247
  const stateData = await stateManager.readRepoData(repo);
247
248
  if (!stateData)
248
- return;
249
+ return { saved: false, matchedExistingTest: false };
249
250
  const testIndex = stateData.existingTests?.findIndex((t) => canonicalTestPath(t.testFile) === canonicalTestPath(testFile)) ?? -1;
250
251
  if (testIndex >= 0) {
251
252
  if (phase === "before") {
@@ -263,5 +264,6 @@ export async function persistTestExecutionResult(stateFile, repo, testType, phas
263
264
  !options.reserved),
264
265
  };
265
266
  await stateManager.updateRepoData(stateData, { repo });
267
+ return { saved: true, matchedExistingTest: testIndex >= 0 };
266
268
  });
267
269
  }
@@ -14,6 +14,14 @@ export declare function isDiscoveredTestFile(filePath: string): boolean;
14
14
  * `isDiscoveredTestFile`'s rule, which also counts `tests/`, a page-object
15
15
  * directory and `test_orders.py`. The maintenance tool wants this narrow one. */
16
16
  export declare function isTestFile(filePath: string): boolean;
17
+ /** The file's own NAME says it is a test — `login.spec.ts`, `orders.test.js`,
18
+ * `test_orders.py`, `UserDAOTest.java`. Almost entirely a NAME rule: a helper,
19
+ * a fixture, a page object or a config sitting in `e2e/` or `cypress/support/`
20
+ * is not a test and cannot be run as one. The one exception is `__tests__/`,
21
+ * Jest's convention that everything inside it is a test whatever it is called.
22
+ * Exported for callers that must not act on a file merely because of where it
23
+ * lives. */
24
+ export declare function hasTestFileName(filePath: string): boolean;
17
25
  /** Whether a path is a test file, by directory or by name. Read by the route
18
26
  * checks: a route declared in a mock server or a spec fixture is not a surface
19
27
  * anyone serves. */
@@ -76,10 +76,43 @@ const TEST_ROOT_DIR = /(^|[\\/])(tests?|__tests__|__mocks__|e2e|cypress|testing)
76
76
  * `TEST_FILE_PATTERNS` rows it resembles — any extension, and `.`, `_` or `-`
77
77
  * before `test` — so no row above can stand in for it. */
78
78
  const TEST_FILE_NAME = /[._-](test|spec)\.[a-z]+$|(^|[\\/])test_[^\\/]*$/i;
79
+ /** The file's own NAME says it is a test — `login.spec.ts`, `orders.test.js`,
80
+ * `test_orders.py`, `UserDAOTest.java`. Almost entirely a NAME rule: a helper,
81
+ * a fixture, a page object or a config sitting in `e2e/` or `cypress/support/`
82
+ * is not a test and cannot be run as one. The one exception is `__tests__/`,
83
+ * Jest's convention that everything inside it is a test whatever it is called.
84
+ * Exported for callers that must not act on a file merely because of where it
85
+ * lives. */
86
+ export function hasTestFileName(filePath) {
87
+ return (
88
+ // Discovery's own name rule, applied to the basename so it admits no
89
+ // directory. It carries the framework spellings the regexes below miss:
90
+ // `UserDAOTest.java`, `orders_smoke.py`, `checkout_e2e.ts`.
91
+ TEST_FILE_PATTERNS.some((p) => p.test(path.basename(filePath))) ||
92
+ TEST_FILE_NAME.test(filePath) ||
93
+ CAMEL_TEST_FILE_NAME.test(filePath) ||
94
+ // Surefire's default includes are `Test*.java` as well as `*Test.java`, and
95
+ // Failsafe's are `*IT.java`. CAMEL_TEST_FILE_NAME needs a lowercase
96
+ // character before the suffix, so the acronym spelling (`APIIT.java`) and
97
+ // the prefix spelling (`TestOrder.java`) both fall through it.
98
+ /(^|[\\/])Test[A-Z][A-Za-z0-9]*\.(java|kt|kts|scala)$/.test(filePath) ||
99
+ /[A-Za-z0-9]IT\.(java|kt|kts|scala)$/.test(filePath) ||
100
+ // mocha's default spec glob is `./test/*.js` — one directory level, and no
101
+ // name rule at all, so `test/app.js` is a test to mocha.
102
+ /(^|[\\/])test[\\/][^\\/]+\.(js|cjs|mjs)$/.test(filePath) ||
103
+ // `login.e2e.ts` and Cypress's own `login.cy.ts` — the dot forms
104
+ // TEST_FILE_NAME does not carry. `.cy.` is to Cypress what `.spec.` is to
105
+ // Playwright.
106
+ /\.(cy|e2e)\.[a-z]+$/i.test(filePath) ||
107
+ // Jest's convention: everything under `__tests__/` is a test, whatever it
108
+ // is called. The only directory that means that on its own.
109
+ /(^|[\\/])__tests__[\\/]/.test(filePath));
110
+ }
79
111
  /** The CamelCase spellings, which carry no separator before `Test`.
80
- * Case-SENSITIVE and must stay so: with `i` it also matches `Latest.java` and
81
- * `Manifest.cs`, ordinary source files. That is why the case-insensitive
82
- * `*Test.java` / `*Tests.cs` rows above cannot serve here. */
112
+ * Case-SENSITIVE and must stay so: with `i` it also matches `Latest.java`,
113
+ * `Audit.java`, `Edit.cs` and `Digit.ts`, ordinary source files. That is why
114
+ * the case-insensitive `*Test.java` / `*Tests.cs` rows above cannot serve
115
+ * here. */
83
116
  const CAMEL_TEST_FILE_NAME = /[a-z0-9](Tests?|IT)\.[A-Za-z0-9]+$/;
84
117
  /** Whether a path is a test file, by directory or by name. Read by the route
85
118
  * checks: a route declared in a mock server or a spec fixture is not a surface
@@ -25,7 +25,49 @@ import type { UtilsLanguage } from "./language-spec.js";
25
25
  * helper in either language gets the same key.
26
26
  */
27
27
  export declare function actionKeyOf(body: string, language?: UtilsLanguage): string | undefined;
28
+ /** The keys of every step, in order, or `undefined` when there is no step or a
29
+ * step has no key — the sequence `actionKeyOf` joins, kept as steps for a caller
30
+ * that matches step by step (a key is not split back: a selector may carry the
31
+ * separator). */
32
+ export declare function actionStepKeys(steps: ActionStep[]): string[] | undefined;
33
+ /** One Playwright action in a body, in source order — the unit `actionKeyOf` joins. */
34
+ export interface ActionStep {
35
+ /** The step's key (`verb chain [target]`), or `undefined` when this action cannot
36
+ * be read: its locator is not built from plain string literals, or its arguments
37
+ * do not balance. A helper with one such step has no key; a spec's other steps
38
+ * are still read, so an unreadable step is a boundary no matched run crosses. */
39
+ key?: string;
40
+ /** 1-based line of the action verb. */
41
+ line: number;
42
+ /** The source between the previous action's closing `)` (or the start of the
43
+ * body) and this action's chain: what stands between two consecutive actions.
44
+ * Comments are as the caller left them; string contents are kept. Empty for
45
+ * the first step. See `betweenActionsIsTransparent`. */
46
+ gapBefore: string;
47
+ /** The source after this action's closing `)` up to the next action's chain, or
48
+ * to the end of the body for the last step — the next step's `gapBefore`. */
49
+ gapAfter: string;
50
+ }
51
+ /**
52
+ * Every action in `body`, in order, each keyed as `actionKeyOf` keys it. Where
53
+ * `actionKeyOf` answers "what does this helper do" with one string, this answers
54
+ * "which actions, where, and what sits between them" — what a scan for an inline
55
+ * copy of a helper's sequence needs. Lines survive the Python and alias rewrites:
56
+ * neither adds or removes a newline.
57
+ */
58
+ export declare function actionStepsOf(body: string, language?: UtilsLanguage): ActionStep[];
59
+ /** `click empty-cart-btn; fill qty` → `click \`empty-cart-btn\`; fill \`qty\`` — the
60
+ * key as the agent reads it, selectors quoted so a `#id` never reads as prose. */
61
+ export declare function describeActionKey(key: string): string;
28
62
  /** Index of the `)` that closes the `(` at `open`, on a string-blanked copy. */
29
63
  export declare function matchingClose(structure: string, open: number): number;
64
+ /** Whitespace and braces dropped outside string literals, `'` normalised to `"` —
65
+ * the spelling a locator chain has inside a key. */
66
+ export declare function normalizeChain(text: string): string;
30
67
  /** String contents replaced by spaces, delimiters kept, same length. */
31
68
  export declare function blankStringContents(text: string): string;
69
+ /** The statements of a fragment as `[start, end)` spans on `structure` (a
70
+ * string-blanked copy): each cut where valueEnd cuts — a `;` at depth 0, or a line
71
+ * end at depth 0 that the next line does not continue with `.` — so a
72
+ * Prettier-wrapped chain is one statement, not one per line. */
73
+ export declare function statementSpans(structure: string): [number, number][];
@@ -34,52 +34,101 @@ const TARGET_ARG = new Set(["goto", "dragTo"]);
34
34
  * helper in either language gets the same key.
35
35
  */
36
36
  export function actionKeyOf(body, language = "typescript") {
37
+ return actionStepKeys(actionStepsOf(body, language))?.join("; ");
38
+ }
39
+ /** The keys of every step, in order, or `undefined` when there is no step or a
40
+ * step has no key — the sequence `actionKeyOf` joins, kept as steps for a caller
41
+ * that matches step by step (a key is not split back: a selector may carry the
42
+ * separator). */
43
+ export function actionStepKeys(steps) {
44
+ if (steps.length === 0 || steps.some((s) => s.key === undefined))
45
+ return undefined;
46
+ return steps.map((s) => s.key);
47
+ }
48
+ /**
49
+ * Every action in `body`, in order, each keyed as `actionKeyOf` keys it. Where
50
+ * `actionKeyOf` answers "what does this helper do" with one string, this answers
51
+ * "which actions, where, and what sits between them" — what a scan for an inline
52
+ * copy of a helper's sequence needs. Lines survive the Python and alias rewrites:
53
+ * neither adds or removes a newline.
54
+ */
55
+ export function actionStepsOf(body, language = "typescript") {
37
56
  const src = resolveLocatorAliases(language === "python" ? toPlaywrightJs(body) : body);
38
57
  // Structure is read on a copy with string CONTENTS blanked (same length), so a
39
- // `)` or `.` inside a literal never ends a chain; text is taken from the original.
58
+ // `)` or `.` inside a literal never ends a chain, and a `.click(` written inside
59
+ // a string is not an action; text is taken from the original.
40
60
  const structure = blankStringContents(src);
41
61
  const steps = [];
42
- for (const m of src.matchAll(ACTION_RE)) {
62
+ let prevEnd = 0;
63
+ for (const m of structure.matchAll(ACTION_RE)) {
43
64
  const verb = m[1];
44
65
  const dot = m.index;
66
+ const line = src.slice(0, dot).split("\n").length;
45
67
  const argsOpen = dot + m[0].length - 1;
46
68
  const argsClose = matchingClose(structure, argsOpen);
47
- if (argsClose === -1)
48
- return undefined;
49
69
  const chainStart = chainStartBefore(structure, dot);
50
- let chain = src.slice(chainStart, dot);
51
- const args = src.slice(argsOpen + 1, argsClose);
52
- let target = "";
53
- if (!/\(/.test(structure.slice(chainStart, dot))) {
54
- // No locator call in the chain: the legacy `page.click("#save")` form, whose
55
- // first argument is the selector; or `page.goto(url)`.
56
- target = firstArgument(structure, src, argsOpen + 1, argsClose);
57
- if (!target)
58
- return undefined;
59
- }
60
- else if (TARGET_ARG.has(verb))
61
- target = args;
62
- if (verb === "goto") {
63
- chain = "";
64
- // A template target is keyed on its text, `${…}` included: generated helpers
65
- // prefix every path with one module constant (`${baseUrl}/login`), and
66
- // `/orders/${id}` is one page whatever the id. A bare variable is not a
67
- // target this can read.
68
- if (/^\s*`/.test(target))
69
- target = "`" + target.trim().slice(1, -1) + "`";
70
- else if (!/^\s*["']/.test(target))
71
- return undefined;
70
+ const gapBefore = steps.length === 0 ? "" : src.slice(prevEnd, chainStart);
71
+ // An action inside another action's arguments: its chain starts before the
72
+ // previous action ended, so there is no gap to judge and no run to join.
73
+ if (argsClose === -1 || chainStart < prevEnd) {
74
+ steps.push({ line, gapBefore, gapAfter: "" });
75
+ prevEnd = argsOpen + 1;
76
+ continue;
72
77
  }
73
- if (hasDynamicSelector(chain) || hasDynamicSelector(target))
78
+ prevEnd = argsClose + 1;
79
+ steps.push({
80
+ line,
81
+ gapBefore,
82
+ gapAfter: "",
83
+ key: stepKey(verb, src, structure, chainStart, dot, argsOpen, argsClose),
84
+ });
85
+ }
86
+ for (let i = 0; i < steps.length; i++)
87
+ steps[i].gapAfter =
88
+ i + 1 < steps.length ? steps[i + 1].gapBefore : src.slice(prevEnd);
89
+ return steps;
90
+ }
91
+ /** The key of one action, or `undefined` when its locator or target is unreadable. */
92
+ function stepKey(verb, src, structure, chainStart, dot, argsOpen, argsClose) {
93
+ let chain = src.slice(chainStart, dot);
94
+ const args = src.slice(argsOpen + 1, argsClose);
95
+ let target = "";
96
+ if (!/\(/.test(structure.slice(chainStart, dot))) {
97
+ // No locator call in the chain: the legacy `page.click("#save")` form, whose
98
+ // first argument is the selector; or `page.goto(url)`.
99
+ target = firstArgument(structure, src, argsOpen + 1, argsClose);
100
+ if (!target)
74
101
  return undefined;
75
- if (!/["'`]/.test(chain) && !/["'`]/.test(target))
102
+ }
103
+ else if (TARGET_ARG.has(verb))
104
+ target = args;
105
+ if (verb === "goto") {
106
+ chain = "";
107
+ // A template target is keyed on its text, `${…}` included: generated helpers
108
+ // prefix every path with one module constant (`${baseUrl}/login`), and
109
+ // `/orders/${id}` is one page whatever the id. A bare variable is not a
110
+ // target this can read.
111
+ if (/^\s*`/.test(target))
112
+ target = "`" + target.trim().slice(1, -1) + "`";
113
+ else if (!/^\s*["']/.test(target))
76
114
  return undefined;
77
- const step = [verb, normalize(chain), normalize(target)]
78
- .filter(Boolean)
79
- .join(" ");
80
- steps.push(step);
81
115
  }
82
- return steps.length > 0 ? steps.join("; ") : undefined;
116
+ if (hasDynamicSelector(chain) || hasDynamicSelector(target))
117
+ return undefined;
118
+ if (!/["'`]/.test(chain) && !/["'`]/.test(target))
119
+ return undefined;
120
+ return [verb, normalize(chain), normalize(target)].filter(Boolean).join(" ");
121
+ }
122
+ /** `click empty-cart-btn; fill qty` → `click \`empty-cart-btn\`; fill \`qty\`` — the
123
+ * key as the agent reads it, selectors quoted so a `#id` never reads as prose. */
124
+ export function describeActionKey(key) {
125
+ return key
126
+ .split("; ")
127
+ .map((step) => {
128
+ const at = step.indexOf(" ");
129
+ return `${step.slice(0, at)} \`${step.slice(at + 1)}\``;
130
+ })
131
+ .join("; ");
83
132
  }
84
133
  /** Index of the `)` that closes the `(` at `open`, on a string-blanked copy. */
85
134
  export function matchingClose(structure, open) {
@@ -170,7 +219,11 @@ function hasDynamicSelector(text) {
170
219
  return true;
171
220
  return false;
172
221
  }
173
- /** Whitespace and braces dropped outside string literals, `'` normalised to `"`. */
222
+ /** Whitespace and braces dropped outside string literals, `'` normalised to `"` —
223
+ * the spelling a locator chain has inside a key. */
224
+ export function normalizeChain(text) {
225
+ return normalize(text);
226
+ }
174
227
  function normalize(text) {
175
228
  let out = "";
176
229
  let quote = null;
@@ -243,7 +296,12 @@ function toPlaywrightJs(body) {
243
296
  * semicolons, to the end of its `.`-continued lines — so a Prettier-wrapped chain
244
297
  * is kept whole. A name bound more than once (`for (const row of rows)`), reassigned
245
298
  * (`t = page.…`), or used as a parameter is ambiguous and is left alone; a variable
246
- * receiver is unreadable. */
299
+ * receiver is unreadable.
300
+ *
301
+ * Deliberately file-wide when the sibling scan hands it a whole spec: a name bound
302
+ * in two `test()` blocks is then ambiguous and its steps lose their key, and the
303
+ * final substitution runs on raw text, so an alias name inside a selector string
304
+ * is rewritten too. Both only ever lose a match, never invent one. */
247
305
  function resolveLocatorAliases(body) {
248
306
  const structure = blankStringContents(body);
249
307
  const aliases = new Map();
@@ -251,7 +309,13 @@ function resolveLocatorAliases(body) {
251
309
  const name = m[1];
252
310
  const from = m.index + m[0].length;
253
311
  const to = valueEnd(structure, from);
254
- aliases.set(name, body.slice(from, to).trim().replace(/;$/, ""));
312
+ // Joined onto one line: the substitution must not move the lines of the
313
+ // actions after it (actionStepsOf reports each action's line).
314
+ aliases.set(name, body
315
+ .slice(from, to)
316
+ .trim()
317
+ .replace(/;$/, "")
318
+ .replace(/\s*\n\s*/g, " "));
255
319
  }
256
320
  for (const name of [...aliases.keys()]) {
257
321
  const esc = name.replace(/\$/g, "\\$");
@@ -271,6 +335,24 @@ function resolveLocatorAliases(body) {
271
335
  return body;
272
336
  return body.replace(/(?<![\w$.])([A-Za-z_$][\w$]*)(?=\s*\.)/g, (id) => aliases.get(id) ?? id);
273
337
  }
338
+ /** The statements of a fragment as `[start, end)` spans on `structure` (a
339
+ * string-blanked copy): each cut where valueEnd cuts — a `;` at depth 0, or a line
340
+ * end at depth 0 that the next line does not continue with `.` — so a
341
+ * Prettier-wrapped chain is one statement, not one per line. */
342
+ export function statementSpans(structure) {
343
+ const out = [];
344
+ let i = 0;
345
+ while (i < structure.length) {
346
+ while (i < structure.length && /[\s;]/.test(structure[i]))
347
+ i++;
348
+ if (i >= structure.length)
349
+ break;
350
+ const end = valueEnd(structure, i);
351
+ out.push([i, end]);
352
+ i = end + 1;
353
+ }
354
+ return out;
355
+ }
274
356
  /** End of an initialiser that starts at `from`: the `;` at paren depth 0, or a
275
357
  * newline at depth 0 whose next line does not continue the chain with `.`. */
276
358
  function valueEnd(structure, from) {
@@ -0,0 +1,32 @@
1
+ import { type UtilsHelper } from "./parse.js";
2
+ import { type UtilsLanguageSpec } from "./language-spec.js";
3
+ import { type InlineCallSite, type SiblingFile } from "./call-sites.js";
4
+ /**
5
+ * The browser family's sibling scan: a contiguous run of actions in OTHER
6
+ * Skyramp-generated tests beside `testFile` whose action sequence equals a utils
7
+ * helper's — the inline copy of a shared step (measured: three cohorts in a row, a
8
+ * run created the helper, rewired the two copies it happened to read and left the
9
+ * third, naming it in its own report).
10
+ *
11
+ * A browser step is a SEQUENCE, so the match is a sequence match: each helper's key
12
+ * (verb + locator chain per action, in body order — the same key STEP 3b matches a
13
+ * customer's helper on) is looked for as a run of consecutive actions in the
14
+ * sibling. Two things stand between "consecutive" and "one call can replace it":
15
+ *
16
+ * - **What sits between two actions of the run.** A structural wait (a presence
17
+ * matcher on a structural locator, a `waitFor…`), a fixed sleep or a comment is
18
+ * transparent — the helper's own body may hold the same, and the key ignores it
19
+ * either way. ANYTHING ELSE breaks the run: a value assertion (it cannot move
20
+ * across an action — the reuse policy's own boundary rule), a page-error guard,
21
+ * another call, a binding, or the end of the block the run started in. The test
22
+ * the gap is held to is the one the verify pass holds a helper body to, so a run
23
+ * is matched only where a helper could have held every line of it.
24
+ * - **A step this cannot read.** A helper whose key is undefined is no candidate;
25
+ * a sibling action with an undefined key equals nothing, so no run crosses it.
26
+ * Both fail open — no match, no finding — as STEP 3b's decline rule does.
27
+ *
28
+ * Where two helpers' keys start the same run, the longer wins; the run's actions are
29
+ * consumed and the scan continues after it. A run inside the sibling's own local
30
+ * helper is reported with that helper's name, as the API scan does.
31
+ */
32
+ export declare function findSiblingInlineActionSites(testFile: string, helpersByFile: ReadonlyMap<string, UtilsHelper[]>, spec: UtilsLanguageSpec, preReadSiblings: SiblingFile[] | undefined): Promise<InlineCallSite[]>;
@@ -0,0 +1,202 @@
1
+ import * as path from "path";
2
+ import { blankComments, localHelperAt, localHelpersOf, } from "./parse.js";
3
+ import { expectArgs, HELPER_FAMILIES, } from "./language-spec.js";
4
+ import { actionStepsOf, blankStringContents, normalizeChain, statementSpans, } from "./action-key.js";
5
+ import { realpath } from "./locate.js";
6
+ import { nonUtilsSiblings, } from "./call-sites.js";
7
+ /** A presence assertion as `[normalised locator, matcher]`, or undefined when the
8
+ * text is not one this can read. */
9
+ function presenceWait(text, spec) {
10
+ if (!spec.assertionRe.test(text))
11
+ return undefined;
12
+ const matcher = /\)\s*\.\s*((?:not[._]\s*)?to\w+)\s*\(/.exec(text);
13
+ if (!matcher)
14
+ return undefined;
15
+ return [normalizeChain(expectArgs(text)), matcher[1].replace(/\s+/g, "")];
16
+ }
17
+ /**
18
+ * The browser family's sibling scan: a contiguous run of actions in OTHER
19
+ * Skyramp-generated tests beside `testFile` whose action sequence equals a utils
20
+ * helper's — the inline copy of a shared step (measured: three cohorts in a row, a
21
+ * run created the helper, rewired the two copies it happened to read and left the
22
+ * third, naming it in its own report).
23
+ *
24
+ * A browser step is a SEQUENCE, so the match is a sequence match: each helper's key
25
+ * (verb + locator chain per action, in body order — the same key STEP 3b matches a
26
+ * customer's helper on) is looked for as a run of consecutive actions in the
27
+ * sibling. Two things stand between "consecutive" and "one call can replace it":
28
+ *
29
+ * - **What sits between two actions of the run.** A structural wait (a presence
30
+ * matcher on a structural locator, a `waitFor…`), a fixed sleep or a comment is
31
+ * transparent — the helper's own body may hold the same, and the key ignores it
32
+ * either way. ANYTHING ELSE breaks the run: a value assertion (it cannot move
33
+ * across an action — the reuse policy's own boundary rule), a page-error guard,
34
+ * another call, a binding, or the end of the block the run started in. The test
35
+ * the gap is held to is the one the verify pass holds a helper body to, so a run
36
+ * is matched only where a helper could have held every line of it.
37
+ * - **A step this cannot read.** A helper whose key is undefined is no candidate;
38
+ * a sibling action with an undefined key equals nothing, so no run crosses it.
39
+ * Both fail open — no match, no finding — as STEP 3b's decline rule does.
40
+ *
41
+ * Where two helpers' keys start the same run, the longer wins; the run's actions are
42
+ * consumed and the scan continues after it. A run inside the sibling's own local
43
+ * helper is reported with that helper's name, as the API scan does.
44
+ */
45
+ export async function findSiblingInlineActionSites(testFile, helpersByFile, spec, preReadSiblings) {
46
+ const sequences = helperSequences(helpersByFile, spec);
47
+ if (sequences.length === 0)
48
+ return [];
49
+ const siblings = await nonUtilsSiblings(testFile, [...helpersByFile.keys()], spec, preReadSiblings, false);
50
+ const self = await realpath(testFile);
51
+ const out = [];
52
+ for (const { file, content } of siblings) {
53
+ if (path.resolve(file) === self)
54
+ continue;
55
+ const localHelpers = localHelpersOf(content, spec);
56
+ // Comments blanked, strings kept — the same reading the helper's key was made
57
+ // from, so a commented-out click is not a step and the selectors compare.
58
+ const steps = actionStepsOf(blankComments(content, spec), spec.language);
59
+ let i = 0;
60
+ while (i < steps.length) {
61
+ const match = sequences.find((s) => runMatches(steps, i, s, spec));
62
+ if (!match) {
63
+ i++;
64
+ continue;
65
+ }
66
+ const first = steps[i];
67
+ const last = steps[i + match.steps.length - 1];
68
+ const owner = localHelperAt(localHelpers, first.line);
69
+ out.push({
70
+ kind: "sequence",
71
+ file,
72
+ line: first.line,
73
+ endLine: last.line,
74
+ actionKey: match.key,
75
+ helper: match.helper,
76
+ utilsFile: match.utilsFile,
77
+ ...(owner ? { localHelper: owner.name } : {}),
78
+ });
79
+ i += match.steps.length;
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+ /** Whether the helper's sequence is the run of actions starting at `at`, with
85
+ * nothing between its actions that a helper body could not hold, and no wait in
86
+ * or right after the run that contradicts one of the helper's own. */
87
+ function runMatches(steps, at, helper, spec) {
88
+ const want = helper.steps;
89
+ if (at + want.length > steps.length)
90
+ return false;
91
+ for (let k = 0; k < want.length; k++) {
92
+ const step = steps[at + k];
93
+ if (step.key === undefined || step.key !== want[k])
94
+ return false;
95
+ if (k > 0) {
96
+ const between = transparentStatements(step.gapBefore, spec);
97
+ if (between === undefined)
98
+ return false;
99
+ if (contradictsWaits(between, helper.waits, spec))
100
+ return false;
101
+ }
102
+ }
103
+ // The structural waits that follow the last action are the run's too: a wait the
104
+ // helper would perform, or its opposite.
105
+ const trailing = transparentStatements(steps[at + want.length - 1].gapAfter, spec, { prefix: true });
106
+ return !contradictsWaits(trailing ?? [], helper.waits, spec);
107
+ }
108
+ /** Whether one of `statements` asserts an element the helper waits on with a
109
+ * different matcher or negation. */
110
+ function contradictsWaits(statements, waits, spec) {
111
+ for (const s of statements) {
112
+ const wait = presenceWait(s.replace(/^await\s+/, ""), spec);
113
+ if (!wait)
114
+ continue;
115
+ const held = waits.get(wait[0]);
116
+ if (held !== undefined && held !== wait[1])
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+ /**
122
+ * The statements of `gap` that a shared browser helper may hold — a permitted
123
+ * structural wait, as the verify pass judges one (`HELPER_FAMILIES.browser`), or a
124
+ * `waitFor…` — or `undefined` when one is not, or when the gap closes a bracket it
125
+ * did not open. With `prefix`, the leading transparent statements up to the first
126
+ * that is not: the waits that follow a run, read until the run's own text ends.
127
+ * Statements are cut as statementSpans cuts them, on a string-blanked copy, so a
128
+ * `;` inside a literal is not a boundary and a Prettier-wrapped chain is one
129
+ * statement. Conservative: an unfamiliar statement ends the run.
130
+ */
131
+ function transparentStatements(gap, spec, opts = {}) {
132
+ const structure = blankStringContents(gap);
133
+ if (!opts.prefix && leavesBlock(structure))
134
+ return undefined;
135
+ const out = [];
136
+ for (const [from, to] of statementSpans(structure)) {
137
+ const text = gap.slice(from, to).trim();
138
+ // The `await` that precedes the next action's chain.
139
+ if (text === "await")
140
+ continue;
141
+ const body = text.replace(/^await\s+/, "");
142
+ const transparent = WAIT_STATEMENT_RE.test(blankStringContents(body)) ||
143
+ (spec.assertionRe.test(body) &&
144
+ HELPER_FAMILIES.browser.isAllowed(spec, body));
145
+ if (!transparent)
146
+ return opts.prefix ? out : undefined;
147
+ out.push(text);
148
+ }
149
+ return out;
150
+ }
151
+ /** A closing bracket the fragment did not open: the run leaves its block. */
152
+ function leavesBlock(structure) {
153
+ let depth = 0;
154
+ for (const ch of structure) {
155
+ if ("([{".includes(ch))
156
+ depth++;
157
+ else if (")]}".includes(ch) && --depth < 0)
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ /** `page.waitForTimeout(3000)`, `page.waitForLoadState()`, `page.getByTestId("x")
163
+ * .waitFor({ state: "visible" })` — a wait on the page or on a locator chain, as a
164
+ * statement of its own (not bound: `const p = page.waitForResponse(…)` is used
165
+ * later, and the run cannot carry it). */
166
+ const WAIT_STATEMENT_RE = /^(?:this\.)?page(?:\s*\.\s*[\w$]+\s*\([^;]*\))*\s*\.\s*(?:waitFor\w*|wait_for\w*)\s*\([^;]*\)\s*$/;
167
+ /** A step whose receiver is the keyboard or the mouse rather than a locator. */
168
+ function isElementlessStep(step) {
169
+ return /^\w+ (?:this\.)?page\.(?:keyboard|mouse)(?:\s|$)/.test(step);
170
+ }
171
+ /** Every keyed browser helper across the utils files, in file then body order, from
172
+ * the helpers the verify pass already parsed. The first helper to carry a key owns
173
+ * it; a second (the `duplicate-action` advisory's case) is not reported twice. */
174
+ function helperSequences(helpersByFile, spec) {
175
+ const out = [];
176
+ const seen = new Set();
177
+ for (const [utilsFile, helpers] of helpersByFile)
178
+ for (const h of helpers) {
179
+ if (!h.actionKey || !h.actionSteps || seen.has(h.actionKey))
180
+ continue;
181
+ // A key of bare keyboard or mouse steps names no element: `press "Escape"`
182
+ // is what closes a dialog in one helper and a menu in the next, and every
183
+ // Escape in every sibling matched it (measured: fixture 22, the advisory
184
+ // named a menu close for a dialog-closing helper). One locator-bearing step
185
+ // is enough to identify the sequence.
186
+ if (h.actionKey.split("; ").every(isElementlessStep))
187
+ continue;
188
+ seen.add(h.actionKey);
189
+ out.push({
190
+ helper: h.name,
191
+ utilsFile,
192
+ key: h.actionKey,
193
+ steps: h.actionSteps,
194
+ waits: new Map(h.assertions
195
+ .map((a) => presenceWait(a.text, spec))
196
+ .filter((w) => w !== undefined)),
197
+ });
198
+ }
199
+ // Longest first, so the run a longer sequence covers is not claimed by a shorter
200
+ // helper that shares its opening actions.
201
+ return out.sort((a, b) => b.steps.length - a.steps.length);
202
+ }
@@ -1,5 +1,5 @@
1
1
  import * as fs from "fs";
2
- import { paramName, parseUtilsFile, signatureParams, } from "./parse.js";
2
+ import { localHelpersOf, paramName, parseUtilsFile, signatureParams, } from "./parse.js";
3
3
  import { callText, generatedSiblings, REQUEST_CALL_RE, } from "./call-sites.js";
4
4
  /** Words a fallback idiom spells that name no value: `x or {}`, `x ?? {}`,
5
5
  * `x if x else None`. What remains after them is what the override actually sends. */
@@ -320,9 +320,7 @@ export async function findUnreachableBodies(testFile, utilsFiles, spec, preReadS
320
320
  (await generatedSiblings(testFile, spec, { includeSelf: true }));
321
321
  const out = [];
322
322
  for (const { file, content } of siblings) {
323
- for (const h of parseUtilsFile(content, spec)) {
324
- if (/^test/i.test(h.name))
325
- continue;
323
+ for (const h of localHelpersOf(content, spec)) {
326
324
  if (!h.method || !h.normalizedPath)
327
325
  continue;
328
326
  const held = frozen.get(`${h.method} ${h.normalizedPath}`);
@@ -22,14 +22,13 @@ export declare function generatedSiblings(testFile: string, spec: UtilsLanguageS
22
22
  }): Promise<SiblingFile[]>;
23
23
  /** An SDK request call. Global: clone with `new RegExp(...)` before `matchAll`. */
24
24
  export declare const REQUEST_CALL_RE: RegExp;
25
- export interface InlineCallSite {
25
+ interface InlineSiteBase {
26
26
  /** Absolute path of the sibling test. */
27
27
  file: string;
28
- /** 1-based line of the request call. */
28
+ /** 1-based line of the request call, or of the first action of the sequence. */
29
29
  line: number;
30
- method: string;
31
- path: string;
32
- /** The utils helper that wraps the same method+path. */
30
+ /** The utils helper that wraps the same method+path, or performs the same
31
+ * action sequence. */
33
32
  helper: string;
34
33
  /** Absolute path of the utils file that defines `helper` — with several modules in
35
34
  * scope, the advisory names this one, not the list. */
@@ -39,6 +38,19 @@ export interface InlineCallSite {
39
38
  * block-by-block. */
40
39
  localHelper?: string;
41
40
  }
41
+ /** An inline copy of a shared step: one request keyed by route (the API family), or
42
+ * a run of actions keyed by action sequence (the browser family). */
43
+ export type InlineCallSite = (InlineSiteBase & {
44
+ kind: "request";
45
+ method: string;
46
+ path: string;
47
+ }) | (InlineSiteBase & {
48
+ kind: "sequence";
49
+ /** The action key the sequence and the helper share (see actionKeyOf). */
50
+ actionKey: string;
51
+ /** 1-based line of the sequence's last action. */
52
+ endLine: number;
53
+ });
42
54
  /**
43
55
  * Inline request calls in OTHER Skyramp-generated tests beside `testFile` that a
44
56
  * utils helper already wraps — the reuse the prompt asks for and the agent does not
@@ -54,7 +66,9 @@ export interface InlineCallSite {
54
66
  * normalised path, nothing else — literals are what the call's arguments are for.
55
67
  *
56
68
  * Advisory by design: it names a change to a PRE-EXISTING test, which the maintenance
57
- * flow owns, so it informs rather than gates. Never throws.
69
+ * flow owns, so it informs rather than gates. Never throws. The API family's scan;
70
+ * findSiblingInlineActionSites is the browser family's, and inlineCallSiteFamily
71
+ * decides which runs.
58
72
  */
59
73
  export declare function findSiblingInlineCallSites(testFile: string, utilsFiles: string[], _helpers: UtilsHelper[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<InlineCallSite[]>;
60
74
  /** One operation written in two or more sibling generated specs that no shared
@@ -95,6 +109,10 @@ export interface UnextractedSite {
95
109
  * different question. Advisory by design — consolidation is a judgement. Never throws.
96
110
  */
97
111
  export declare function findUnextractedDuplicates(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings?: SiblingFile[]): Promise<UnextractedDuplicate[]>;
112
+ /** The generated siblings less the utils files themselves (a utils file carries no
113
+ * codegen marker, so this is belt and braces). Pre-read siblings are used as given —
114
+ * the caller's walk decides whether the spec itself is among them. */
115
+ export declare function nonUtilsSiblings(testFile: string, utilsFiles: string[], spec: UtilsLanguageSpec, preReadSiblings: SiblingFile[] | undefined, includeSelf: boolean): Promise<SiblingFile[]>;
98
116
  /** Lines after a call scanned for its schema assertion — the generated shape puts it
99
117
  * immediately after the status-code assertion. */
100
118
  export declare const SCHEMA_SCAN_LINES = 12;
@@ -144,3 +162,4 @@ export declare function callShape(call: string): Set<string>;
144
162
  export declare function shapeKeys(call: string): Set<string>;
145
163
  /** Text of the parenthesised argument list starting at the `(` at `open`. */
146
164
  export declare function callText(content: string, open: number): string;
165
+ export {};