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.
@@ -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
  }