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

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 (110) 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/generate-tests/generateE2ERestTool.d.ts +0 -1
  36. package/build/tools/generate-tests/generateE2ERestTool.js +1 -9
  37. package/build/tools/generate-tests/generateUIRestTool.d.ts +0 -2
  38. package/build/tools/generate-tests/generateUIRestTool.js +1 -9
  39. package/build/tools/submitReportTool.js +128 -0
  40. package/build/tools/test-management/actionsTool.js +31 -0
  41. package/build/tools/test-management/analyzeChangesTool.d.ts +4 -4
  42. package/build/tools/test-management/analyzeTestHealthTool.d.ts +0 -11
  43. package/build/tools/test-management/analyzeTestHealthTool.js +7 -63
  44. package/build/tools/test-management/testsOwedBeforeRun.d.ts +28 -0
  45. package/build/tools/test-management/testsOwedBeforeRun.js +53 -0
  46. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  47. package/build/types/RepositoryAnalysis.d.ts +32 -32
  48. package/build/types/ReuseOutcome.d.ts +4 -3
  49. package/build/types/TestExecution.d.ts +2 -2
  50. package/build/types/TestTypes.d.ts +3 -7
  51. package/build/types/TestTypes.js +6 -20
  52. package/build/utils/AnalysisStateManager.d.ts +0 -7
  53. package/build/utils/AnalysisStateManager.js +1 -1
  54. package/build/utils/connectionErrors.d.ts +10 -0
  55. package/build/utils/connectionErrors.js +10 -0
  56. package/build/utils/language-helper.js +24 -3
  57. package/build/utils/progress.d.ts +1 -1
  58. package/build/utils/progress.js +1 -1
  59. package/build/utils/rebaselineSnapshots.d.ts +1 -1
  60. package/build/utils/rebaselineSnapshots.js +6 -16
  61. package/build/utils/reuseRouting.d.ts +10 -0
  62. package/build/utils/reuseRouting.js +15 -0
  63. package/build/utils/runContextGauge.d.ts +27 -0
  64. package/build/utils/runContextGauge.js +181 -0
  65. package/build/utils/skyrampMdContent.d.ts +1 -1
  66. package/build/utils/skyrampMdContent.js +1 -1
  67. package/build/utils/skyrampSdkVersion.d.ts +9 -0
  68. package/build/utils/skyrampSdkVersion.js +16 -0
  69. package/build/utils/testDependencyPolicy.js +21 -0
  70. package/build/utils/testExecutionRecord.d.ts +5 -1
  71. package/build/utils/testExecutionRecord.js +3 -1
  72. package/build/utils/testFileClassification.d.ts +8 -0
  73. package/build/utils/testFileClassification.js +36 -3
  74. package/build/utils/utils-verify/action-key.d.ts +42 -0
  75. package/build/utils/utils-verify/action-key.js +118 -36
  76. package/build/utils/utils-verify/action-sites.d.ts +32 -0
  77. package/build/utils/utils-verify/action-sites.js +202 -0
  78. package/build/utils/utils-verify/body-reach.js +2 -4
  79. package/build/utils/utils-verify/call-sites.d.ts +25 -6
  80. package/build/utils/utils-verify/call-sites.js +8 -5
  81. package/build/utils/utils-verify/index.d.ts +1 -0
  82. package/build/utils/utils-verify/index.js +1 -0
  83. package/build/utils/utils-verify/language-spec.d.ts +25 -0
  84. package/build/utils/utils-verify/language-spec.js +16 -2
  85. package/build/utils/utils-verify/parse.d.ts +10 -1
  86. package/build/utils/utils-verify/parse.js +19 -2
  87. package/build/utils/utils-verify/verify.d.ts +3 -2
  88. package/build/utils/utils-verify/verify.js +16 -18
  89. package/build/workspace/workspace.d.ts +72 -52
  90. package/build/workspace/workspace.js +12 -8
  91. package/package.json +1 -1
  92. package/plugin/prompts/testbot-task1.md +0 -2
  93. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -1
  94. package/plugin/skills/enhance-assertions/reference/ui.md +1 -1
  95. package/plugin/skills/fix-test-import-errors/SKILL.md +2 -1
  96. package/plugin/skills/run-test/SKILL.md +16 -0
  97. package/build/adapters/jestAdapter.d.ts +0 -14
  98. package/build/adapters/jestAdapter.js +0 -131
  99. package/build/adapters/mochaAdapter.d.ts +0 -13
  100. package/build/adapters/mochaAdapter.js +0 -93
  101. package/build/adapters/playwrightAdapter.d.ts +0 -17
  102. package/build/adapters/playwrightAdapter.js +0 -184
  103. package/build/adapters/pytestAdapter.d.ts +0 -15
  104. package/build/adapters/pytestAdapter.js +0 -119
  105. package/build/tools/runExistingTestsTool.d.ts +0 -138
  106. package/build/tools/runExistingTestsTool.js +0 -666
  107. package/build/types/ExternalTestExecution.d.ts +0 -67
  108. package/build/types/ExternalTestExecution.js +0 -8
  109. package/build/workspace/testSuites.d.ts +0 -20
  110. package/build/workspace/testSuites.js +0 -17
@@ -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 {};
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { SKYRAMP_UTILS_HEADER } from "../utils.js";
4
- import { blankCommentsAndStrings, isSingleLiteral, parseUtilsFile, } from "./parse.js";
4
+ import { blankCommentsAndStrings, isSingleLiteral, localHelperAt, localHelpersOf, parseUtilsFile, } from "./parse.js";
5
5
  import { normalizePath } from "./language-spec.js";
6
6
  import { realpath } from "./locate.js";
7
7
  import { escapeRegExp } from "../regex.js";
@@ -66,7 +66,9 @@ export const REQUEST_CALL_RE = /\b(?:send_request|sendRequest)\s*\(/g;
66
66
  * normalised path, nothing else — literals are what the call's arguments are for.
67
67
  *
68
68
  * Advisory by design: it names a change to a PRE-EXISTING test, which the maintenance
69
- * 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.
70
72
  */
71
73
  export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers, spec, preReadSiblings) {
72
74
  const byRoute = await helperRoutes(utilsFiles, spec);
@@ -91,6 +93,7 @@ export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers,
91
93
  }))
92
94
  continue;
93
95
  out.push({
96
+ kind: "request",
94
97
  file,
95
98
  line: c.line,
96
99
  method: c.method,
@@ -178,7 +181,7 @@ async function helperRoutes(utilsFiles, spec) {
178
181
  /** The generated siblings less the utils files themselves (a utils file carries no
179
182
  * codegen marker, so this is belt and braces). Pre-read siblings are used as given —
180
183
  * the caller's walk decides whether the spec itself is among them. */
181
- async function nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, includeSelf) {
184
+ export async function nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, includeSelf) {
182
185
  const utilsSet = new Set(await Promise.all(utilsFiles.map((f) => realpath(f))));
183
186
  return (preReadSiblings ??
184
187
  (await generatedSiblings(testFile, spec, { includeSelf }))).filter((sib) => !utilsSet.has(path.resolve(sib.file)));
@@ -189,7 +192,7 @@ async function nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, inc
189
192
  * commented-out or quoted request is never a site and a `)` inside a string never
190
193
  * truncates the argument text, which is read from the original. */
191
194
  function requestCallsIn(content, spec) {
192
- const localHelpers = parseUtilsFile(content, spec).filter((h) => !/^test/i.test(h.name));
195
+ const localHelpers = localHelpersOf(content, spec);
193
196
  const scan = blankCommentsAndStrings(content, spec);
194
197
  const out = [];
195
198
  for (const m of scan.matchAll(REQUEST_CALL_RE)) {
@@ -205,7 +208,7 @@ function requestCallsIn(content, spec) {
205
208
  if (!method || !pm || !isSingleLiteral(call, pm))
206
209
  continue;
207
210
  const p = pm[1];
208
- const owner = localHelpers.find((h) => line >= h.line && line <= h.endLine);
211
+ const owner = localHelperAt(localHelpers, line);
209
212
  out.push({
210
213
  line,
211
214
  start,
@@ -5,5 +5,6 @@ export * from "./locate.js";
5
5
  export * from "./verify.js";
6
6
  export * from "./stage.js";
7
7
  export * from "./call-sites.js";
8
+ export * from "./action-sites.js";
8
9
  export * from "./retrofit-equivalence.js";
9
10
  export * from "./in-house.js";
@@ -5,5 +5,6 @@ export * from "./locate.js";
5
5
  export * from "./verify.js";
6
6
  export * from "./stage.js";
7
7
  export * from "./call-sites.js";
8
+ export * from "./action-sites.js";
8
9
  export * from "./retrofit-equivalence.js";
9
10
  export * from "./in-house.js";
@@ -78,7 +78,32 @@ export interface HelperFamilySpec {
78
78
  /** One clause for the fix-instruction sentence — composed by the gate, never
79
79
  * restated there (the browser clause reads as a continuation of the api one). */
80
80
  fixRule: string;
81
+ /** The sibling inline call-site advisory's family-specific half: what a site is
82
+ * called, what the helper already does with it, and how to replace it. The gate
83
+ * appends the tail both families share. */
84
+ inlineSite: {
85
+ /** The prompt's noun for a site — `HelperPolicy.inlineSite.noun`. */
86
+ noun: string;
87
+ /** `… that a shared helper already <verb>:` */
88
+ already: string;
89
+ /** What makes a site a candidate, and what the replacement keeps. */
90
+ instruction: string;
91
+ /** The copy as the shared tail names it inside a local helper: `that request`
92
+ * / `the sequence`. */
93
+ localCopy: string;
94
+ };
81
95
  }
96
+ /**
97
+ * The RECEIVED value inside `expect(...)` — its first argument, up to the matcher.
98
+ *
99
+ * Only the first, because Playwright takes a message as the second: `expect(locator,
100
+ * "why this matters")`. Reading every argument let that message decide, so a
101
+ * structural wait whose message merely NAMED a text-selecting form was refused — and
102
+ * a refusal blocks a delivery that is right, which is worse than missing one that is
103
+ * wrong. The split walks bracket depth on a copy whose strings are blanked, so a comma
104
+ * inside a selector or a message does not end the argument.
105
+ */
106
+ export declare function expectArgs(expr: string): string;
82
107
  export declare const HELPER_FAMILIES: Record<HelperFamily, HelperFamilySpec>;
83
108
  /** The helper family a test type's shared helpers follow. Unlike `isBrowserTestType`,
84
109
  * an ABSENT test type resolves to `api`: this runs against a utils file that already