@skyramp/mcp 0.5.0-rc.6 → 0.5.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/prompts/modularization/ui-test-modularization.d.ts +1 -0
- package/build/prompts/modularization/ui-test-modularization.js +9 -2
- package/build/tools/code-refactor/codeReuseTool.js +2 -1
- package/build/tools/code-refactor/utils-verify-gates.js +1 -1
- package/build/tools/fixErrorTool.js +2 -1
- package/build/utils/utils-verify/action-key.d.ts +59 -2
- package/build/utils/utils-verify/action-key.js +162 -6
- package/build/utils/utils-verify/action-sites.d.ts +7 -0
- package/build/utils/utils-verify/action-sites.js +277 -35
- package/build/utils/utils-verify/call-sites.d.ts +8 -2
- package/build/utils/utils-verify/language-spec.js +1 -1
- package/build/utils/utils-verify/parse.d.ts +4 -0
- package/build/utils/utils-verify/parse.js +2 -1
- package/package.json +1 -1
- package/plugin/skills/setup-testbot-target-sut/reference/deployed-environment.md +21 -6
|
@@ -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
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
const
|
|
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:
|
|
73
|
-
endLine:
|
|
74
|
-
actionKey:
|
|
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
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
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
|
|
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
|
|
94
|
-
return
|
|
107
|
+
if (step.key === undefined || !stepMatches(want[k], step.key, args))
|
|
108
|
+
return undefined;
|
|
95
109
|
if (k > 0) {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
return
|
|
99
|
-
if (contradictsWaits(
|
|
100
|
-
return
|
|
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
|
-
|
|
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`),
|
|
124
|
-
* `waitFor
|
|
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 (
|
|
165
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
423
|
+
seen.add(key);
|
|
189
424
|
out.push({
|
|
190
425
|
helper: h.name,
|
|
191
426
|
utilsFile,
|
|
192
|
-
key
|
|
193
|
-
steps
|
|
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
|
-
|
|
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++) {
|
package/package.json
CHANGED
|
@@ -14,7 +14,9 @@ Take these from the guidance, and confirm them in the repositories (routes, conf
|
|
|
14
14
|
header, basic auth, a session cookie, or a preview's bypass header). A token generated at run time is fine.
|
|
15
15
|
- **Ready check:** the route and answer that mean it is ready, such as `GET /health → 200`. A page every route returns,
|
|
16
16
|
like a single-page app's, proves nothing; use a route that returns real data.
|
|
17
|
-
- **
|
|
17
|
+
- **How to tell what runs:** a mechanism that shows the commit each component runs, on every later run: a version
|
|
18
|
+
route, a platform API (Vercel, Netlify, Render, Heroku), GitHub Deployments, a GitOps manifest or Helm values file in
|
|
19
|
+
a Target repository, or Kubernetes metadata. Find the mechanism, never a list of commits.
|
|
18
20
|
|
|
19
21
|
`localhost`, `127.0.0.1` or a container name is not a deployment: it only says where a local bring-up listens.
|
|
20
22
|
|
|
@@ -46,6 +48,18 @@ Say exactly what the user must add, and where:
|
|
|
46
48
|
- `health-check.sh` runs the ready check, signed in when needed, and prints one line per part of at least 16 characters,
|
|
47
49
|
with no secrets: `qa.acme.dev ready: GET /health -> 200`. `health.json` runs it.
|
|
48
50
|
- `get-auth-token.sh`, when sign-in is needed, prints one token for this run. It never logs anyone out.
|
|
51
|
+
- `deployed-revisions.sh` prints one JSON object and nothing else: each component a repository builds, mapped to the
|
|
52
|
+
full commit it runs, such as `{"api": "<40-character commit>"}`. For a source that says only what a deploy tool was
|
|
53
|
+
asked to deploy (GitHub Deployments, a deploy workflow run), give that component an object under its own name:
|
|
54
|
+
`{"api": "<commit>", "web": {"revision": "<commit>", "state": "requested"}}`.
|
|
55
|
+
Expand a short commit inside the script (`gh api repos/<owner>/<repo>/commits/<short> --jq .sha`).
|
|
56
|
+
- Testbot runs it with no agent, before and after each later run, in an empty directory with no checkout. It gets
|
|
57
|
+
the credentials in `environment` as environment variables of those names, `GH_TOKEN` (reads the Target's
|
|
58
|
+
repositories) and `GITHUB_API_URL`. Your own session has no `GH_TOKEN`.
|
|
59
|
+
- Testbot runs it twice at setup. Each commit must be the same in both runs and be a commit of the component's own
|
|
60
|
+
repository, so never print a config repository's commit (Argo CD, Flux) for an app component.
|
|
61
|
+
- A repository it reads that is not one of the Target's: ask to add it to the Target. A network a GitHub-hosted
|
|
62
|
+
runner cannot reach, such as a cluster API: report a blocker that it needs a self-hosted runner.
|
|
49
63
|
|
|
50
64
|
**Otherwise: the prompt does not ask for `environment`, part of it is built, its address changes per PR (a preview), or
|
|
51
65
|
reaching it needs something on the runner** (a tunnel, port-forward, proxy or client certificate):
|
|
@@ -62,13 +76,14 @@ reaching it needs something on the runner** (a tunnel, port-forward, proxy or cl
|
|
|
62
76
|
"environment": {"address": "https://qa.acme.dev", "readyWhen": "GET /health → 200", "credentials": ["QA_TEST_USER"],
|
|
63
77
|
"revisionSources": [{"method": "version_endpoint", "detail": "GET /version", "components": ["api"]}]}
|
|
64
78
|
```
|
|
65
|
-
- `deployed`: the
|
|
79
|
+
- `deployed`: the commit the component runs, as `deployed-revisions.sh` prints it, or `null` when nothing shows it.
|
|
66
80
|
- A deployed part no repository of the Target builds, such as its database, is a component with
|
|
67
81
|
`"repository": "shared"`.
|
|
68
|
-
- **Revision sources
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
- **Revision sources** describe what `deployed-revisions.sh` reads, such as `version_endpoint` with `GET /version`,
|
|
83
|
+
`deploy_workflow`, `kubernetes_labels`, or `command` when no method fits. Each component a repository builds needs
|
|
84
|
+
one. If nothing shows what one runs, ask on the Setup page with a `choose_source` question whose choices are the
|
|
85
|
+
mechanisms you found, never the commits; write the script from the answer. If no answer comes, report a blocker
|
|
86
|
+
asking for it in the Test Environment's notes.
|
|
72
87
|
- Otherwise, leave `environment` and `deployed` out.
|
|
73
88
|
Report each deployed part with these steps:
|
|
74
89
|
- `start`: `Already deployed at <address>` (source `notes`)
|