@skyramp/mcp 0.5.0-rc.7 → 0.5.0-rc.9

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.
@@ -1,3 +1,4 @@
1
+ export declare const PICKED_OPTION_RULE = "ONE exception, because there the selector IS the value this scenario selects: the locator of the option or item the scenario picks from a list, when its test id or role name carries that option's label (`getByTestId(\"add-order-product-option-Adidas Running Shoes\")`, `getByRole(\"option\", { name: \"Adidas Running Shoes\" })`). That one argument becomes a parameter, and the call site passes the original string whole and byte-identical (`getByTestId(productOptionTestId)` called with `\"add-order-product-option-Adidas Running Shoes\"`; `getByRole(\"option\", { name: productName })` called with `\"Adidas Running Shoes\"`) \u2014 never rebuilt from a template or a shorter value. Every other selector, including the control that opens the list and the button that confirms the pick, stays verbatim.";
1
2
  export declare function getModularizationPrompt(filePath: string, testType: string, language?: string): string;
2
3
  /** Structured view of which steps render for the given inputs (for tests). */
3
4
  export declare function previewModularizationPlan(filePath: string, testType: string, language?: string): import("../test-recommendation/promptPlan.js").PlanPreview;
@@ -151,7 +151,7 @@ ${p.utilsReuse
151
151
  **SHARED HELPER SHAPE:**
152
152
  - [ ] Every helper name is a canonical intent (\`openEditOrderForm\`, \`applyDiscount\`) with no scenario value or adjective in it
153
153
  - [ ] No helper contains a value assertion (${BROWSER_VALUE_ASSERTIONS}) — each sits in the test body, right after the helper call it verifies
154
- - [ ] Every selector inside a helper is byte-identical to the original test
154
+ - [ ] Every selector inside a helper is byte-identical to the original test, except a picked option's locator argument, which is a parameter whose call-site argument is byte-identical
155
155
  - [ ] The assertion COUNT is unchanged: every \`expect(\` from the original is still in this file or inside a helper it calls, and the \`page.on('pageerror'...)\` guard and its final \`expect\` over the collected errors are still here — cross-cutting guards are never removed (the verify step counts both)
156
156
  `
157
157
  : ""}
@@ -184,10 +184,17 @@ If ANY answer is "no", fix it before submitting.
184
184
  // scenario's data — a `toHaveText("Total: $899.98")` inside a shared helper
185
185
  // fails every other caller. Selectors stay verbatim regardless (a selector is
186
186
  // not scenario data; parameterizing it invents a template the recorder never saw).
187
+ // SKYR-4537: the one selector that is scenario data. An option picked from a list
188
+ // is often located by its own label (test id or role name), so the value the
189
+ // scenario selects lives only inside that locator. Kept verbatim in the helper it
190
+ // pins every caller to this scenario's option, and the reuse scan can never match
191
+ // another test that picks a different one. Passing the whole string (no template)
192
+ // keeps the selector byte-identical at the call site.
193
+ export const PICKED_OPTION_RULE = 'ONE exception, because there the selector IS the value this scenario selects: the locator of the option or item the scenario picks from a list, when its test id or role name carries that option\'s label (`getByTestId("add-order-product-option-Adidas Running Shoes")`, `getByRole("option", { name: "Adidas Running Shoes" })`). That one argument becomes a parameter, and the call site passes the original string whole and byte-identical (`getByTestId(productOptionTestId)` called with `"add-order-product-option-Adidas Running Shoes"`; `getByRole("option", { name: productName })` called with `"Adidas Running Shoes"`) — never rebuilt from a template or a shorter value. Every other selector, including the control that opens the list and the button that confirms the pick, stays verbatim.';
187
194
  function sharedHelperShape() {
188
195
  return `**SHARED HELPER SHAPE (this project consolidates helpers into a shared utils file):**
189
196
  - ${BROWSER_HELPERS.namingRule}
190
- - **PARAMETERS**: \`page\`, plus every value this scenario types or selects (${BROWSER_HELPERS.liftableLiterals}). Selectors are NEVER parameters — they stay verbatim inside the helper body.
197
+ - **PARAMETERS**: \`page\`, plus every value this scenario types or selects (${BROWSER_HELPERS.liftableLiterals}). Selectors are NEVER parameters — they stay verbatim inside the helper body. ${PICKED_OPTION_RULE}
191
198
  - ${BROWSER_HELPERS.assertionRule} ${ASSERTION_BOUNDARY_RULE_EXTRACT}
192
199
  - Helpers are module-level named functions in this file; the test body becomes navigation + a sequence of helper calls + this scenario's assertions, in the original order.`;
193
200
  }
@@ -469,8 +469,9 @@ export function registerCodeReuseTool(server) {
469
469
  return errorResult;
470
470
  }
471
471
  finally {
472
+ // No prompt: it carries the user's test code, and PushToolEvent
473
+ // forwards every field verbatim.
472
474
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
473
- prompt: params.prompt,
474
475
  testFile: params.testFile,
475
476
  language: params.language,
476
477
  framework: params.framework,
@@ -248,7 +248,7 @@ function inlineCallSitesAdvisory(r) {
248
248
  "",
249
249
  "",
250
250
  `ADVISORY — ${n} ${family.inlineSite.noun}${n === 1 ? "" : "s"} in other Skyramp-generated tests that a shared helper already ${family.inlineSite.already}:`,
251
- ...r.inlineCallSites.map((c) => `- ${show(c.file, r)}:${lines(c)}${c.localHelper ? ` (in local helper ${c.localHelper})` : ""} · ${what(c)} · ${labelOf(c.utilsFile)}.${c.helper}`),
251
+ ...r.inlineCallSites.map((c) => `- ${show(c.file, r)}:${lines(c)}${c.localHelper ? ` (in local helper ${c.localHelper})` : ""} · ${what(c)} · ${labelOf(c.utilsFile)}.${c.helper}${c.kind === "sequence" && c.call ? ` · replace lines ${lines(c)} (the generated bindings in them included) with \`${c.call}\`` : ""}`),
252
252
  `${family.inlineSite.instruction} ${inlineSiteTail(family.inlineSite.localCopy)}`,
253
253
  ].join("\n");
254
254
  }
@@ -58,8 +58,9 @@ export function registerFixErrorTool(server) {
58
58
  return errorResult;
59
59
  }
60
60
  finally {
61
+ // No prompt: it carries the user's test code, and PushToolEvent
62
+ // forwards every field verbatim.
61
63
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
62
- prompt: params.prompt,
63
64
  testFile: params.testFile,
64
65
  }).catch((err) => {
65
66
  logger.warning("Analytics event failed", { error: String(err) });
@@ -37,7 +37,9 @@ export interface ActionStep {
37
37
  * do not balance. A helper with one such step has no key; a spec's other steps
38
38
  * are still read, so an unreadable step is a boundary no matched run crosses. */
39
39
  key?: string;
40
- /** 1-based line of the action verb. */
40
+ /** 1-based line where the action's chain starts (`page` in a prettier-wrapped
41
+ * `await page\n .getByTestId(…)\n .click()`); the verb's line when the chain
42
+ * starts inside the previous action. */
41
43
  line: number;
42
44
  /** The source between the previous action's closing `)` (or the start of the
43
45
  * body) and this action's chain: what stands between two consecutive actions.
@@ -47,6 +49,11 @@ export interface ActionStep {
47
49
  /** The source after this action's closing `)` up to the next action's chain, or
48
50
  * to the end of the body for the last step — the next step's `gapBefore`. */
49
51
  gapAfter: string;
52
+ /** 1-based line of the action's closing `)`, where `gapAfter` starts. */
53
+ endLine: number;
54
+ /** The action's first argument as written (`""` when it has none): the value a
55
+ * `fill` types, which the key leaves out. */
56
+ value: string;
50
57
  }
51
58
  /**
52
59
  * Every action in `body`, in order, each keyed as `actionKeyOf` keys it. Where
@@ -55,12 +62,62 @@ export interface ActionStep {
55
62
  * copy of a helper's sequence needs. Lines survive the Python and alias rewrites:
56
63
  * neither adds or removes a newline.
57
64
  */
58
- export declare function actionStepsOf(body: string, language?: UtilsLanguage): ActionStep[];
65
+ export declare function actionStepsOf(body: string, language?: UtilsLanguage,
66
+ /** A helper's parameter names: a locator argument (or `name:` option) that is
67
+ * one of them keys as the wildcard `paramSlot(name)` instead of failing. */
68
+ params?: ReadonlySet<string>): ActionStep[];
59
69
  /** `click empty-cart-btn; fill qty` → `click \`empty-cart-btn\`; fill \`qty\`` — the
60
70
  * key as the agent reads it, selectors quoted so a `#id` never reads as prose. */
61
71
  export declare function describeActionKey(key: string): string;
62
72
  /** Index of the `)` that closes the `(` at `open`, on a string-blanked copy. */
63
73
  export declare function matchingClose(structure: string, open: number): number;
74
+ /** The text a parameter-fed locator argument keys as: a string no selector holds.
75
+ * A key holding SLOT_MARK holds a wildcard. */
76
+ export declare const SLOT_MARK = "\u0001";
77
+ export declare function paramSlot(name: string): string;
78
+ /** What an action's value is, as `resolveStepValue` reads it: one of the reading
79
+ * helper's parameters, a literal, or another expression (kept as written). */
80
+ export interface StepValue {
81
+ kind: "param" | "literal" | "expr";
82
+ text: string;
83
+ /** The zero-argument calls chained on a resolved `getValue(…)`, spaces dropped:
84
+ * `.toString()` for a number the fill types. */
85
+ suffix?: string;
86
+ }
87
+ /**
88
+ * An action's value (`ActionStep.value`) as the sibling scan compares it: a bare
89
+ * parameter of the helper, a literal, or the generated `getValue(playwrightRequestN,
90
+ * "key")` — a dotted key read through nested objects, a chained `.toString()` kept
91
+ * as `suffix` — read through the binding that holds it (`requestBindingsOf`), to
92
+ * the parameter or literal stored under that key. Anything else is an expression,
93
+ * compared as written.
94
+ */
95
+ export declare function resolveStepValue(raw: string, bindings: ReadonlyMap<string, string>, params?: ReadonlySet<string>): StepValue;
96
+ /** The generator's `const playwrightRequestN: Record<…> = { … }` bindings in `text`,
97
+ * by name, to the object literal as written. A name bound twice is left out. */
98
+ export declare function requestBindingsOf(text: string): Map<string, string>;
99
+ /** The normalised arguments of every `page.waitForResponse(…)` in `text`. */
100
+ export declare function responseWaitsOf(text: string): string[];
101
+ /** A use of the identifier `name`: not part of a longer name or a property access. */
102
+ export declare function identifierUseRe(name: string, flags?: string): RegExp;
103
+ /** A declared parameter's name (see paramName), or `undefined` for a rest or
104
+ * destructured one: no single value a call or a wildcard can carry. */
105
+ export declare function simpleParamName(raw: string): string | undefined;
106
+ /** A browser helper's sequence as the sibling scan matches it (SKYR-4537). */
107
+ export interface ActionPattern {
108
+ /** One key per step, as `actionStepKeys` keys it, except that a locator argument
109
+ * fed by a parameter is the wildcard `paramSlot(name)`. */
110
+ steps: string[];
111
+ /** Each step's value, read through the helper's own request bindings. */
112
+ values: StepValue[];
113
+ /** The helper's `waitForResponse` arguments (see responseWaitsOf). */
114
+ responseWaits: string[];
115
+ /** The parameters the body reads: one a call leaves out changes what it does. */
116
+ usedParams: string[];
117
+ }
118
+ /** The helper's ActionPattern, or `undefined` when a step stays unreadable with
119
+ * its parameters as wildcards. `params` are the declared parameters as written. */
120
+ export declare function actionPatternOf(body: string, language: UtilsLanguage, params: readonly string[]): ActionPattern | undefined;
64
121
  /** Whitespace and braces dropped outside string literals, `'` normalised to `"` —
65
122
  * the spelling a locator chain has inside a key. */
66
123
  export declare function normalizeChain(text: string): string;
@@ -1,3 +1,5 @@
1
+ import { escapeRegExp } from "../regex.js";
2
+ import { paramName } from "./parse.js";
1
3
  /**
2
4
  * The Playwright actions a browser helper can perform — the closed vocabulary that
3
5
  * gives the action VERB, where an API helper's method comes from the request. A
@@ -52,7 +54,10 @@ export function actionStepKeys(steps) {
52
54
  * copy of a helper's sequence needs. Lines survive the Python and alias rewrites:
53
55
  * neither adds or removes a newline.
54
56
  */
55
- export function actionStepsOf(body, language = "typescript") {
57
+ export function actionStepsOf(body, language = "typescript",
58
+ /** A helper's parameter names: a locator argument (or `name:` option) that is
59
+ * one of them keys as the wildcard `paramSlot(name)` instead of failing. */
60
+ params) {
56
61
  const src = resolveLocatorAliases(language === "python" ? toPlaywrightJs(body) : body);
57
62
  // Structure is read on a copy with string CONTENTS blanked (same length), so a
58
63
  // `)` or `.` inside a literal never ends a chain, and a `.click(` written inside
@@ -63,7 +68,7 @@ export function actionStepsOf(body, language = "typescript") {
63
68
  for (const m of structure.matchAll(ACTION_RE)) {
64
69
  const verb = m[1];
65
70
  const dot = m.index;
66
- const line = src.slice(0, dot).split("\n").length;
71
+ const verbLine = src.slice(0, dot).split("\n").length;
67
72
  const argsOpen = dot + m[0].length - 1;
68
73
  const argsClose = matchingClose(structure, argsOpen);
69
74
  const chainStart = chainStartBefore(structure, dot);
@@ -71,16 +76,24 @@ export function actionStepsOf(body, language = "typescript") {
71
76
  // An action inside another action's arguments: its chain starts before the
72
77
  // previous action ended, so there is no gap to judge and no run to join.
73
78
  if (argsClose === -1 || chainStart < prevEnd) {
74
- steps.push({ line, gapBefore, gapAfter: "" });
79
+ steps.push({
80
+ line: verbLine,
81
+ gapBefore,
82
+ gapAfter: "",
83
+ endLine: verbLine,
84
+ value: "",
85
+ });
75
86
  prevEnd = argsOpen + 1;
76
87
  continue;
77
88
  }
78
89
  prevEnd = argsClose + 1;
79
90
  steps.push({
80
- line,
91
+ line: src.slice(0, chainStart).split("\n").length,
81
92
  gapBefore,
82
93
  gapAfter: "",
83
- key: stepKey(verb, src, structure, chainStart, dot, argsOpen, argsClose),
94
+ endLine: verbLine + (src.slice(dot, argsClose).match(/\n/g)?.length ?? 0),
95
+ value: firstArgument(structure, src, argsOpen + 1, argsClose),
96
+ key: stepKey(verb, src, structure, chainStart, dot, argsOpen, argsClose, params),
84
97
  });
85
98
  }
86
99
  for (let i = 0; i < steps.length; i++)
@@ -89,8 +102,10 @@ export function actionStepsOf(body, language = "typescript") {
89
102
  return steps;
90
103
  }
91
104
  /** The key of one action, or `undefined` when its locator or target is unreadable. */
92
- function stepKey(verb, src, structure, chainStart, dot, argsOpen, argsClose) {
105
+ function stepKey(verb, src, structure, chainStart, dot, argsOpen, argsClose, params) {
93
106
  let chain = src.slice(chainStart, dot);
107
+ if (params?.size)
108
+ chain = chain.replace(PARAM_LOCATOR_RE, (m, head, id) => params.has(id) ? `${head}"${paramSlot(id)}"` : m);
94
109
  const args = src.slice(argsOpen + 1, argsClose);
95
110
  let target = "";
96
111
  if (!/\(/.test(structure.slice(chainStart, dot))) {
@@ -219,6 +234,147 @@ function hasDynamicSelector(text) {
219
234
  return true;
220
235
  return false;
221
236
  }
237
+ /** A locator argument or `name:` option that is a bare identifier. */
238
+ const PARAM_LOCATOR_RE = /(\.(?:getBy\w+|locator|frameLocator)\s*\(\s*|\bname\s*:\s*)([A-Za-z_$][\w$]*)(?=\s*[,)}])/g;
239
+ /** The text a parameter-fed locator argument keys as: a string no selector holds.
240
+ * A key holding SLOT_MARK holds a wildcard. */
241
+ export const SLOT_MARK = "\u0001";
242
+ export function paramSlot(name) {
243
+ return `${SLOT_MARK}${name}${SLOT_MARK}`;
244
+ }
245
+ const LITERAL_RE = /^(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|-?\d+(?:\.\d+)?)$/;
246
+ const GET_VALUE_RE = /^getValue\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*(["'])([\w$ .-]*)\2\s*\)((?:\s*\.\s*[A-Za-z_$][\w$]*\s*\(\s*\))*)$/;
247
+ /**
248
+ * An action's value (`ActionStep.value`) as the sibling scan compares it: a bare
249
+ * parameter of the helper, a literal, or the generated `getValue(playwrightRequestN,
250
+ * "key")` — a dotted key read through nested objects, a chained `.toString()` kept
251
+ * as `suffix` — read through the binding that holds it (`requestBindingsOf`), to
252
+ * the parameter or literal stored under that key. Anything else is an expression,
253
+ * compared as written.
254
+ */
255
+ export function resolveStepValue(raw, bindings, params = new Set()) {
256
+ const text = raw.trim();
257
+ if (params.has(text))
258
+ return { kind: "param", text };
259
+ if (LITERAL_RE.test(text))
260
+ return { kind: "literal", text };
261
+ const get = GET_VALUE_RE.exec(text);
262
+ const object = get ? bindings.get(get[1]) : undefined;
263
+ if (get && object !== undefined) {
264
+ let field = fieldOf(object, get[3]);
265
+ if (field === undefined)
266
+ for (const [i, part] of get[3].split(".").entries())
267
+ field = fieldOf(i === 0 ? object : (field ?? ""), part);
268
+ if (field !== undefined)
269
+ return {
270
+ ...resolveStepValue(field, new Map(), params),
271
+ suffix: get[4].replace(/\s+/g, ""),
272
+ };
273
+ }
274
+ return { kind: "expr", text };
275
+ }
276
+ /** The value written under the TOP-LEVEL `key` of an object literal, or undefined. */
277
+ function fieldOf(object, key) {
278
+ const text = object.trim();
279
+ if (!text.startsWith("{") || !text.endsWith("}"))
280
+ return undefined;
281
+ const structure = blankStringContents(text);
282
+ let depth = 0;
283
+ let from = 1;
284
+ for (let i = 1; i < text.length; i++) {
285
+ const ch = structure[i];
286
+ if ("([{".includes(ch))
287
+ depth++;
288
+ else if (depth > 0 && ")]}".includes(ch))
289
+ depth--;
290
+ else if (depth === 0 && (ch === "," || i === text.length - 1)) {
291
+ const colon = structure.indexOf(":", from);
292
+ if (colon !== -1 && colon < i) {
293
+ const name = text
294
+ .slice(from, colon)
295
+ .trim()
296
+ .replace(/^(["'])(.*)\1$/, "$2");
297
+ if (name === key)
298
+ return text.slice(colon + 1, i).trim();
299
+ }
300
+ from = i + 1;
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+ /** The generator's `const playwrightRequestN: Record<…> = { … }` bindings in `text`,
306
+ * by name, to the object literal as written. A name bound twice is left out. */
307
+ export function requestBindingsOf(text) {
308
+ const structure = blankStringContents(text);
309
+ const out = new Map();
310
+ const twice = new Set();
311
+ for (const m of structure.matchAll(/\bconst\s+(playwrightRequest\d+)\s*(?::\s*Record\s*<[^>]*>)?\s*=\s*\{/g)) {
312
+ const open = m.index + m[0].length - 1;
313
+ const close = matchingClose(structure, open);
314
+ if (close === -1 || out.has(m[1]))
315
+ twice.add(m[1]);
316
+ else
317
+ out.set(m[1], text.slice(open, close + 1));
318
+ }
319
+ for (const name of twice)
320
+ out.delete(name);
321
+ return out;
322
+ }
323
+ /** The normalised arguments of every `page.waitForResponse(…)` in `text`. */
324
+ export function responseWaitsOf(text) {
325
+ const structure = blankStringContents(text);
326
+ const out = [];
327
+ for (const m of structure.matchAll(/\bwaitForResponse\s*\(/g)) {
328
+ const open = m.index + m[0].length - 1;
329
+ const close = matchingClose(structure, open);
330
+ if (close !== -1)
331
+ out.push(normalize(text.slice(open + 1, close)));
332
+ }
333
+ return out;
334
+ }
335
+ /** A use of the identifier `name`: not part of a longer name or a property access. */
336
+ export function identifierUseRe(name, flags = "") {
337
+ return new RegExp(`(?<![\\w$.])${escapeRegExp(name)}(?![\\w$])`, flags);
338
+ }
339
+ /** A declared parameter's name (see paramName), or `undefined` for a rest or
340
+ * destructured one: no single value a call or a wildcard can carry. */
341
+ export function simpleParamName(raw) {
342
+ if (/^\s*\.\.\./.test(raw))
343
+ return undefined;
344
+ const name = paramName(raw);
345
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : undefined;
346
+ }
347
+ /** The helper's ActionPattern, or `undefined` when a step stays unreadable with
348
+ * its parameters as wildcards. `params` are the declared parameters as written. */
349
+ export function actionPatternOf(body, language, params) {
350
+ // A parameter the body reassigns (`=`, `+=`, `++`, a destructuring) is not the
351
+ // value its caller passed.
352
+ const assigned = (text, name) => {
353
+ const id = escapeRegExp(name);
354
+ return [
355
+ `(?<![\\w$.])${id}\\s*(?:[-+*/%&|^]|\\*\\*|<<|>>>?|&&|\\|\\||\\?\\?)?(?<![!<>=])=(?![=>])`,
356
+ `(?:\\+\\+|--)\\s*${id}(?![\\w$])|(?<![\\w$.])${id}\\s*(?:\\+\\+|--)`,
357
+ `[[{][^=;]*(?<![\\w$.])${id}(?![\\w$])[^=;]*[\\]}]\\s*=(?![=>])`,
358
+ ].reduce((n, re) => n + (text.match(new RegExp(re, "g"))?.length ?? 0), 0);
359
+ };
360
+ const names = new Set(params
361
+ .map(simpleParamName)
362
+ .filter((n, i) => n !== undefined && assigned(body, n) <= assigned(params[i], n)));
363
+ const steps = actionStepsOf(body, language, names);
364
+ const keys = actionStepKeys(steps);
365
+ if (!keys)
366
+ return undefined;
367
+ const bindings = requestBindingsOf(body);
368
+ const uses = (text, name) => text.match(identifierUseRe(name, "g"))?.length ?? 0;
369
+ return {
370
+ steps: keys,
371
+ values: steps.map((s) => resolveStepValue(s.value, bindings, names)),
372
+ responseWaits: responseWaitsOf(body),
373
+ usedParams: params
374
+ .map(simpleParamName)
375
+ .filter((n, i) => n !== undefined && uses(body, n) > uses(params[i], n)),
376
+ };
377
+ }
222
378
  /** Whitespace and braces dropped outside string literals, `'` normalised to `"` —
223
379
  * the spelling a locator chain has inside a key. */
224
380
  export function normalizeChain(text) {
@@ -25,6 +25,13 @@ import { type InlineCallSite, type SiblingScan } from "./call-sites.js";
25
25
  * a sibling action with an undefined key equals nothing, so no run crosses it.
26
26
  * Both fail open — no match, no finding — as STEP 3b's decline rule does.
27
27
  *
28
+ * Two widenings (SKYR-4537), each held to boundRun's checks, so that every such
29
+ * site names the exact call that replaces it: the generator's own per-step bindings
30
+ * (`const playwrightRequestN = {…}`, `const responsePromiseN =
31
+ * page.waitForResponse(…)`, `const responseN = await responsePromiseN`) are
32
+ * transparent between two actions, and a helper whose locator argument is one of
33
+ * its parameters matches with the sibling's literal in that place.
34
+ *
28
35
  * Where two helpers' keys start the same run, the longer wins; the run's actions are
29
36
  * consumed and the scan continues after it. A run inside the sibling's own local
30
37
  * helper is reported with that helper's name, as the API scan does.
@@ -1,7 +1,7 @@
1
1
  import * as path from "path";
2
- import { blankComments, localHelperAt, localHelpersOf, } from "./parse.js";
2
+ import { blankComments, defaultAt, localHelperAt, localHelpersOf, } from "./parse.js";
3
3
  import { expectArgs, HELPER_FAMILIES, } from "./language-spec.js";
4
- import { actionStepsOf, blankStringContents, normalizeChain, statementSpans, } from "./action-key.js";
4
+ import { actionStepsOf, blankStringContents, identifierUseRe, normalizeChain, paramSlot, requestBindingsOf, SLOT_MARK, resolveStepValue, responseWaitsOf, simpleParamName, statementSpans, } from "./action-key.js";
5
5
  import { realpath } from "./locate.js";
6
6
  import { nonUtilsSiblings, } from "./call-sites.js";
7
7
  /** A presence assertion as `[normalised locator, matcher]`, or undefined when the
@@ -38,6 +38,13 @@ function presenceWait(text, spec) {
38
38
  * a sibling action with an undefined key equals nothing, so no run crosses it.
39
39
  * Both fail open — no match, no finding — as STEP 3b's decline rule does.
40
40
  *
41
+ * Two widenings (SKYR-4537), each held to boundRun's checks, so that every such
42
+ * site names the exact call that replaces it: the generator's own per-step bindings
43
+ * (`const playwrightRequestN = {…}`, `const responsePromiseN =
44
+ * page.waitForResponse(…)`, `const responseN = await responsePromiseN`) are
45
+ * transparent between two actions, and a helper whose locator argument is one of
46
+ * its parameters matches with the sibling's literal in that place.
47
+ *
41
48
  * Where two helpers' keys start the same run, the longer wins; the run's actions are
42
49
  * consumed and the scan continues after it. A run inside the sibling's own local
43
50
  * helper is reported with that helper's name, as the API scan does.
@@ -55,25 +62,28 @@ export async function findSiblingInlineActionSites(testFile, helpersByFile, spec
55
62
  const localHelpers = localHelpersOf(content, spec);
56
63
  // Comments blanked, strings kept — the same reading the helper's key was made
57
64
  // from, so a commented-out click is not a step and the selectors compare.
58
- const steps = actionStepsOf(blankComments(content, spec), spec.language);
65
+ const blanked = blankComments(content, spec);
66
+ const steps = actionStepsOf(blanked, spec.language);
59
67
  let i = 0;
60
68
  while (i < steps.length) {
61
- const match = sequences.find((s) => runMatches(steps, i, s, spec));
62
- if (!match) {
69
+ let run;
70
+ const match = sequences.find((s) => (run = runMatches(steps, i, s, spec, blanked)));
71
+ if (!match || !run) {
63
72
  i++;
64
73
  continue;
65
74
  }
66
- const first = steps[i];
67
- const last = steps[i + match.steps.length - 1];
68
- const owner = localHelperAt(localHelpers, first.line);
75
+ const owner = localHelperAt(localHelpers, steps[i].line);
76
+ // A local helper named as the shared one: a call inside it would call itself.
77
+ const call = owner?.name === match.helper ? undefined : run.call;
69
78
  out.push({
70
79
  kind: "sequence",
71
80
  file,
72
- line: first.line,
73
- endLine: last.line,
74
- actionKey: match.key,
81
+ line: run.line,
82
+ endLine: run.endLine,
83
+ actionKey: run.actionKey,
75
84
  helper: match.helper,
76
85
  utilsFile: match.utilsFile,
86
+ ...(call ? { call } : {}),
77
87
  ...(owner ? { localHelper: owner.name } : {}),
78
88
  });
79
89
  i += match.steps.length;
@@ -81,29 +91,214 @@ export async function findSiblingInlineActionSites(testFile, helpersByFile, spec
81
91
  }
82
92
  return out;
83
93
  }
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) {
94
+ /** The helper's sequence as the run of actions starting at `at`, with nothing
95
+ * between its actions that a helper body could not hold, and no wait in or right
96
+ * after the run that contradicts one of the helper's own — or `undefined`. A run
97
+ * that matches only through a generated binding or a wildcard is held to boundRun.
98
+ * `sibling` is the sibling's source, comments blanked, as `steps` were read. */
99
+ function runMatches(steps, at, helper, spec, sibling) {
88
100
  const want = helper.steps;
89
101
  if (at + want.length > steps.length)
90
- return false;
102
+ return undefined;
103
+ const args = new Map();
104
+ const between = [];
91
105
  for (let k = 0; k < want.length; k++) {
92
106
  const step = steps[at + k];
93
- if (step.key === undefined || step.key !== want[k])
94
- return false;
107
+ if (step.key === undefined || !stepMatches(want[k], step.key, args))
108
+ return undefined;
95
109
  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;
110
+ const gap = transparentStatements(step.gapBefore, spec);
111
+ if (gap === undefined)
112
+ return undefined;
113
+ if (contradictsWaits(gap, helper.waits, spec))
114
+ return undefined;
115
+ between.push(...gap);
101
116
  }
102
117
  }
103
118
  // The structural waits that follow the last action are the run's too: a wait the
104
119
  // helper would perform, or its opposite.
105
120
  const trailing = transparentStatements(steps[at + want.length - 1].gapAfter, spec, { prefix: true });
106
- return !contradictsWaits(trailing ?? [], helper.waits, spec);
121
+ if (contradictsWaits(trailing ?? [], helper.waits, spec))
122
+ return undefined;
123
+ const run = steps.slice(at, at + want.length);
124
+ if (!helper.wildcard && !between.some(isGeneratedBinding)) {
125
+ // No call is named here, but the advisory still has the agent write one.
126
+ if (run.some((s, k) => liftsLiteralSecret(helper.values[k], s.value, resolveStepValue(s.value, new Map()))))
127
+ return undefined;
128
+ return {
129
+ line: run[0].line,
130
+ endLine: run[run.length - 1].endLine,
131
+ actionKey: helper.key,
132
+ };
133
+ }
134
+ return boundRun(steps[at - 1], run, between, helper, args, sibling, spec);
135
+ }
136
+ /** Whether a sibling step's key equals the helper's, a wildcard taking the literal
137
+ * in its place — recorded in `args`, and refused when the same parameter has
138
+ * already taken a different one. */
139
+ function stepMatches(want, got, args) {
140
+ if (!want.includes(SLOT_MARK))
141
+ return want === got;
142
+ const parts = want.split(SLOT_RE);
143
+ const literal = new RegExp("^" +
144
+ parts
145
+ .map((p, i) => i % 2
146
+ ? '("(?:[^"\\\\]|\\\\.)*")'
147
+ : p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
148
+ .join("") +
149
+ "$").exec(got);
150
+ if (!literal)
151
+ return false;
152
+ return parts
153
+ .filter((_, i) => i % 2)
154
+ .every((name, i) => bindArg(args, name, literal[i + 1]));
155
+ }
156
+ /** A wildcard in a key: the parameter's name between paramSlot's markers. */
157
+ const SLOT_RE = new RegExp(`"${paramSlot("([A-Za-z_$][\\w$]*)")}"`);
158
+ /** Records `value` as the argument for `name`; false when `name` already has a
159
+ * different one — one parameter cannot carry two values. */
160
+ function bindArg(args, name, value) {
161
+ const held = args.get(name);
162
+ if (held === undefined)
163
+ args.set(name, value);
164
+ return held === undefined || normalizeChain(held) === normalizeChain(value);
165
+ }
166
+ /**
167
+ * A run that matched through a generated binding or a wildcard: the call that
168
+ * replaces it, with the range that call replaces — or `undefined`, when one call
169
+ * cannot replace it exactly:
170
+ *
171
+ * - **The range.** Grown back over the generated declarations right before the
172
+ * first action and forward over the `await responsePromiseN` right after the
173
+ * last, so a binding and its use go together.
174
+ * - **No dangling name.** Every generated name the range uses is declared in it,
175
+ * and no name it declares is used outside it.
176
+ * - **Same response waits.** The range's `waitForResponse`s and the helper's are
177
+ * equal as multisets: a wait the helper lacks would be lost, and one the range
178
+ * lacks would wait for a response the sibling may never cause.
179
+ * - **No lost assertion.** Between two actions, only generated bindings, waits and
180
+ * the presence waits the helper itself performs: any other assertion would be
181
+ * deleted with the range.
182
+ * - **No lifted secret.** A password- or secret-named value is not lifted into a
183
+ * literal argument of hand-maintained code.
184
+ * - **Values.** A value the helper takes as a parameter becomes that argument —
185
+ * a binding's literal lifted out of `getValue(playwrightRequestN, …)`; any other
186
+ * value must equal the helper's. Each parameter maps to ONE value, and every
187
+ * parameter but `page` and a trailing defaulted one is given one.
188
+ */
189
+ function boundRun(before, run, between, helper, args, sibling, spec) {
190
+ for (const s of between) {
191
+ if (isGeneratedBinding(s))
192
+ continue;
193
+ const body = s.replace(/^await\s+/, "");
194
+ if (WAIT_STATEMENT_RE.test(blankStringContents(body)))
195
+ continue;
196
+ const wait = presenceWait(body, spec);
197
+ if (!wait || helper.waits.get(wait[0]) !== wait[1])
198
+ return undefined;
199
+ }
200
+ const first = run[0];
201
+ const last = run[run.length - 1];
202
+ let line = first.line;
203
+ let endLine = last.endLine;
204
+ const lead = before ? spansOf(before.gapAfter) : [];
205
+ let from = lead.length;
206
+ while (from > 0 && DECLARATION_RE.test(lead[from - 1].structure))
207
+ from--;
208
+ if (from < lead.length)
209
+ line = lead[from].line + before.endLine - 1;
210
+ const trail = spansOf(last.gapAfter);
211
+ let to = 0;
212
+ while (to < trail.length && RESPONSE_AWAIT_RE.test(trail[to].structure))
213
+ to++;
214
+ if (to > 0)
215
+ endLine = trail[to - 1].endLine + last.endLine - 1;
216
+ const range = [
217
+ ...lead.slice(from).map((s) => s.text),
218
+ ...between,
219
+ ...trail.slice(0, to).map((s) => s.text),
220
+ ];
221
+ const declared = new Set(range.flatMap((s) => [...s.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1])));
222
+ const used = [...range, ...run.map((s) => s.value)].flatMap((s) => [...s.matchAll(GENERATED_NAME_RE)].map((m) => m[0]));
223
+ if (used.some((name) => !declared.has(name)))
224
+ return undefined;
225
+ const lines = sibling.split("\n");
226
+ for (const name of declared) {
227
+ const use = identifierUseRe(name);
228
+ if (lines.some((l, i) => (i + 1 < line || i + 1 > endLine) && use.test(l)))
229
+ return undefined;
230
+ }
231
+ const rangeWaits = range.flatMap(responseWaitsOf).sort();
232
+ const helperWaits = [...helper.responseWaits].sort();
233
+ if (rangeWaits.length !== helperWaits.length ||
234
+ rangeWaits.some((w, i) => w !== helperWaits[i]))
235
+ return undefined;
236
+ const bindings = requestBindingsOf(range.join("\n"));
237
+ for (let k = 0; k < run.length; k++) {
238
+ const want = helper.values[k];
239
+ const got = resolveStepValue(run[k].value, bindings);
240
+ // A value read through a generated binding this cannot resolve: the same
241
+ // `playwrightRequestN` text holds a different value in each spec.
242
+ const opaque = (v) => v.kind === "expr" && v.text.match(GENERATED_NAME_RE) !== null;
243
+ const ok = !liftsLiteralSecret(want, run[k].value, got) &&
244
+ !opaque(want) &&
245
+ !opaque(got) &&
246
+ (want.suffix ?? "") === (got.suffix ?? "") &&
247
+ (want.kind === "param"
248
+ ? ![...declared].some((n) => identifierUseRe(n).test(got.text)) &&
249
+ bindArg(args, want.text, got.text)
250
+ : normalizeChain(want.text) === normalizeChain(got.text));
251
+ if (!ok)
252
+ return undefined;
253
+ }
254
+ const call = callOf(helper, args);
255
+ if (call === undefined)
256
+ return undefined;
257
+ return {
258
+ line,
259
+ endLine,
260
+ actionKey: helper.wildcard ? run.map((s) => s.key).join("; ") : helper.key,
261
+ call,
262
+ };
263
+ }
264
+ /** The statements of a gap, each with its text, its string-blanked text, and its
265
+ * first and last line counted from the gap's own first line (1). */
266
+ function spansOf(gap) {
267
+ const structure = blankStringContents(gap);
268
+ const lineAt = (i) => gap.slice(0, i).split("\n").length;
269
+ return statementSpans(structure)
270
+ .map(([from, to]) => ({
271
+ text: gap.slice(from, to).trim(),
272
+ structure: structure.slice(from, to).trim(),
273
+ line: lineAt(from),
274
+ endLine: lineAt(to),
275
+ }))
276
+ .filter((s) => s.text !== "await");
277
+ }
278
+ /** `await createOrder(page, "a@b.c", "option-1")`: the helper's call with each
279
+ * parameter's argument, or `undefined` when a parameter has none — `page` is the
280
+ * page, and a defaulted or optional parameter with nothing after it is left out —
281
+ * unless the body reads it: `express = false` gating a click the sibling makes. */
282
+ function callOf(helper, args) {
283
+ const out = [];
284
+ for (const param of helper.params) {
285
+ const name = simpleParamName(param);
286
+ if (!name)
287
+ return undefined;
288
+ const optional = /^\s*[\w$]+\s*\?/.test(param) || defaultAt(param) !== -1;
289
+ out.push(args.get(name) ??
290
+ (name === "page" || /:\s*Page\b/.test(param)
291
+ ? "page"
292
+ : optional && !helper.usedParams.includes(name)
293
+ ? undefined
294
+ : "\0"));
295
+ }
296
+ while (out.length > 0 && out[out.length - 1] === undefined)
297
+ out.pop();
298
+ if (out.some((a) => a === undefined || a === "\0"))
299
+ return undefined;
300
+ // The browser scan runs for TypeScript and JavaScript only (inlineCallSiteFamily).
301
+ return `await ${helper.helper}(${out.join(", ")})`;
107
302
  }
108
303
  /** Whether one of `statements` asserts an element the helper waits on with a
109
304
  * different matcher or negation. */
@@ -120,8 +315,9 @@ function contradictsWaits(statements, waits, spec) {
120
315
  }
121
316
  /**
122
317
  * 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
318
+ * structural wait, as the verify pass judges one (`HELPER_FAMILIES.browser`), a
319
+ * `waitFor…`, or between two actions one of the generator's per-step bindings
320
+ * (isGeneratedBinding; boundRun carries it) — or `undefined` when one is not, or when the gap closes a bracket it
125
321
  * did not open. With `prefix`, the leading transparent statements up to the first
126
322
  * that is not: the waits that follow a run, read until the run's own text ends.
127
323
  * Statements are cut as statementSpans cuts them, on a string-blanked copy, so a
@@ -140,6 +336,7 @@ function transparentStatements(gap, spec, opts = {}) {
140
336
  continue;
141
337
  const body = text.replace(/^await\s+/, "");
142
338
  const transparent = WAIT_STATEMENT_RE.test(blankStringContents(body)) ||
339
+ (!opts.prefix && isGeneratedBinding(text)) ||
143
340
  (spec.assertionRe.test(body) &&
144
341
  HELPER_FAMILIES.browser.isAllowed(spec, body));
145
342
  if (!transparent)
@@ -161,9 +358,36 @@ function leavesBlock(structure) {
161
358
  }
162
359
  /** `page.waitForTimeout(3000)`, `page.waitForLoadState()`, `page.getByTestId("x")
163
360
  * .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). */
361
+ * statement of its own (a BOUND wait is used later: only the generator's own, see
362
+ * isGeneratedBinding, is carried, by boundRun). */
166
363
  const WAIT_STATEMENT_RE = /^(?:this\.)?page(?:\s*\.\s*[\w$]+\s*\([^;]*\))*\s*\.\s*(?:waitFor\w*|wait_for\w*)\s*\([^;]*\)\s*$/;
364
+ /** The generator's per-step declarations, string-blanked: `const playwrightRequestN:
365
+ * Record<string, any> = {…}` (the value the next fill reads) and `const
366
+ * responsePromiseN = page.waitForResponse(…)`. A hand-written binding is not one. */
367
+ const DECLARATION_RE = /^const\s+(?:playwrightRequest\d+\s*(?::\s*Record\s*<[^>]*>)?\s*=\s*\{[^;]*\}|responsePromise\d+\s*=\s*(?:this\.)?page\s*\.\s*waitForResponse\s*\([^;]*\))\s*;?$/;
368
+ /** The generator's `const responseN = await responsePromiseN`, string-blanked. */
369
+ const RESPONSE_AWAIT_RE = /^(?:const\s+response\d+\s*=\s*)?await\s+responsePromise\d+\s*;?$/;
370
+ /** A parameter or binding key that holds a secret: its value stays out of a call. */
371
+ const SECRET_NAME_RE = /password|passwd|secret|token|api_?key/i;
372
+ /** Whether a step would lift a literal secret into a call argument — a password-
373
+ * or secret-named parameter (or `getValue` key) given a literal — on either path:
374
+ * the literal stays out of hand-maintained code. A variable (`loginPassword`) is
375
+ * no secret in itself and may be passed. */
376
+ function liftsLiteralSecret(want, raw, got) {
377
+ return (want.kind === "param" &&
378
+ got.kind === "literal" &&
379
+ (SECRET_NAME_RE.test(want.text) ||
380
+ SECRET_NAME_RE.test(GET_VALUE_KEY_RE.exec(raw)?.[1] ?? "")));
381
+ }
382
+ /** The key a `getValue(binding, "key")` step value reads. */
383
+ const GET_VALUE_KEY_RE = /^\s*getValue\s*\([^,]*,\s*["']([^"']*)["']/;
384
+ /** A name the generator binds per step. */
385
+ const GENERATED_NAME_RE = /\b(?:playwrightRequest|responsePromise|response)\d+\b/g;
386
+ /** One of the generator's per-step bindings (statement text as written). */
387
+ function isGeneratedBinding(text) {
388
+ const structure = blankStringContents(text);
389
+ return DECLARATION_RE.test(structure) || RESPONSE_AWAIT_RE.test(structure);
390
+ }
167
391
  /** A step whose receiver is the keyboard or the mouse rather than a locator. */
168
392
  function isElementlessStep(step) {
169
393
  return /^\w+ (?:this\.)?page\.(?:keyboard|mouse)(?:\s|$)/.test(step);
@@ -176,27 +400,45 @@ function helperSequences(helpersByFile, spec) {
176
400
  const seen = new Set();
177
401
  for (const [utilsFile, helpers] of helpersByFile)
178
402
  for (const h of helpers) {
179
- if (!h.actionKey || !h.actionSteps || seen.has(h.actionKey))
403
+ const pattern = h.actionPattern;
404
+ // No key: a step whose locator is fed by a parameter is a wildcard (SKYR-4537).
405
+ const steps = h.actionSteps ?? pattern?.steps;
406
+ const key = h.actionKey ?? steps?.join("; ");
407
+ if (!key || !steps || !pattern || seen.has(key))
180
408
  continue;
181
409
  // A key of bare keyboard or mouse steps names no element: `press "Escape"`
182
410
  // is what closes a dialog in one helper and a menu in the next, and every
183
411
  // Escape in every sibling matched it (measured: fixture 22, the advisory
184
412
  // named a menu close for a dialog-closing helper). One locator-bearing step
185
413
  // is enough to identify the sequence.
186
- if (h.actionKey.split("; ").every(isElementlessStep))
414
+ if (key.split("; ").every(isElementlessStep))
415
+ continue;
416
+ // A wildcard widens what a step equals, so the sequence must still be named
417
+ // by its literal steps: at least two, and more than its wildcard steps — a
418
+ // `clickByTestId(page, id)` would otherwise equal every such click.
419
+ const wildcard = !h.actionSteps;
420
+ const literal = steps.filter((s) => !s.includes(SLOT_MARK) && !isElementlessStep(s)).length;
421
+ if (wildcard && (literal < 2 || literal <= steps.length - literal))
187
422
  continue;
188
- seen.add(h.actionKey);
423
+ seen.add(key);
189
424
  out.push({
190
425
  helper: h.name,
191
426
  utilsFile,
192
- key: h.actionKey,
193
- steps: h.actionSteps,
427
+ key,
428
+ steps,
429
+ wildcard,
430
+ params: h.params ?? [],
431
+ values: pattern.values,
432
+ responseWaits: pattern.responseWaits,
433
+ usedParams: pattern.usedParams,
194
434
  waits: new Map(h.assertions
195
435
  .map((a) => presenceWait(a.text, spec))
196
436
  .filter((w) => w !== undefined)),
197
437
  });
198
438
  }
199
439
  // 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);
440
+ // helper that shares its opening actions; at equal length, a literal sequence
441
+ // before a wildcard one.
442
+ return out.sort((a, b) => b.steps.length - a.steps.length ||
443
+ Number(a.wildcard) - Number(b.wildcard));
202
444
  }
@@ -35,7 +35,8 @@ export declare const REQUEST_CALL_RE: RegExp;
35
35
  interface InlineSiteBase {
36
36
  /** Absolute path of the sibling test. */
37
37
  file: string;
38
- /** 1-based line of the request call, or of the first action of the sequence. */
38
+ /** 1-based line of the request call, or of the first action of the sequence (or
39
+ * of the generated declaration that precedes it, SKYR-4537). */
39
40
  line: number;
40
41
  /** The utils helper that wraps the same method+path, or performs the same
41
42
  * action sequence. */
@@ -58,8 +59,13 @@ export type InlineCallSite = (InlineSiteBase & {
58
59
  kind: "sequence";
59
60
  /** The action key the sequence and the helper share (see actionKeyOf). */
60
61
  actionKey: string;
61
- /** 1-based line of the sequence's last action. */
62
+ /** 1-based line of the sequence's last action — or of the generated
63
+ * `await responsePromiseN` that goes with it (SKYR-4537). */
62
64
  endLine: number;
65
+ /** The call that replaces lines `line`–`endLine`, arguments included, when
66
+ * the run matched through a generated binding or a parameter-fed locator
67
+ * (SKYR-4537): such a site is reported only when this can be named. */
68
+ call?: string;
63
69
  });
64
70
  /**
65
71
  * Inline request calls in OTHER Skyramp-generated tests beside `testFile` that a
@@ -355,7 +355,7 @@ export const HELPER_FAMILIES = {
355
355
  inlineSite: {
356
356
  noun: BROWSER_HELPERS.inlineSite.noun,
357
357
  already: "performs",
358
- instruction: "Each is a candidate: the sequence's actions and locators equal the helper's, in order. Replace the sequence with a call to the helper only if the remaining differences are literals — fill values, option labels, literal arguments — which become the arguments (import the helper into that file; every assertion and the page-error guard stay in the test body where they are; change nothing else there).",
358
+ instruction: "Each is a candidate: the sequence's actions and locators equal the helper's, in order, except a locator the helper takes as a parameter, which becomes that argument. Replace the sequence with a call to the helper only if the remaining differences are literals — fill values, option labels, literal arguments — which become the arguments (import the helper into that file; every assertion and the page-error guard stay in the test body where they are; change nothing else there).",
359
359
  localCopy: "the sequence",
360
360
  },
361
361
  },
@@ -1,4 +1,5 @@
1
1
  import { type UtilsLanguageSpec } from "./language-spec.js";
2
+ import { type ActionPattern } from "./action-key.js";
2
3
  import { type HelperSleep } from "./fixed-sleep.js";
3
4
  import { type SourceSpan } from "../pom-scope/strip.js";
4
5
  export interface HelperAssertion {
@@ -56,6 +57,9 @@ export interface UtilsHelper {
56
57
  * step per entry, for the sibling scan that matches step by step. */
57
58
  actionKey?: string;
58
59
  actionSteps?: string[];
60
+ /** The sequence as the sibling scan matches it, a parameter-fed locator as a
61
+ * wildcard (SKYR-4537). See actionPatternOf. */
62
+ actionPattern?: ActionPattern;
59
63
  }
60
64
  /** The declared parameters of a signature, raw text trimmed, empty entries dropped
61
65
  * (a trailing comma). Reads the signature from the definition line to its body
@@ -1,5 +1,5 @@
1
1
  import { normalizePath } from "./language-spec.js";
2
- import { actionStepKeys, actionStepsOf, } from "./action-key.js";
2
+ import { actionPatternOf, actionStepKeys, actionStepsOf, } from "./action-key.js";
3
3
  /** A helper's action steps and their joined key, read once from its body. */
4
4
  function actionSequence(body, language) {
5
5
  const steps = actionStepsOf(body, language);
@@ -282,6 +282,7 @@ export function parseUtilsFile(content, spec) {
282
282
  // Comments blanked, strings kept: a commented-out click is not a step, and
283
283
  // the selector literals are the key.
284
284
  ...actionSequence(blankComments(body.join("\n"), spec), spec.language),
285
+ actionPattern: actionPatternOf(blankComments(body.join("\n"), spec), spec.language, params ?? []),
285
286
  };
286
287
  let requests = 0;
287
288
  for (let k = 0; k < body.length; k++) {
@@ -1,4 +1,4 @@
1
- export declare const SKYRAMP_IMAGE_VERSION = "v1.3.52";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.52";
3
- export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.52";
1
+ export declare const SKYRAMP_IMAGE_VERSION = "v1.3.53";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.53";
3
+ export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.53";
4
4
  export declare const WORKER_CONTROL_PORT = 35142;
@@ -1,4 +1,4 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.52";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.53";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
4
4
  // Control port the Skyramp worker listens on (SDK `CONTAINER_PORT`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.5.0-rc.7",
3
+ "version": "0.5.0-rc.9",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",
@@ -72,7 +72,7 @@
72
72
  "@modelcontextprotocol/client": "^2.0.0",
73
73
  "@modelcontextprotocol/server": "^2.0.0",
74
74
  "@playwright/test": "^1.55.0",
75
- "@skyramp/skyramp": "1.3.52",
75
+ "@skyramp/skyramp": "1.3.53",
76
76
  "dockerode": "^5.0.0",
77
77
  "fast-glob": "^3.3.3",
78
78
  "js-yaml": "^4.1.1",