chrometools-mcp 1.9.1 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/README.md +279 -53
- package/browser/browser-manager.js +206 -0
- package/browser/page-manager.js +298 -0
- package/index.js +625 -1875
- package/package.json +1 -1
- package/recorder/page-object-generator.js +720 -0
- package/recorder/recorder-script.js +63 -9
- package/recorder/scenario-executor.js +47 -27
- package/recorder/scenario-storage.js +251 -29
- package/server/tool-definitions.js +655 -0
- package/server/tool-schemas.js +295 -0
- package/utils/code-generators/code-generator-base.js +61 -0
- package/utils/code-generators/file-appender.js +202 -0
- package/utils/code-generators/playwright-python.js +84 -0
- package/utils/code-generators/playwright-typescript.js +95 -0
- package/utils/code-generators/selenium-java.js +123 -0
- package/utils/code-generators/selenium-python.js +82 -0
- package/utils/css-utils.js +151 -0
- package/utils/image-processing.js +236 -0
- package/utils/platform-utils.js +62 -0
- package/utils/url-to-project.js +141 -0
- package/utils/project-detector.js +0 -87
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/tool-schemas.js
|
|
3
|
+
*
|
|
4
|
+
* Zod schemas for all MCP tools
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
// Basic tools
|
|
10
|
+
export const PingSchema = z.object({
|
|
11
|
+
message: z.string().optional().describe("Optional message to send"),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const OpenBrowserSchema = z.object({
|
|
15
|
+
url: z.string().describe("URL to open in the browser"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const ClickSchema = z.object({
|
|
19
|
+
selector: z.string().describe("CSS selector for element to click"),
|
|
20
|
+
waitAfter: z.number().optional().describe("Milliseconds to wait after click (default: 1500)"),
|
|
21
|
+
screenshot: z.boolean().optional().describe("Capture screenshot after click (default: false for performance)"),
|
|
22
|
+
timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const TypeSchema = z.object({
|
|
26
|
+
selector: z.string().describe("CSS selector for input element"),
|
|
27
|
+
text: z.string().describe("Text to type"),
|
|
28
|
+
delay: z.number().optional().describe("Delay between keystrokes in ms (default: 0)"),
|
|
29
|
+
clearFirst: z.boolean().optional().describe("Clear field before typing (default: true)"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const GetElementSchema = z.object({
|
|
33
|
+
selector: z.string().optional().describe("CSS selector (optional, defaults to body)"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const HoverSchema = z.object({
|
|
37
|
+
selector: z.string().describe("CSS selector for element to hover"),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// CSS tools
|
|
41
|
+
export const GetComputedCssSchema = z.object({
|
|
42
|
+
selector: z.string().optional().describe("CSS selector (optional, defaults to body)"),
|
|
43
|
+
category: z.enum(['all', 'layout', 'typography', 'colors', 'visual']).optional().describe("Filter by CSS category: 'layout' (sizing, positioning), 'typography' (fonts, text), 'colors' (color schemes), 'visual' (effects, transforms), 'all' (default)"),
|
|
44
|
+
properties: z.array(z.string()).optional().describe("Specific CSS properties to return (e.g., ['color', 'font-size']). Overrides category filter."),
|
|
45
|
+
includeDefaults: z.boolean().optional().describe("Include properties with default values (default: false)"),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const GetBoxModelSchema = z.object({
|
|
49
|
+
selector: z.string().describe("CSS selector for element"),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const SetStylesSchema = z.object({
|
|
53
|
+
selector: z.string().describe("CSS selector for element to modify"),
|
|
54
|
+
styles: z.array(z.object({
|
|
55
|
+
name: z.string().describe("CSS property name (e.g., 'color')"),
|
|
56
|
+
value: z.string().describe("CSS property value (e.g., 'red')")
|
|
57
|
+
})).describe("Array of CSS property name-value pairs"),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Screenshot tools
|
|
61
|
+
export const ScreenshotSchema = z.object({
|
|
62
|
+
selector: z.string().describe("CSS selector for element to screenshot"),
|
|
63
|
+
padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
|
|
64
|
+
maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
|
|
65
|
+
maxHeight: z.number().nullable().optional().describe("Maximum height in pixels, auto-scales if larger (default: 8000 for API limit, set to null for original size)"),
|
|
66
|
+
quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 80, only applies to JPEG format)"),
|
|
67
|
+
format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format: 'png', 'jpeg', or 'auto' (default: 'auto' - chooses based on size)"),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const SaveScreenshotSchema = z.object({
|
|
71
|
+
selector: z.string().describe("CSS selector for element to screenshot"),
|
|
72
|
+
filePath: z.string().describe("Absolute path where to save file"),
|
|
73
|
+
padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
|
|
74
|
+
maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
|
|
75
|
+
maxHeight: z.number().nullable().optional().describe("Maximum height in pixels, auto-scales if larger (default: 8000 for API limit, set to null for original size)"),
|
|
76
|
+
quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 80, only applies to JPEG format)"),
|
|
77
|
+
format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format: 'png', 'jpeg', or 'auto' (default: 'auto' - chooses based on size)"),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Navigation tools
|
|
81
|
+
export const ScrollToSchema = z.object({
|
|
82
|
+
selector: z.string().describe("CSS selector for element to scroll to"),
|
|
83
|
+
behavior: z.enum(['auto', 'smooth']).optional().describe("Scroll behavior (default: auto)"),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const WaitForElementSchema = z.object({
|
|
87
|
+
selector: z.string().describe("CSS selector to wait for"),
|
|
88
|
+
timeout: z.number().optional().describe("Maximum time to wait in milliseconds (default: 5000)"),
|
|
89
|
+
visible: z.boolean().optional().describe("Wait for element to be visible (default: true)"),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const NavigateToSchema = z.object({
|
|
93
|
+
url: z.string().describe("URL to navigate to"),
|
|
94
|
+
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2'])
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("Wait until event (default: networkidle2)"),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const SetViewportSchema = z.object({
|
|
100
|
+
width: z.number().min(320).max(4000).describe("Viewport width in pixels (320-4000)"),
|
|
101
|
+
height: z.number().min(200).max(3000).describe("Viewport height in pixels (200-3000)"),
|
|
102
|
+
deviceScaleFactor: z.number().min(0.5).max(3).optional().describe("Device pixel ratio (0.5-3, default: 1)"),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const GetViewportSchema = z.object({});
|
|
106
|
+
|
|
107
|
+
// Script execution
|
|
108
|
+
export const ExecuteScriptSchema = z.object({
|
|
109
|
+
script: z.string().describe("JavaScript code to execute in page context"),
|
|
110
|
+
waitAfter: z.number().optional().describe("Milliseconds to wait after execution (default: 500)"),
|
|
111
|
+
screenshot: z.boolean().optional().describe("Capture screenshot after execution (default: false for performance)"),
|
|
112
|
+
timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Network monitoring tools
|
|
116
|
+
export const GetConsoleLogsSchema = z.object({
|
|
117
|
+
types: z.array(z.enum(['log', 'warn', 'error', 'info', 'debug', 'verbose', 'warning']))
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Filter by log types (default: all)"),
|
|
120
|
+
clear: z.boolean().optional().describe("Clear logs after reading (default: false)"),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const ListNetworkRequestsSchema = z.object({
|
|
124
|
+
types: z.array(z.enum(['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Script', 'XHR', 'Fetch', 'WebSocket', 'Other']))
|
|
125
|
+
.optional()
|
|
126
|
+
.default(['Fetch', 'XHR'])
|
|
127
|
+
.describe("Filter by request types (default: Fetch, XHR)"),
|
|
128
|
+
status: z.enum(['pending', 'completed', 'failed', 'all'])
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Filter by status (default: all)"),
|
|
131
|
+
limit: z.number().min(1).max(500).optional().default(50).describe("Maximum number of requests to return (default: 50)"),
|
|
132
|
+
offset: z.number().min(0).optional().default(0).describe("Number of requests to skip before returning results (default: 0)"),
|
|
133
|
+
clear: z.boolean().optional().describe("Clear requests after reading (default: false)"),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const GetNetworkRequestSchema = z.object({
|
|
137
|
+
requestId: z.string().describe("Request ID to get details for"),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const FilterNetworkRequestsSchema = z.object({
|
|
141
|
+
urlPattern: z.string().describe("URL pattern to filter by (regex or partial match)"),
|
|
142
|
+
types: z.array(z.enum(['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Script', 'XHR', 'Fetch', 'WebSocket', 'Other']))
|
|
143
|
+
.optional()
|
|
144
|
+
.default(['Fetch', 'XHR'])
|
|
145
|
+
.describe("Filter by request types (default: Fetch, XHR)"),
|
|
146
|
+
clear: z.boolean().optional().describe("Clear requests after reading (default: false)"),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Figma tools
|
|
150
|
+
export const GetFigmaFrameSchema = z.object({
|
|
151
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
152
|
+
fileKey: z.string().describe("Figma file key (from URL: figma.com/file/FILE_KEY/...)"),
|
|
153
|
+
nodeId: z.string().describe("Figma node ID (frame/component ID)"),
|
|
154
|
+
scale: z.number().min(0.1).max(4).optional().describe("Export scale (0.1-4, default: 2)"),
|
|
155
|
+
format: z.enum(['png', 'jpg', 'svg']).optional().describe("Export format (default: png)")
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export const CompareFigmaToElementSchema = z.object({
|
|
159
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
160
|
+
fileKey: z.string().describe("Figma file key"),
|
|
161
|
+
nodeId: z.string().describe("Figma frame/component ID"),
|
|
162
|
+
selector: z.string().describe("CSS selector for page element"),
|
|
163
|
+
threshold: z.number().min(0).max(1).optional().describe("Difference threshold (0-1, default: 0.05)"),
|
|
164
|
+
figmaScale: z.number().min(0.1).max(4).optional().describe("Figma export scale (default: 2)")
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export const GetFigmaSpecsSchema = z.object({
|
|
168
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
169
|
+
fileKey: z.string().describe("Figma file key"),
|
|
170
|
+
nodeId: z.string().describe("Figma frame/component ID")
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export const ParseFigmaUrlSchema = z.object({
|
|
174
|
+
url: z.string().describe("Full Figma URL or fileKey")
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const ListFigmaPagesSchema = z.object({
|
|
178
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
179
|
+
fileKey: z.string().describe("Figma file key or full Figma URL")
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const SearchFigmaFramesSchema = z.object({
|
|
183
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
184
|
+
fileKey: z.string().describe("Figma file key or full Figma URL"),
|
|
185
|
+
searchQuery: z.string().describe("Search query")
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
export const GetFigmaComponentsSchema = z.object({
|
|
189
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
190
|
+
fileKey: z.string().describe("Figma file key or full Figma URL")
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export const GetFigmaStylesSchema = z.object({
|
|
194
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
195
|
+
fileKey: z.string().describe("Figma file key or full Figma URL")
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export const GetFigmaColorPaletteSchema = z.object({
|
|
199
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
200
|
+
fileKey: z.string().describe("Figma file key or full Figma URL")
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Page analysis tools
|
|
204
|
+
export const SmartFindElementSchema = z.object({
|
|
205
|
+
description: z.string().describe("Natural language description of element to find (e.g., 'login button', 'email field')"),
|
|
206
|
+
maxResults: z.number().min(1).max(20).optional().describe("Maximum number of candidates to return (default: 5)"),
|
|
207
|
+
action: z.object({
|
|
208
|
+
type: z.enum(['click', 'type', 'scrollTo', 'screenshot', 'hover', 'setStyles']).describe("Action to perform on the best match"),
|
|
209
|
+
text: z.string().optional().describe("Text to type (required for 'type' action)"),
|
|
210
|
+
styles: z.array(z.object({
|
|
211
|
+
name: z.string(),
|
|
212
|
+
value: z.string()
|
|
213
|
+
})).optional().describe("Styles to apply (required for 'setStyles' action)"),
|
|
214
|
+
screenshot: z.boolean().optional().describe("Capture screenshot after action (default: false)"),
|
|
215
|
+
waitAfter: z.number().optional().describe("Wait time in ms after action"),
|
|
216
|
+
}).optional().describe("Optional action to perform on the best matching element"),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
export const AnalyzePageSchema = z.object({
|
|
220
|
+
refresh: z.boolean().optional().describe("Force refresh of cached analysis (default: false)"),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export const GetAllInteractiveElementsSchema = z.object({
|
|
224
|
+
includeHidden: z.boolean().optional().describe("Include hidden elements (default: false)"),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
export const FindElementsByTextSchema = z.object({
|
|
228
|
+
text: z.string().describe("Text to search for in elements"),
|
|
229
|
+
exact: z.boolean().optional().describe("Exact match only (default: false)"),
|
|
230
|
+
caseSensitive: z.boolean().optional().describe("Case sensitive search (default: false)"),
|
|
231
|
+
action: z.object({
|
|
232
|
+
type: z.enum(['click', 'type', 'scrollTo', 'screenshot', 'hover', 'setStyles']).describe("Action to perform on the first match"),
|
|
233
|
+
text: z.string().optional().describe("Text to type (required for 'type' action)"),
|
|
234
|
+
styles: z.array(z.object({
|
|
235
|
+
name: z.string(),
|
|
236
|
+
value: z.string()
|
|
237
|
+
})).optional().describe("Styles to apply (required for 'setStyles' action)"),
|
|
238
|
+
screenshot: z.boolean().optional().describe("Capture screenshot after action (default: false)"),
|
|
239
|
+
waitAfter: z.number().optional().describe("Wait time in ms after action"),
|
|
240
|
+
}).optional().describe("Optional action to perform on the first matching element"),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Recorder tools (schemas created from inline definitions)
|
|
244
|
+
export const EnableRecorderSchema = z.object({
|
|
245
|
+
directory: z.string().optional().describe("Directory to save scenarios (optional, defaults to auto-detected project root)")
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export const ExecuteScenarioSchema = z.object({
|
|
249
|
+
name: z.string().describe("Scenario name"),
|
|
250
|
+
projectId: z.string().optional().describe("Optional: Project ID (domain) to disambiguate scenarios with same name"),
|
|
251
|
+
parameters: z.record(z.any()).optional().describe("Execution parameters"),
|
|
252
|
+
executeDependencies: z.boolean().optional().describe("Execute dependencies (default: true)")
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export const ListScenariosSchema = z.object({
|
|
256
|
+
directory: z.string().optional().describe("Directory where scenarios are stored (optional)")
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
export const SearchScenariosSchema = z.object({
|
|
260
|
+
text: z.string().optional().describe("Search text"),
|
|
261
|
+
tags: z.array(z.string()).optional().describe("Filter tags"),
|
|
262
|
+
directory: z.string().optional().describe("Directory where scenarios are stored (optional)")
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
export const GetScenarioInfoSchema = z.object({
|
|
266
|
+
name: z.string().describe("Scenario name"),
|
|
267
|
+
includeSecrets: z.boolean().optional().describe("Include secrets (default: false)"),
|
|
268
|
+
directory: z.string().optional().describe("Directory where scenarios are stored (optional)")
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
export const DeleteScenarioSchema = z.object({
|
|
272
|
+
name: z.string().describe("Scenario name"),
|
|
273
|
+
directory: z.string().optional().describe("Directory where scenarios are stored (optional)")
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
export const ExportScenarioAsCodeSchema = z.object({
|
|
277
|
+
scenarioName: z.string().describe("Name of scenario to export"),
|
|
278
|
+
language: z.enum(['playwright-typescript', 'playwright-python', 'selenium-python', 'selenium-java']).describe("Target test framework and language"),
|
|
279
|
+
cleanSelectors: z.boolean().optional().describe("Remove unstable CSS classes (default: true)"),
|
|
280
|
+
includeComments: z.boolean().optional().describe("Include descriptive comments (default: true)"),
|
|
281
|
+
generatePageObject: z.boolean().optional().describe("Also generate Page Object class for the page (default: false)"),
|
|
282
|
+
pageObjectClassName: z.string().optional().describe("Page Object class name (optional, auto-generated if not provided)"),
|
|
283
|
+
directory: z.string().optional().describe("Directory where scenarios are stored (optional)"),
|
|
284
|
+
appendToFile: z.string().optional().describe("Path to existing test file to append to (enables append mode)"),
|
|
285
|
+
testName: z.string().optional().describe("Override test name (default: from scenario name)"),
|
|
286
|
+
insertPosition: z.enum(['end', 'before', 'after']).optional().describe("Where to insert test: 'end' (default), 'before', or 'after' a reference test"),
|
|
287
|
+
referenceTestName: z.string().optional().describe("Reference test name for 'before'/'after' insertion")
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
export const GeneratePageObjectSchema = z.object({
|
|
291
|
+
className: z.string().optional().describe("Page Object class name (optional, auto-generated from page title/URL if not provided)"),
|
|
292
|
+
framework: z.enum(['playwright-typescript', 'playwright-python', 'selenium-python', 'selenium-java']).optional().describe("Target test framework (default: playwright-typescript)"),
|
|
293
|
+
includeComments: z.boolean().optional().describe("Include descriptive comments in generated code (default: true)"),
|
|
294
|
+
groupElements: z.boolean().optional().describe("Group elements by page sections (default: true)")
|
|
295
|
+
});
|
|
@@ -234,4 +234,65 @@ export class CodeGeneratorBase {
|
|
|
234
234
|
generateUrlAssertion(expectedUrl) {
|
|
235
235
|
throw new Error('generateUrlAssertion() must be implemented by subclass');
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Generate only test function/method (without imports)
|
|
240
|
+
* Used for appending tests to existing files
|
|
241
|
+
* @param {Object} scenario - Scenario object
|
|
242
|
+
* @param {Object} options - Generation options
|
|
243
|
+
* @returns {string} - Generated test code (no imports)
|
|
244
|
+
*/
|
|
245
|
+
generateTestOnly(scenario, options = {}) {
|
|
246
|
+
this.options = { ...this.options, ...options };
|
|
247
|
+
|
|
248
|
+
const testName = options.testName || scenario.metadata?.name || 'test';
|
|
249
|
+
const description = scenario.metadata?.description || '';
|
|
250
|
+
const entryUrl = scenario.metadata?.entryUrl || '';
|
|
251
|
+
const exitUrl = scenario.metadata?.exitUrl || '';
|
|
252
|
+
|
|
253
|
+
const lines = [];
|
|
254
|
+
|
|
255
|
+
// Generate test function/method header
|
|
256
|
+
lines.push(...this.generateTestHeader(testName, description));
|
|
257
|
+
lines.push('');
|
|
258
|
+
|
|
259
|
+
// Generate navigation to entry URL
|
|
260
|
+
if (entryUrl) {
|
|
261
|
+
lines.push(...this.generateNavigate(entryUrl));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Generate actions
|
|
265
|
+
for (const action of scenario.chain) {
|
|
266
|
+
const actionCode = this.generateAction(action);
|
|
267
|
+
if (actionCode && actionCode.length > 0) {
|
|
268
|
+
lines.push(...actionCode);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Generate exit URL validation
|
|
273
|
+
if (exitUrl) {
|
|
274
|
+
lines.push('');
|
|
275
|
+
if (this.options.includeComments) {
|
|
276
|
+
lines.push(this.indent(this.generateComment('Validate final URL'), 1));
|
|
277
|
+
}
|
|
278
|
+
lines.push(...this.generateUrlAssertion(exitUrl));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Generate test footer
|
|
282
|
+
lines.push(...this.generateTestFooter());
|
|
283
|
+
|
|
284
|
+
return lines.join('\n');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Append test to existing file content
|
|
289
|
+
* Must be implemented by subclass for language-specific file parsing
|
|
290
|
+
* @param {string} existingContent - Current file content
|
|
291
|
+
* @param {string} newTestCode - New test code to insert
|
|
292
|
+
* @param {Object} options - Insertion options {insertPosition, referenceTestName}
|
|
293
|
+
* @returns {string} - Updated file content
|
|
294
|
+
*/
|
|
295
|
+
appendTest(existingContent, newTestCode, options = {}) {
|
|
296
|
+
throw new Error('appendTest() must be implemented by subclass');
|
|
297
|
+
}
|
|
237
298
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/code-generators/file-appender.js
|
|
3
|
+
*
|
|
4
|
+
* Utilities for appending tests to existing test files
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
8
|
+
import { extname } from 'path';
|
|
9
|
+
|
|
10
|
+
export class FileAppender {
|
|
11
|
+
/**
|
|
12
|
+
* Expected file extensions for each language
|
|
13
|
+
*/
|
|
14
|
+
static EXTENSIONS = {
|
|
15
|
+
'playwright-typescript': ['.ts', '.spec.ts', '.test.ts'],
|
|
16
|
+
'playwright-python': ['.py'],
|
|
17
|
+
'selenium-python': ['.py'],
|
|
18
|
+
'selenium-java': ['.java']
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read file with error handling
|
|
23
|
+
* @param {string} filePath - Path to file
|
|
24
|
+
* @returns {string} - File content
|
|
25
|
+
*/
|
|
26
|
+
static readFile(filePath) {
|
|
27
|
+
if (!existsSync(filePath)) {
|
|
28
|
+
throw new Error(`File not found: ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(filePath, 'utf-8');
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`Failed to read file ${filePath}: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write file with automatic backup
|
|
40
|
+
* @param {string} filePath - Path to file
|
|
41
|
+
* @param {string} content - Content to write
|
|
42
|
+
* @param {boolean} backup - Create backup before writing
|
|
43
|
+
*/
|
|
44
|
+
static writeFile(filePath, content, backup = true) {
|
|
45
|
+
const backupPath = filePath + '.backup';
|
|
46
|
+
let originalContent = null;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Create backup if file exists and backup is requested
|
|
50
|
+
if (backup && existsSync(filePath)) {
|
|
51
|
+
originalContent = readFileSync(filePath, 'utf-8');
|
|
52
|
+
writeFileSync(backupPath, originalContent, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Write new content
|
|
56
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
57
|
+
|
|
58
|
+
// Clean up backup on success
|
|
59
|
+
if (backup && existsSync(backupPath)) {
|
|
60
|
+
unlinkSync(backupPath);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// Restore from backup on failure
|
|
64
|
+
if (backup && originalContent !== null) {
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(filePath, originalContent, 'utf-8');
|
|
67
|
+
} catch (restoreError) {
|
|
68
|
+
throw new Error(`Failed to write file and restore backup: ${error.message}, ${restoreError.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Failed to write file ${filePath}: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate file extension matches language
|
|
77
|
+
* @param {string} filePath - Path to file
|
|
78
|
+
* @param {string} language - Target language
|
|
79
|
+
* @throws {Error} If extension doesn't match
|
|
80
|
+
*/
|
|
81
|
+
static validateFile(filePath, language) {
|
|
82
|
+
const ext = extname(filePath);
|
|
83
|
+
const expectedExts = this.EXTENSIONS[language];
|
|
84
|
+
|
|
85
|
+
if (!expectedExts) {
|
|
86
|
+
throw new Error(`Unknown language: ${language}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isValid = expectedExts.some(e => filePath.endsWith(e));
|
|
90
|
+
if (!isValid) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`File extension doesn't match language: expected ${expectedExts.join(' or ')} for ${language}, got ${ext}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create backup of file
|
|
99
|
+
* @param {string} filePath - Path to file
|
|
100
|
+
* @returns {string} - Backup file path
|
|
101
|
+
*/
|
|
102
|
+
static createBackup(filePath) {
|
|
103
|
+
const backupPath = filePath + '.backup';
|
|
104
|
+
if (existsSync(filePath)) {
|
|
105
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
106
|
+
writeFileSync(backupPath, content, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
return backupPath;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Restore file from backup
|
|
113
|
+
* @param {string} filePath - Original file path
|
|
114
|
+
*/
|
|
115
|
+
static restoreBackup(filePath) {
|
|
116
|
+
const backupPath = filePath + '.backup';
|
|
117
|
+
if (existsSync(backupPath)) {
|
|
118
|
+
const content = readFileSync(backupPath, 'utf-8');
|
|
119
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
120
|
+
unlinkSync(backupPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Detect test framework from file content
|
|
126
|
+
* @param {string} content - File content
|
|
127
|
+
* @returns {string|null} - Detected framework or null
|
|
128
|
+
*/
|
|
129
|
+
static detectFramework(content) {
|
|
130
|
+
// Playwright TypeScript patterns
|
|
131
|
+
if (content.includes("from '@playwright/test'") ||
|
|
132
|
+
content.includes('import { test, expect } from')) {
|
|
133
|
+
return 'playwright-typescript';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Playwright Python patterns
|
|
137
|
+
if (content.includes('from playwright.sync_api import') ||
|
|
138
|
+
content.includes('from playwright.async_api import')) {
|
|
139
|
+
return 'playwright-python';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Selenium Python patterns
|
|
143
|
+
if (content.includes('from selenium') ||
|
|
144
|
+
content.includes('import selenium')) {
|
|
145
|
+
return 'selenium-python';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Selenium Java patterns
|
|
149
|
+
if (content.includes('import org.openqa.selenium') ||
|
|
150
|
+
content.includes('import org.junit')) {
|
|
151
|
+
return 'selenium-java';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if file has unmatched braces (for TS/Java)
|
|
159
|
+
* @param {string} content - File content
|
|
160
|
+
* @returns {boolean} - True if unmatched
|
|
161
|
+
*/
|
|
162
|
+
static hasUnmatchedBraces(content) {
|
|
163
|
+
let count = 0;
|
|
164
|
+
for (const char of content) {
|
|
165
|
+
if (char === '{') count++;
|
|
166
|
+
if (char === '}') count--;
|
|
167
|
+
}
|
|
168
|
+
return count !== 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if file is empty or contains only whitespace
|
|
173
|
+
* @param {string} content - File content
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
static isEmpty(content) {
|
|
177
|
+
return content.trim() === '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check if content has any test functions/methods
|
|
182
|
+
* @param {string} content - File content
|
|
183
|
+
* @param {string} language - Language to check for
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
static hasTests(content, language) {
|
|
187
|
+
switch (language) {
|
|
188
|
+
case 'playwright-typescript':
|
|
189
|
+
return /test\(['"`]/.test(content);
|
|
190
|
+
|
|
191
|
+
case 'playwright-python':
|
|
192
|
+
case 'selenium-python':
|
|
193
|
+
return /def test_\w+\s*\(/.test(content);
|
|
194
|
+
|
|
195
|
+
case 'selenium-java':
|
|
196
|
+
return /@Test/.test(content);
|
|
197
|
+
|
|
198
|
+
default:
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -250,4 +250,88 @@ export class PlaywrightPythonGenerator extends CodeGeneratorBase {
|
|
|
250
250
|
|
|
251
251
|
return lines;
|
|
252
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Append test to existing Python file
|
|
256
|
+
*/
|
|
257
|
+
appendTest(existingContent, newTestCode, options = {}) {
|
|
258
|
+
const lines = existingContent.split('\n');
|
|
259
|
+
const insertPosition = options.insertPosition || 'end';
|
|
260
|
+
const referenceTestName = options.referenceTestName;
|
|
261
|
+
|
|
262
|
+
let insertIndex;
|
|
263
|
+
|
|
264
|
+
if (insertPosition === 'end') {
|
|
265
|
+
// Insert at end of file
|
|
266
|
+
insertIndex = lines.length;
|
|
267
|
+
} else if (insertPosition === 'before' || insertPosition === 'after') {
|
|
268
|
+
if (!referenceTestName) {
|
|
269
|
+
throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pythonTestName = this.pythonTestName(referenceTestName);
|
|
273
|
+
insertIndex = this.findPythonTestByName(lines, pythonTestName);
|
|
274
|
+
|
|
275
|
+
if (insertIndex === -1) {
|
|
276
|
+
throw new Error(`Reference test '${referenceTestName}' not found in file`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (insertPosition === 'after') {
|
|
280
|
+
insertIndex = this.findPythonTestEnd(lines, insertIndex);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Insert new test with proper spacing (2 blank lines before test - PEP 8)
|
|
285
|
+
lines.splice(insertIndex, 0, '', '', newTestCode);
|
|
286
|
+
|
|
287
|
+
return lines.join('\n');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Convert test name to Python convention
|
|
292
|
+
*/
|
|
293
|
+
pythonTestName(name) {
|
|
294
|
+
return name
|
|
295
|
+
.replace(/([A-Z])/g, '_$1')
|
|
296
|
+
.replace(/-/g, '_')
|
|
297
|
+
.replace(/ /g, '_')
|
|
298
|
+
.toLowerCase()
|
|
299
|
+
.replace(/^_/, '')
|
|
300
|
+
.replace(/__+/g, '_');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Find Python test function by name
|
|
305
|
+
*/
|
|
306
|
+
findPythonTestByName(lines, testName) {
|
|
307
|
+
const testRegex = new RegExp(`^def test_${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
|
|
308
|
+
return lines.findIndex(line => testRegex.test(line.trim()));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Find end of Python function (next def/class or end of file)
|
|
313
|
+
*/
|
|
314
|
+
findPythonTestEnd(lines, startIndex) {
|
|
315
|
+
// Find next function or class definition at same or lower indentation level
|
|
316
|
+
const startLine = lines[startIndex];
|
|
317
|
+
const startIndent = startLine.match(/^\s*/)[0].length;
|
|
318
|
+
|
|
319
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
320
|
+
const trimmed = lines[i].trim();
|
|
321
|
+
|
|
322
|
+
// Skip empty lines and comments
|
|
323
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const currentIndent = lines[i].match(/^\s*/)[0].length;
|
|
328
|
+
|
|
329
|
+
// If we find a def or class at same or lower indentation, function has ended
|
|
330
|
+
if (currentIndent <= startIndent && (trimmed.startsWith('def ') || trimmed.startsWith('class '))) {
|
|
331
|
+
return i;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return lines.length;
|
|
336
|
+
}
|
|
253
337
|
}
|