@skyramp/mcp 0.3.4 → 0.3.6-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/playwright/registerPlaywrightTools.js +92 -30
- package/build/playwright/traceRecordingPrompt.d.ts +6 -0
- package/build/playwright/traceRecordingPrompt.js +6 -2
- package/build/prompts/code-reuse.d.ts +1 -2
- package/build/prompts/code-reuse.js +182 -77
- package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
- package/build/prompts/modularization/integration-test-modularization.js +83 -41
- package/build/prompts/modularization/render.d.ts +18 -0
- package/build/prompts/modularization/render.js +12 -0
- package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
- package/build/prompts/modularization/ui-test-modularization.js +89 -47
- package/build/prompts/pom-aware-code-reuse.js +3 -1
- package/build/prompts/shared-helper-policy.d.ts +57 -0
- package/build/prompts/shared-helper-policy.js +135 -0
- package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
- package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
- package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
- package/build/prompts/test-recommendation/recommendationShared.js +90 -16
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
- package/build/prompts/testbot/testbot-prompts.js +88 -33
- package/build/recommendation/budgeters/shared.js +105 -27
- package/build/recommendation/discriminators.js +13 -2
- package/build/recommendation/planRanker.d.ts +6 -6
- package/build/recommendation/planRanker.js +6 -61
- package/build/services/AnalyticsService.d.ts +7 -0
- package/build/services/AnalyticsService.js +7 -1
- package/build/services/ModularizationService.js +1 -3
- package/build/services/TestDiscoveryService.d.ts +0 -2
- package/build/services/TestDiscoveryService.js +2 -37
- package/build/services/TestGenerationService.d.ts +16 -0
- package/build/services/TestGenerationService.js +86 -10
- package/build/services/containerEnv.js +13 -12
- package/build/tools/code-refactor/codeReuseTool.js +279 -93
- package/build/tools/code-refactor/enhance-state.d.ts +49 -0
- package/build/tools/code-refactor/enhance-state.js +109 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
- package/build/tools/code-refactor/modularizationTool.js +9 -2
- package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
- package/build/tools/code-refactor/reuse-outcome.js +14 -4
- package/build/tools/code-refactor/reuse-state.d.ts +127 -5
- package/build/tools/code-refactor/reuse-state.js +628 -16
- package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
- package/build/tools/code-refactor/utils-verify-gates.js +100 -0
- package/build/tools/code-refactor/verify-gates.d.ts +2 -1
- package/build/tools/code-refactor/verify-gates.js +90 -25
- package/build/tools/executeSkyrampTestTool.d.ts +19 -0
- package/build/tools/executeSkyrampTestTool.js +158 -8
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
- package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
- package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
- package/build/tools/generate-tests/generateUIRestTool.js +22 -0
- package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
- package/build/tools/generate-tests/scenarioLint.js +127 -19
- package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
- package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
- package/build/tools/submitReportTool.d.ts +38 -38
- package/build/tools/submitReportTool.js +487 -104
- package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
- package/build/tools/test-management/analyzeChangesTool.js +75 -12
- package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
- package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
- package/build/tools/test-management/registerTestPlanTool.js +149 -23
- package/build/types/Recommendation.d.ts +34 -5
- package/build/types/RepositoryAnalysis.d.ts +133 -114
- package/build/types/RepositoryAnalysis.js +1 -1
- package/build/types/ReuseOutcome.d.ts +102 -6
- package/build/types/ReuseOutcome.js +16 -2
- package/build/types/TestRecommendation.js +21 -3
- package/build/types/TestTypes.js +14 -8
- package/build/types/TestbotReport.d.ts +25 -3
- package/build/types/index.d.ts +2 -2
- package/build/types/index.js +1 -1
- package/build/utils/AnalysisStateManager.d.ts +69 -1
- package/build/utils/AnalysisStateManager.js +69 -5
- package/build/utils/branchDiff.d.ts +10 -0
- package/build/utils/branchDiff.js +28 -0
- package/build/utils/changedRoutes.d.ts +29 -0
- package/build/utils/changedRoutes.js +87 -0
- package/build/utils/featureFlags.d.ts +21 -0
- package/build/utils/featureFlags.js +23 -0
- package/build/utils/frontendIntegration.js +34 -4
- package/build/utils/importerHop.d.ts +2 -8
- package/build/utils/importerHop.js +15 -53
- package/build/utils/pathMatching.d.ts +38 -0
- package/build/utils/pathMatching.js +71 -0
- package/build/utils/pathSignatures.d.ts +22 -0
- package/build/utils/pathSignatures.js +57 -0
- package/build/utils/planMatchKeys.d.ts +16 -3
- package/build/utils/planMatchKeys.js +26 -10
- package/build/utils/pluralization.d.ts +10 -0
- package/build/utils/pluralization.js +18 -0
- package/build/utils/pom-catalog-parse.d.ts +52 -0
- package/build/utils/pom-catalog-parse.js +141 -0
- package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
- package/build/utils/pom-scope/selector-extractor.js +34 -8
- package/build/utils/pom-verify/verify.d.ts +6 -5
- package/build/utils/pom-verify/verify.js +8 -6
- package/build/utils/reportLanguage.d.ts +43 -0
- package/build/utils/reportLanguage.js +125 -0
- package/build/utils/reportVerification.d.ts +74 -4
- package/build/utils/reportVerification.js +259 -3
- package/build/utils/reuseRouting.d.ts +3 -0
- package/build/utils/reuseRouting.js +50 -0
- package/build/utils/routeParsers.d.ts +2 -0
- package/build/utils/routeParsers.js +65 -8
- package/build/utils/scenarioDrafting.d.ts +1 -1
- package/build/utils/scenarioDrafting.js +57 -45
- package/build/utils/subjectEndpoints.d.ts +19 -0
- package/build/utils/subjectEndpoints.js +98 -0
- package/build/utils/testFileClassification.d.ts +11 -0
- package/build/utils/testFileClassification.js +47 -0
- package/build/utils/uiPageEnumerator.d.ts +45 -19
- package/build/utils/uiPageEnumerator.js +95 -51
- package/build/utils/utils-verify/allow.d.ts +16 -0
- package/build/utils/utils-verify/allow.js +68 -0
- package/build/utils/utils-verify/call-sites.d.ts +34 -0
- package/build/utils/utils-verify/call-sites.js +154 -0
- package/build/utils/utils-verify/index.d.ts +7 -0
- package/build/utils/utils-verify/index.js +7 -0
- package/build/utils/utils-verify/language-spec.d.ts +91 -0
- package/build/utils/utils-verify/language-spec.js +210 -0
- package/build/utils/utils-verify/locate.d.ts +39 -0
- package/build/utils/utils-verify/locate.js +199 -0
- package/build/utils/utils-verify/parse.d.ts +34 -0
- package/build/utils/utils-verify/parse.js +177 -0
- package/build/utils/utils-verify/stage.d.ts +24 -0
- package/build/utils/utils-verify/stage.js +107 -0
- package/build/utils/utils-verify/verify.d.ts +63 -0
- package/build/utils/utils-verify/verify.js +168 -0
- package/build/utils/utils.d.ts +3 -1
- package/build/utils/utils.js +3 -1
- package/build/workspace/workspace.d.ts +32 -32
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
- package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Path parameters spelled `{id}`, `${id}`, `:id` all normalise to `{}` so two helpers
|
|
2
|
+
* for `/orders/{order_id}` and `/orders/{id}` compare equal. */
|
|
3
|
+
export function normalizePath(p) {
|
|
4
|
+
return (p
|
|
5
|
+
// Absolute URLs and query strings compare as their path.
|
|
6
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, "")
|
|
7
|
+
.replace(/[?#].*$/, "")
|
|
8
|
+
.replace(/\$\{[^}]*\}/g, "{}")
|
|
9
|
+
.replace(/\{[^}]*\}/g, "{}")
|
|
10
|
+
// `:id` is a parameter only as a whole segment; `/users:activate` is a
|
|
11
|
+
// custom method (Google AIP style) and a different endpoint.
|
|
12
|
+
.replace(/\/:[A-Za-z_]\w*(?=\/|$)/g, "/{}")
|
|
13
|
+
.replace(/\/+$/, "") || "/");
|
|
14
|
+
}
|
|
15
|
+
const PYTHON = {
|
|
16
|
+
language: "python",
|
|
17
|
+
extensions: [".py"],
|
|
18
|
+
commentPrefix: "#",
|
|
19
|
+
helperStart: /^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/,
|
|
20
|
+
methodRe: /\bmethod\s*=\s*["']([A-Za-z]+)["']/,
|
|
21
|
+
pathRe: /\bpath\s*=\s*[frbu]{0,2}["']([^"']+)["']/,
|
|
22
|
+
assertionRe: /^\s*(?:assert\b|(?:await\s+)?expect(?:\.soft)?\s*\()/,
|
|
23
|
+
// A status assertion is any assertion about the status code and nothing else — the
|
|
24
|
+
// comparison may be reversed, `in (...)`, or carry a trailing comment. Anything that
|
|
25
|
+
// reads the body (`json()`, `get_response_value`, `.text`, subscripts) is a body
|
|
26
|
+
// assertion whatever else the line says.
|
|
27
|
+
allowedAssertion: /^\s*assert\b(?=.*\bstatus_code\b)(?!.*(?:\.json\(|get_response_value|\.text\b|\.content\b|\[["']))/,
|
|
28
|
+
// Playwright for Python spells the structural matchers in snake_case.
|
|
29
|
+
allowedBrowserAssertion: /\bexpect\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)\s*\.\s*(?:not\.)?to_be_(?:visible|hidden|attached|enabled|disabled|editable|focused|in_viewport)\s*\(/,
|
|
30
|
+
importedNames(spec, stem) {
|
|
31
|
+
const names = new Set();
|
|
32
|
+
const stemRe = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
|
+
// `from SkyrampUtils import a, b` — or parenthesised across lines. The two
|
|
34
|
+
// forms are separate alternatives: an optional `(` with a lazy body let `$`
|
|
35
|
+
// match at the end of the `import (` line and captured nothing.
|
|
36
|
+
const fromRe = new RegExp(`^\\s*from\\s+(?:[\\w.]*\\.)?${stemRe}\\s+import\\s+(?:\\(([^)]*)\\)|([^\\n]+))`, "gm");
|
|
37
|
+
for (const m of spec.matchAll(fromRe)) {
|
|
38
|
+
for (const raw of (m[1] ?? m[2]).split(",")) {
|
|
39
|
+
const name = raw
|
|
40
|
+
.trim()
|
|
41
|
+
.split(/\s+as\s+/)[0]
|
|
42
|
+
.trim();
|
|
43
|
+
if (/^[A-Za-z_]\w*$/.test(name))
|
|
44
|
+
names.add(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// `import SkyrampUtils` / `import SkyrampUtils as utils` — every
|
|
48
|
+
// `<module-or-alias>.<name>` use counts.
|
|
49
|
+
const mod = new RegExp(`^\\s*import\\s+(?:[\\w.]*\\.)?${stemRe}(?:\\s+as\\s+([A-Za-z_]\\w*))?\\s*$`, "m").exec(spec);
|
|
50
|
+
if (mod) {
|
|
51
|
+
const qualifier = (mod[1] ?? stem).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
52
|
+
for (const m of spec.matchAll(new RegExp(`\\b${qualifier}\\.([A-Za-z_]\\w*)`, "g")))
|
|
53
|
+
names.add(m[1]);
|
|
54
|
+
}
|
|
55
|
+
return [...names];
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
function tsImportedNames(spec, stem) {
|
|
59
|
+
const names = new Set();
|
|
60
|
+
const stemRe = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
const from = `["'][^"']*\\/${stemRe}(?:\\.[cm]?[jt]s)?["']`;
|
|
62
|
+
for (const m of spec.matchAll(new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*${from}`, "g"))) {
|
|
63
|
+
for (const raw of m[1].split(",")) {
|
|
64
|
+
// The EXPORTED name (before `as`): the count checks what the utils file defines,
|
|
65
|
+
// so an alias would under-count. Same choice as the Python `from … import x as y`.
|
|
66
|
+
const name = raw
|
|
67
|
+
.trim()
|
|
68
|
+
.split(/\s+as\s+/)[0]
|
|
69
|
+
.trim();
|
|
70
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name))
|
|
71
|
+
names.add(name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const m of spec.matchAll(new RegExp(`import\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s*from\\s*${from}`, "g"))) {
|
|
75
|
+
for (const use of spec.matchAll(new RegExp(`\\b${m[1]}\\.([A-Za-z_$][\\w$]*)`, "g")))
|
|
76
|
+
names.add(use[1]);
|
|
77
|
+
}
|
|
78
|
+
for (const m of spec.matchAll(new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*require\\(\\s*${from}\\s*\\)`, "g"))) {
|
|
79
|
+
for (const raw of m[1].split(",")) {
|
|
80
|
+
// `{ a: b } = require(…)` — `a` is the exported name; `b` is the local alias.
|
|
81
|
+
const name = raw.trim().split(":")[0].trim();
|
|
82
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name))
|
|
83
|
+
names.add(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...names];
|
|
87
|
+
}
|
|
88
|
+
const TYPESCRIPT = {
|
|
89
|
+
language: "typescript",
|
|
90
|
+
extensions: [".ts", ".mts", ".cts"],
|
|
91
|
+
commentPrefix: "//",
|
|
92
|
+
// `function f(`, `const f = (…) =>`, `const f = async function (`, `const f = <T,>(…) =>`
|
|
93
|
+
// — and a signature Prettier wrapped, whose `(` still opens on the definition line.
|
|
94
|
+
helperStart: /^(?:export\s+)?(?:(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*[<(]|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*)?=\s*(?:async\s*)?(?:function\b|<[^>]*>\s*\(|\(|[A-Za-z_$][\w$]*\s*=>))/,
|
|
95
|
+
methodRe: /\bmethod\s*:\s*["'`]([A-Za-z]+)["'`]/,
|
|
96
|
+
pathRe: /\bpath\s*:\s*["'`]([^"'`]+)["'`]/,
|
|
97
|
+
assertionRe: /\bexpect(?:\.soft)?\s*\(/,
|
|
98
|
+
allowedAssertion:
|
|
99
|
+
// The receiver is the response itself, never a body/json/data field of it:
|
|
100
|
+
// `expect(res.body.status).toBe("paid")` is a body assertion. An optional
|
|
101
|
+
// string message argument (`expect(r.statusCode, 'status code')`) is the
|
|
102
|
+
// form codegen emits and still a status-only assertion.
|
|
103
|
+
/\bexpect(?:\.soft)?\s*\(\s*(?![\w.]*\.(?:body|json|data|payload)\b)[\w.]+\.status(?:_?[cC]ode)?(?:\(\))?\s*(?:,\s*(?:"[^"]*"|'[^']*'|`[^`]*`)\s*)?\)\s*\.\s*(?:not\.)?(?:toBe|toEqual|toStrictEqual|toBeGreaterThan|toBeLessThan|toBeOneOf)\s*\(/,
|
|
104
|
+
allowedBrowserAssertion: /\bexpect(?:\.soft)?\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)\s*\.\s*(?:not\.)?toBe(?:Visible|Hidden|Attached|Enabled|Disabled|Editable|Focused|InViewport)\s*\(/,
|
|
105
|
+
importedNames: tsImportedNames,
|
|
106
|
+
};
|
|
107
|
+
const JAVASCRIPT = {
|
|
108
|
+
...TYPESCRIPT,
|
|
109
|
+
language: "javascript",
|
|
110
|
+
extensions: [".js", ".mjs", ".cjs"],
|
|
111
|
+
};
|
|
112
|
+
export const UTILS_LANGUAGE_SPECS = {
|
|
113
|
+
python: PYTHON,
|
|
114
|
+
typescript: TYPESCRIPT,
|
|
115
|
+
javascript: JAVASCRIPT,
|
|
116
|
+
};
|
|
117
|
+
const SCENARIO_LITERAL_RE = /["'`][^"'`]*[\d$%€£][^"'`]*["'`]/;
|
|
118
|
+
/** The text inside `expect(...)`, up to the matcher. */
|
|
119
|
+
function expectArgs(expr) {
|
|
120
|
+
const open = expr.indexOf("(", expr.indexOf("expect"));
|
|
121
|
+
const close = expr.search(/\)\s*\.\s*(?:not\.)?to/);
|
|
122
|
+
return open === -1 || close === -1 ? expr : expr.slice(open + 1, close);
|
|
123
|
+
}
|
|
124
|
+
export const HELPER_FAMILIES = {
|
|
125
|
+
api: {
|
|
126
|
+
family: "api",
|
|
127
|
+
routeDuplicates: true,
|
|
128
|
+
allowedAssertion: (spec) => spec.allowedAssertion,
|
|
129
|
+
isAllowed: (spec, expr) => spec.allowedAssertion.test(expr),
|
|
130
|
+
assertionRule: "a shared API helper asserts only the status code; move this assertion to the test body, applied to the response the helper returns",
|
|
131
|
+
},
|
|
132
|
+
browser: {
|
|
133
|
+
family: "browser",
|
|
134
|
+
routeDuplicates: false,
|
|
135
|
+
allowedAssertion: (spec) => spec.allowedBrowserAssertion,
|
|
136
|
+
// A structural wait on a locator that carries a scenario LITERAL
|
|
137
|
+
// (`getByText("Total: $899.98")`) is a value assertion in disguise: digits or
|
|
138
|
+
// currency in any string inside the expect(...) arguments disqualify it.
|
|
139
|
+
isAllowed: (spec, expr) => spec.allowedBrowserAssertion.test(expr) &&
|
|
140
|
+
!SCENARIO_LITERAL_RE.test(expectArgs(expr)),
|
|
141
|
+
assertionRule: "a shared browser helper holds actions and structural waits only (toBeVisible, toBeEnabled, …); move this value assertion to the test body immediately after the helper call, selector verbatim",
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
/** The helper family a test type's shared helpers follow. Unlike `isBrowserTestType`,
|
|
145
|
+
* an ABSENT test type resolves to `api`: this runs against a utils file that already
|
|
146
|
+
* exists, and the browser rules are the more permissive of the two on duplicates, so
|
|
147
|
+
* guessing "browser" would silently switch a check off. */
|
|
148
|
+
export function helperFamilyFor(testType) {
|
|
149
|
+
const t = (testType ?? "").toLowerCase();
|
|
150
|
+
return HELPER_FAMILIES[t === "ui" || t === "e2e" ? "browser" : "api"];
|
|
151
|
+
}
|
|
152
|
+
/** The spec for a language string as the tools receive it, or `undefined` for a
|
|
153
|
+
* language this verifier does not cover (Java: utils reuse is disabled for it). */
|
|
154
|
+
export function utilsSpecFor(language) {
|
|
155
|
+
const key = (language ?? "").toLowerCase();
|
|
156
|
+
return UTILS_LANGUAGE_SPECS[key];
|
|
157
|
+
}
|
|
158
|
+
/** The spec whose extensions cover `file`, for callers that know a path but not a language. */
|
|
159
|
+
export function utilsSpecForFile(file) {
|
|
160
|
+
const lower = file.toLowerCase();
|
|
161
|
+
return Object.values(UTILS_LANGUAGE_SPECS).find((s) => s.extensions.some((e) => lower.endsWith(e)));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Helper names are `<verb>_<resource>` only (`create_order`, `update_order`); scenario
|
|
165
|
+
* data belongs in arguments. A name carrying a scenario adjective or a literal is the
|
|
166
|
+
* signature of a helper that will not merge with the same step from the next test
|
|
167
|
+
* (`update_order_50_discount` beside `update_order_discount`, SKYR-4196 run 4).
|
|
168
|
+
*
|
|
169
|
+
* Tokens are matched after splitting on `_` and camelCase boundaries, so the rule
|
|
170
|
+
* applies unchanged to Python and TS/JS names.
|
|
171
|
+
*/
|
|
172
|
+
export const SCENARIO_NAME_RULE = {
|
|
173
|
+
// Scenario ADJECTIVES only — words that never name a resource or a REST filter.
|
|
174
|
+
// Resource nouns that describe conditions (`error`, `test`, `new`, `first`) and
|
|
175
|
+
// filter states (`expired`, `failed`, `valid`, `missing`, `positive`) are legitimate:
|
|
176
|
+
// `create_test_case`, `list_expired_subscriptions`, `get_failed_jobs`. A false hit
|
|
177
|
+
// here blocks execution, so the list stays short.
|
|
178
|
+
forbiddenTokens: new Set([
|
|
179
|
+
"invalid",
|
|
180
|
+
"wrong",
|
|
181
|
+
"bad",
|
|
182
|
+
"nonexistent",
|
|
183
|
+
"nonexisting",
|
|
184
|
+
"unauthorized",
|
|
185
|
+
"unauthenticated",
|
|
186
|
+
"forbidden",
|
|
187
|
+
"notfound",
|
|
188
|
+
"scenario",
|
|
189
|
+
"twice",
|
|
190
|
+
"again",
|
|
191
|
+
]),
|
|
192
|
+
/** A bare number as its OWN token — `update_order_50_discount` — never a digit inside
|
|
193
|
+
* an identifier (`s3`, `oauth2`, `ipv4`, `sha256`, `base64`, `iso8601`, `2fa`). */
|
|
194
|
+
digitToken: /^\d+$/,
|
|
195
|
+
};
|
|
196
|
+
/** The reason a name is scenario-flavoured, or `undefined` when it is canonical. */
|
|
197
|
+
export function scenarioNameViolation(name) {
|
|
198
|
+
const tokens = name
|
|
199
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.split(/[_$]+/)
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
const hit = tokens.find((t) => SCENARIO_NAME_RULE.digitToken.test(t) ||
|
|
204
|
+
SCENARIO_NAME_RULE.forbiddenTokens.has(t));
|
|
205
|
+
if (!hit)
|
|
206
|
+
return undefined;
|
|
207
|
+
return SCENARIO_NAME_RULE.digitToken.test(hit)
|
|
208
|
+
? `contains the literal "${hit}" — scenario values belong in arguments, not the name`
|
|
209
|
+
: `contains the scenario word "${hit}" — name helpers by method + resource only`;
|
|
210
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The header `generateSkyrampHeader` writes into a utils file: `Generated by Skyramp on
|
|
2
|
+
* <timestamp>`. Deliberately NOT the bare `SKYRAMP_UTILS_HEADER`, which every generated
|
|
3
|
+
* TEST also carries as its codegen marker (`Generated by Skyramp v<version>`). */
|
|
4
|
+
export declare const UTILS_FILE_HEADER_RE: RegExp;
|
|
5
|
+
export interface LocateUtilsParams {
|
|
6
|
+
/** Absolute path of the delivered test file. */
|
|
7
|
+
testFile: string;
|
|
8
|
+
/** Tool-supplied language; inferred from the test file's extension when absent. */
|
|
9
|
+
language?: string;
|
|
10
|
+
/** Override for the git root (tests); detected from `testFile` otherwise. */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every shared utils file relevant to `testFile`, by CONTENT, never by name.
|
|
15
|
+
*
|
|
16
|
+
* The reuse prompt names a conventional file (`SkyrampUtils.py`) but explicitly allows
|
|
17
|
+
* "a new file with a different name" once that one is large, so any scheme deriving the
|
|
18
|
+
* path from the test path or a fixed filename fails silently on exactly that branch. A
|
|
19
|
+
* utils file is instead recognised by the header the prompt makes the agent write.
|
|
20
|
+
*
|
|
21
|
+
* Two sources, unioned, both scoped to the spec:
|
|
22
|
+
* 1. files this run touched (untracked, modified, or staged) IN THE SPEC'S OWN
|
|
23
|
+
* DIRECTORY, in the language's extensions — the file the agent just created or
|
|
24
|
+
* extended beside the test, which is what staging needs. Not repo-wide: a monorepo
|
|
25
|
+
* has one utils file per service directory, and one service's broken helper must
|
|
26
|
+
* not fail — or stage — another service's verify (review finding on #792).
|
|
27
|
+
* 2. modules the delivered spec imports, resolved beside it and required to stay
|
|
28
|
+
* inside the repository — a pre-existing utils file the spec merely imports from
|
|
29
|
+
* (SKYR-4196 run 8: byte-identical, so absent from source 1).
|
|
30
|
+
*
|
|
31
|
+
* Never throws: outside a git repo source 1 is empty and source 2 still runs; any
|
|
32
|
+
* other failure logs and contributes nothing.
|
|
33
|
+
*/
|
|
34
|
+
export declare function locateUtilsFiles(params: LocateUtilsParams): Promise<string[]>;
|
|
35
|
+
/** Canonical path, or the input unchanged when it does not exist (yet). */
|
|
36
|
+
export declare function realpath(p: string): Promise<string>;
|
|
37
|
+
/** True when the first lines of `file` carry the utils header. Reads only the head of
|
|
38
|
+
* the file — a utils file is small, but nothing here should depend on that. */
|
|
39
|
+
export declare function hasUtilsHeader(file: string): Promise<boolean>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { detectGitRoot } from "../gitStaging.js";
|
|
6
|
+
import { logger } from "../logger.js";
|
|
7
|
+
import { SKYRAMP_UTILS_HEADER } from "../utils.js";
|
|
8
|
+
import { utilsSpecFor, utilsSpecForFile, } from "./language-spec.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
/** The header `generateSkyrampHeader` writes into a utils file: `Generated by Skyramp on
|
|
11
|
+
* <timestamp>`. Deliberately NOT the bare `SKYRAMP_UTILS_HEADER`, which every generated
|
|
12
|
+
* TEST also carries as its codegen marker (`Generated by Skyramp v<version>`). */
|
|
13
|
+
export const UTILS_FILE_HEADER_RE = new RegExp(`${SKYRAMP_UTILS_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+on\\b`);
|
|
14
|
+
const HEADER_SCAN_LINES = 5;
|
|
15
|
+
/** Enough bytes for five header lines; a utils file is never read whole to find them. */
|
|
16
|
+
const HEADER_SCAN_BYTES = 2048;
|
|
17
|
+
/**
|
|
18
|
+
* Every shared utils file relevant to `testFile`, by CONTENT, never by name.
|
|
19
|
+
*
|
|
20
|
+
* The reuse prompt names a conventional file (`SkyrampUtils.py`) but explicitly allows
|
|
21
|
+
* "a new file with a different name" once that one is large, so any scheme deriving the
|
|
22
|
+
* path from the test path or a fixed filename fails silently on exactly that branch. A
|
|
23
|
+
* utils file is instead recognised by the header the prompt makes the agent write.
|
|
24
|
+
*
|
|
25
|
+
* Two sources, unioned, both scoped to the spec:
|
|
26
|
+
* 1. files this run touched (untracked, modified, or staged) IN THE SPEC'S OWN
|
|
27
|
+
* DIRECTORY, in the language's extensions — the file the agent just created or
|
|
28
|
+
* extended beside the test, which is what staging needs. Not repo-wide: a monorepo
|
|
29
|
+
* has one utils file per service directory, and one service's broken helper must
|
|
30
|
+
* not fail — or stage — another service's verify (review finding on #792).
|
|
31
|
+
* 2. modules the delivered spec imports, resolved beside it and required to stay
|
|
32
|
+
* inside the repository — a pre-existing utils file the spec merely imports from
|
|
33
|
+
* (SKYR-4196 run 8: byte-identical, so absent from source 1).
|
|
34
|
+
*
|
|
35
|
+
* Never throws: outside a git repo source 1 is empty and source 2 still runs; any
|
|
36
|
+
* other failure logs and contributes nothing.
|
|
37
|
+
*/
|
|
38
|
+
export async function locateUtilsFiles(params) {
|
|
39
|
+
const spec = utilsSpecFor(params.language) ?? utilsSpecForFile(params.testFile);
|
|
40
|
+
if (!spec)
|
|
41
|
+
return [];
|
|
42
|
+
const found = new Set();
|
|
43
|
+
const root = params.cwd ?? detectGitRoot(params.testFile);
|
|
44
|
+
// Git reports paths under the REAL root while a tool-supplied testFile may go
|
|
45
|
+
// through a symlink (macOS: /tmp → /private/tmp), so the two sources would name one
|
|
46
|
+
// file twice. Everything is compared and returned in canonical form.
|
|
47
|
+
const testFile = await realpath(params.testFile);
|
|
48
|
+
const specDir = path.dirname(testFile);
|
|
49
|
+
const realRoot = root ? await realpath(root) : undefined;
|
|
50
|
+
if (realRoot) {
|
|
51
|
+
for (const rel of await changedFiles(realRoot)) {
|
|
52
|
+
if (!spec.extensions.some((e) => rel.toLowerCase().endsWith(e)))
|
|
53
|
+
continue;
|
|
54
|
+
const abs = await realpath(path.resolve(realRoot, rel));
|
|
55
|
+
if (abs === testFile || path.dirname(abs) !== specDir)
|
|
56
|
+
continue;
|
|
57
|
+
if (await hasUtilsHeader(abs))
|
|
58
|
+
found.add(abs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const abs of await importedModules(testFile, spec)) {
|
|
62
|
+
const real = await realpath(abs);
|
|
63
|
+
// A relative import can climb out of the repository; a file there is not this
|
|
64
|
+
// repo's artifact and must never be verified or staged from here.
|
|
65
|
+
if (realRoot && !isInside(real, realRoot))
|
|
66
|
+
continue;
|
|
67
|
+
if (await hasUtilsHeader(real))
|
|
68
|
+
found.add(real);
|
|
69
|
+
}
|
|
70
|
+
return [...found].sort();
|
|
71
|
+
}
|
|
72
|
+
/** Canonical path, or the input unchanged when it does not exist (yet). */
|
|
73
|
+
export async function realpath(p) {
|
|
74
|
+
try {
|
|
75
|
+
return await fs.promises.realpath(p);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return path.resolve(p);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isInside(file, dir) {
|
|
82
|
+
const rel = path.relative(dir, file);
|
|
83
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
84
|
+
}
|
|
85
|
+
async function changedFiles(root) {
|
|
86
|
+
const out = new Set();
|
|
87
|
+
// A caller-supplied root need not be a repository (the eval fixtures are plain
|
|
88
|
+
// directories); source 1 then contributes nothing, silently.
|
|
89
|
+
try {
|
|
90
|
+
await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
91
|
+
cwd: root,
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
// `-z`: NUL-separated, unquoted. Without it git C-quotes any non-ASCII path
|
|
99
|
+
// (`"tests/caf\303\251_helpers.py"`), which no later step could resolve.
|
|
100
|
+
const cmds = [
|
|
101
|
+
["ls-files", "-z", "--others", "--exclude-standard"],
|
|
102
|
+
["diff", "-z", "--name-only"],
|
|
103
|
+
["diff", "-z", "--cached", "--name-only"],
|
|
104
|
+
];
|
|
105
|
+
for (const args of cmds) {
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
108
|
+
cwd: root,
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
});
|
|
111
|
+
for (const entry of stdout.split("\0"))
|
|
112
|
+
if (entry)
|
|
113
|
+
out.add(entry);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.warning("Could not list changed files while locating utils files", { root, args, error: String(err) });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...out];
|
|
120
|
+
}
|
|
121
|
+
/** True when the first lines of `file` carry the utils header. Reads only the head of
|
|
122
|
+
* the file — a utils file is small, but nothing here should depend on that. */
|
|
123
|
+
export async function hasUtilsHeader(file) {
|
|
124
|
+
let fh;
|
|
125
|
+
try {
|
|
126
|
+
fh = await fs.promises.open(file, "r");
|
|
127
|
+
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
|
|
128
|
+
const { bytesRead } = await fh.read(buf, 0, HEADER_SCAN_BYTES, 0);
|
|
129
|
+
const head = buf
|
|
130
|
+
.toString("utf8", 0, bytesRead)
|
|
131
|
+
.split("\n")
|
|
132
|
+
.slice(0, HEADER_SCAN_LINES)
|
|
133
|
+
.join("\n");
|
|
134
|
+
return UTILS_FILE_HEADER_RE.test(head);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
// A missing file is the common, silent case. Anything else means a file exists
|
|
138
|
+
// that cannot be read — it must not pass as "not a utils file" without a trace.
|
|
139
|
+
if (err?.code !== "ENOENT") {
|
|
140
|
+
logger.warning("Could not read a candidate utils file — treating it as not a utils file", {
|
|
141
|
+
file,
|
|
142
|
+
error: String(err),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await fh?.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Absolute paths of sibling modules `testFile` imports, for the module forms the reuse
|
|
152
|
+
* prompt produces. Only paths that exist are returned. */
|
|
153
|
+
async function importedModules(testFile, spec) {
|
|
154
|
+
let content;
|
|
155
|
+
try {
|
|
156
|
+
content = await fs.promises.readFile(testFile, "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (err?.code !== "ENOENT") {
|
|
160
|
+
logger.warning("Could not read the test file to resolve its imports", {
|
|
161
|
+
testFile,
|
|
162
|
+
error: String(err),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const dir = path.dirname(testFile);
|
|
168
|
+
const candidates = new Set();
|
|
169
|
+
if (spec.language === "python") {
|
|
170
|
+
for (const m of content.matchAll(/^\s*(?:from\s+([\w.]+)\s+import\b|import\s+([\w.]+)(?:\s+as\s+\w+)?\s*$)/gm)) {
|
|
171
|
+
const mod = (m[1] ?? m[2]).replace(/^\.+/, "");
|
|
172
|
+
if (!mod)
|
|
173
|
+
continue;
|
|
174
|
+
candidates.add(path.join(dir, ...mod.split(".")) + ".py");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
for (const m of content.matchAll(/(?:from\s*|require\(\s*|import\(\s*)["'](\.{1,2}\/[^"']+)["']/g)) {
|
|
179
|
+
const base = path.resolve(dir, m[1]);
|
|
180
|
+
const stripped = base.replace(/\.[cm]?[jt]sx?$/, "");
|
|
181
|
+
for (const ext of spec.extensions)
|
|
182
|
+
candidates.add(stripped + ext);
|
|
183
|
+
// A `.js` specifier in an ESM TypeScript spec resolves to the `.ts` source.
|
|
184
|
+
candidates.add(stripped + ".ts");
|
|
185
|
+
candidates.add(stripped + ".js");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const existing = [];
|
|
189
|
+
for (const c of candidates) {
|
|
190
|
+
try {
|
|
191
|
+
if ((await fs.promises.stat(c)).isFile())
|
|
192
|
+
existing.push(c);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* not present */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return existing;
|
|
199
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type UtilsLanguageSpec } from "./language-spec.js";
|
|
2
|
+
export interface HelperAssertion {
|
|
3
|
+
line: number;
|
|
4
|
+
text: string;
|
|
5
|
+
/** True for the one permitted shape — a status-code comparison. */
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface UtilsHelper {
|
|
9
|
+
name: string;
|
|
10
|
+
/** 1-based line of the definition. */
|
|
11
|
+
line: number;
|
|
12
|
+
/** 1-based line of the last body line (inclusive). */
|
|
13
|
+
endLine: number;
|
|
14
|
+
method?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
/** `path` with every path parameter collapsed to `{}`; the duplicate key with `method`. */
|
|
17
|
+
normalizedPath?: string;
|
|
18
|
+
assertions: HelperAssertion[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Structured extraction of the module-level helpers in a utils file.
|
|
22
|
+
*
|
|
23
|
+
* Not a general parser. These files are highly regular by construction — one SDK
|
|
24
|
+
* request call plus assertions per helper, authored by a prompt that forbids
|
|
25
|
+
* classes and nested structure — so line-oriented extraction is sufficient and
|
|
26
|
+
* far more robust than a per-language AST dependency. Python bodies end at the
|
|
27
|
+
* next non-blank column-0 line; TS/JS bodies end when the brace depth opened by
|
|
28
|
+
* the definition returns to zero.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseUtilsFile(content: string, spec: UtilsLanguageSpec, allowedAssertion?: RegExp | ((expr: string) => boolean)): UtilsHelper[];
|
|
31
|
+
/** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
|
|
32
|
+
* comments are `#…`; TS/JS go through the shared stripper. */
|
|
33
|
+
/** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */
|
|
34
|
+
export declare function blankStrings(src: string, spec: UtilsLanguageSpec): string;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { normalizePath } from "./language-spec.js";
|
|
2
|
+
import { stripCommentsAndStrings, stripStrings } from "../pom-scope/strip.js";
|
|
3
|
+
/**
|
|
4
|
+
* Structured extraction of the module-level helpers in a utils file.
|
|
5
|
+
*
|
|
6
|
+
* Not a general parser. These files are highly regular by construction — one SDK
|
|
7
|
+
* request call plus assertions per helper, authored by a prompt that forbids
|
|
8
|
+
* classes and nested structure — so line-oriented extraction is sufficient and
|
|
9
|
+
* far more robust than a per-language AST dependency. Python bodies end at the
|
|
10
|
+
* next non-blank column-0 line; TS/JS bodies end when the brace depth opened by
|
|
11
|
+
* the definition returns to zero.
|
|
12
|
+
*/
|
|
13
|
+
export function parseUtilsFile(content, spec, allowedAssertion = spec.allowedAssertion) {
|
|
14
|
+
const isAllowed = typeof allowedAssertion === "function"
|
|
15
|
+
? allowedAssertion
|
|
16
|
+
: (expr) => allowedAssertion.test(expr);
|
|
17
|
+
const lines = content.split("\n");
|
|
18
|
+
const helpers = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < lines.length) {
|
|
21
|
+
const m = spec.helperStart.exec(lines[i]);
|
|
22
|
+
if (!m) {
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const name = m.slice(1).find((g) => g !== undefined) ?? "";
|
|
27
|
+
const start = i;
|
|
28
|
+
const end = spec.language === "python"
|
|
29
|
+
? pythonBodyEnd(lines, start)
|
|
30
|
+
: braceBodyEnd(lines, start);
|
|
31
|
+
const body = lines.slice(start, end);
|
|
32
|
+
// Scan a copy with comments and string contents blanked, so a commented-out
|
|
33
|
+
// `path=` or an `expect(` inside a string never keys or flags anything; the
|
|
34
|
+
// original line is what the agent is shown.
|
|
35
|
+
const scan = blankCommentsAndStrings(body.join("\n"), spec).split("\n");
|
|
36
|
+
const helper = {
|
|
37
|
+
name,
|
|
38
|
+
line: start + 1,
|
|
39
|
+
endLine: Math.max(end, start + 1),
|
|
40
|
+
assertions: [],
|
|
41
|
+
};
|
|
42
|
+
let requests = 0;
|
|
43
|
+
for (let k = 0; k < body.length; k++) {
|
|
44
|
+
const text = body[k];
|
|
45
|
+
const code = scan[k] ?? "";
|
|
46
|
+
if (/\b(?:send_request|sendRequest)\s*\(/.test(code))
|
|
47
|
+
requests++;
|
|
48
|
+
const method = spec.methodRe.exec(text);
|
|
49
|
+
if (method && /\bmethod\b/.test(code) && !helper.method)
|
|
50
|
+
helper.method = method[1].toUpperCase();
|
|
51
|
+
const p = spec.pathRe.exec(text);
|
|
52
|
+
// Key only a path given as ONE literal (followed by `,` or `)`); a concatenation
|
|
53
|
+
// (`"/api/orders/" + str(id) + "/items"`) has no route this can name.
|
|
54
|
+
if (p &&
|
|
55
|
+
/\bpath\b/.test(code) &&
|
|
56
|
+
!helper.path &&
|
|
57
|
+
isSingleLiteral(text, p)) {
|
|
58
|
+
helper.path = p[1];
|
|
59
|
+
helper.normalizedPath = normalizePath(p[1]);
|
|
60
|
+
}
|
|
61
|
+
// The definition line itself is never an assertion, and comment lines are prose.
|
|
62
|
+
// A formatted assertion may span lines (`await expect(\n locator\n).toBeVisible()`):
|
|
63
|
+
// collect it until its parentheses balance, classify the whole expression.
|
|
64
|
+
if (k > 0 && spec.assertionRe.test(code)) {
|
|
65
|
+
let expr = code;
|
|
66
|
+
let last = k;
|
|
67
|
+
while (depthOf(expr) > 0 && last + 1 < body.length && last - k < 8) {
|
|
68
|
+
last++;
|
|
69
|
+
expr += " " + (scan[last] ?? "").trim();
|
|
70
|
+
}
|
|
71
|
+
// Classified on the ORIGINAL text: the allowed patterns look inside string
|
|
72
|
+
// literals (a scenario literal in a locator disqualifies a structural wait).
|
|
73
|
+
const original = body
|
|
74
|
+
.slice(k, last + 1)
|
|
75
|
+
.map((l) => l.trim())
|
|
76
|
+
.join(" ");
|
|
77
|
+
helper.assertions.push({
|
|
78
|
+
line: start + k + 1,
|
|
79
|
+
text: original,
|
|
80
|
+
allowed: isAllowed(original),
|
|
81
|
+
});
|
|
82
|
+
k = last;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// A helper wrapping two requests is a sequence, not "one SDK call": it has no single
|
|
86
|
+
// route to be a duplicate of, and the shared-helper rules do not apply to it.
|
|
87
|
+
if (requests > 1) {
|
|
88
|
+
helper.method = undefined;
|
|
89
|
+
helper.path = undefined;
|
|
90
|
+
helper.normalizedPath = undefined;
|
|
91
|
+
}
|
|
92
|
+
helpers.push(helper);
|
|
93
|
+
i = Math.max(end, start + 1);
|
|
94
|
+
}
|
|
95
|
+
return helpers;
|
|
96
|
+
}
|
|
97
|
+
function depthOf(code) {
|
|
98
|
+
let d = 0;
|
|
99
|
+
for (const ch of code)
|
|
100
|
+
if (ch === "(")
|
|
101
|
+
d++;
|
|
102
|
+
else if (ch === ")")
|
|
103
|
+
d--;
|
|
104
|
+
return d;
|
|
105
|
+
}
|
|
106
|
+
/** True when the `path` value is a single string literal ending the argument. */
|
|
107
|
+
function isSingleLiteral(line, m) {
|
|
108
|
+
const after = line.slice(m.index + m[0].length).replace(/^\s+/, "");
|
|
109
|
+
return (after === "" ||
|
|
110
|
+
after.startsWith(",") ||
|
|
111
|
+
after.startsWith(")") ||
|
|
112
|
+
after.startsWith("}"));
|
|
113
|
+
}
|
|
114
|
+
/** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
|
|
115
|
+
* comments are `#…`; TS/JS go through the shared stripper. */
|
|
116
|
+
/** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */
|
|
117
|
+
export function blankStrings(src, spec) {
|
|
118
|
+
if (spec.language !== "python")
|
|
119
|
+
return stripStrings(src);
|
|
120
|
+
return src
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map((line) => line.replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q[0] + " ".repeat(Math.max(0, q.length - 2)) + q[0]))
|
|
123
|
+
.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function blankCommentsAndStrings(src, spec) {
|
|
126
|
+
if (spec.language !== "python")
|
|
127
|
+
return stripCommentsAndStrings(src);
|
|
128
|
+
return src
|
|
129
|
+
.split("\n")
|
|
130
|
+
.map((line) => line
|
|
131
|
+
.replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q[0] + " ".repeat(Math.max(0, q.length - 2)) + q[0])
|
|
132
|
+
.replace(/#.*$/, ""))
|
|
133
|
+
.join("\n");
|
|
134
|
+
}
|
|
135
|
+
/** Exclusive end index of a Python def starting at `start`: the next non-blank line at
|
|
136
|
+
* column 0 (a following def, import, or module statement). */
|
|
137
|
+
function pythonBodyEnd(lines, start) {
|
|
138
|
+
let i = start + 1;
|
|
139
|
+
while (i < lines.length) {
|
|
140
|
+
const l = lines[i];
|
|
141
|
+
if (l.trim() !== "" && !/^\s/.test(l))
|
|
142
|
+
break;
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
return i;
|
|
146
|
+
}
|
|
147
|
+
/** Exclusive end index of a brace-delimited definition starting at `start`. Strings and
|
|
148
|
+
* comments are not tracked — helper bodies contain request paths, not braces in
|
|
149
|
+
* strings — and an unbalanced body runs to end of file rather than throwing. */
|
|
150
|
+
function braceBodyEnd(lines, start) {
|
|
151
|
+
let depth = 0;
|
|
152
|
+
let opened = false;
|
|
153
|
+
for (let i = start; i < lines.length; i++) {
|
|
154
|
+
for (const ch of lines[i]) {
|
|
155
|
+
if (ch === "{") {
|
|
156
|
+
depth++;
|
|
157
|
+
opened = true;
|
|
158
|
+
}
|
|
159
|
+
else if (ch === "}") {
|
|
160
|
+
depth--;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (opened && depth <= 0)
|
|
164
|
+
return i + 1;
|
|
165
|
+
// No brace yet: a wrapped signature (Prettier splits any signature over 80 columns
|
|
166
|
+
// across lines) continues until its `{`. An arrow helper with an expression body
|
|
167
|
+
// ends at the first line that closes its `=>` expression without a brace.
|
|
168
|
+
if (!opened &&
|
|
169
|
+
i > start &&
|
|
170
|
+
/=>\s*[^{\s]/.test(lines[start]) &&
|
|
171
|
+
/;\s*$/.test(lines[i - 1]))
|
|
172
|
+
return i;
|
|
173
|
+
if (!opened && i - start > 12)
|
|
174
|
+
return i;
|
|
175
|
+
}
|
|
176
|
+
return lines.length;
|
|
177
|
+
}
|