commit a492223e45beb3a43abbb153134710fcb3acbf20 Author: seb Date: Thu Jul 17 05:32:02 2025 +0200 Genesis diff --git a/.cursor/rules/projectinfo.mdc b/.cursor/rules/projectinfo.mdc new file mode 100644 index 0000000..5eddd59 --- /dev/null +++ b/.cursor/rules/projectinfo.mdc @@ -0,0 +1,75 @@ +--- +alwaysApply: true +--- +# About This Project + +This project is both USING and IMPLEMENTING the PlayWrong test language. It provides: +- A custom test language parser (`src/parser.js`) +- A test executor using Playwright (`src/executor.js`) +- A CLI interface (`src/cli.js`) +- Test files written in the custom syntax (`.test` files) + +# PlayWrong Test Language - Cheat Sheet + +## Dump Functionality +The `dump` command creates HTML snapshots in `test-results/Chrome//` that can be searched for HTML elements to help develop test flows. These dumps are invaluable for: +- Inspecting page structure at specific test steps +- Finding correct element selectors +- Debugging test failures +- Understanding page state during test execution + +## Commands + +### Browser Control +- `use "Chrome"` - Select browser profile (Chrome, Mobile, MobileSmall, Firefox, Safari, Edge) +- `use "Chrome" ( "Mobile" , "MobileSmall")` - Multi-profile test +- `open "https://example.com"` - Navigate to URL + +### Element Interaction +- `wait element=button childText="Login"` - Wait for element to appear +- `click element=button childText="Login"` - Click element +- `scroll element=div class="content"` - Scroll to element +- `fill element=input name="email" value="user@example.com"` - Fill input field + +### Flow Control +- `sleep 1000` - Wait milliseconds +- `sleep 1000 "loading animation"` - Wait with description +- `break` - Pause execution (press any key to continue) +- `break "waiting for user input"` - Pause with message +- `dump "step_name"` - Take screenshot + +### Element Selectors +- `element=tagname` - HTML tag (div, span, button, input, a, form, etc.) +- `name="fieldName"` - Name attribute +- `id="elementId"` - ID attribute +- `class="className"` - CSS class +- `href="/path"` - Link href +- `type="email"` - Input type +- `childText="Button Text"` - Element containing text +- `child=span(class="badge" childText="1")` - Nested element selector + +### Variables & Comments +- `$VARIABLE` - Environment variable (e.g., `$PASSWORD`, `$PASSWORDMAIL`) +- `/* multi-line comment */` - Block comment +- `# single line comment` - Line comment +- `// alternative line comment` - Alternative line comment + +### Example Complete Flow +```test +use "Chrome" +open "https://example.com" +wait element=input type="email" +fill element=input type="email" value="user@example.com" +wait element=input type="password" +fill element=input type="password" value="$PASSWORD" +click element=button childText="Login" +sleep 2000 "login processing" +dump "logged_in" +``` + +## Tips +- Use descriptive names in sleep messages for better debugging +- Always wait for elements before interacting with them +- Use environment variables for sensitive data like passwords +- Take screenshots (dump) at key steps for debugging +- Comments help document complex test flows \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf3bbf1 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +PASSWORD=this is not a password but it has whitespace \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02acd6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +test-results/ +playwright-report/ +test-results.json +.env +*.log +tests/ \ No newline at end of file diff --git a/.vscode/extensions/README.md b/.vscode/extensions/README.md new file mode 100644 index 0000000..87ef493 --- /dev/null +++ b/.vscode/extensions/README.md @@ -0,0 +1,47 @@ +# PlayWrong VS Code Extension + +This extension provides syntax highlighting for PlayWrong test files (.test). + +## Installation + +1. Copy the `playwrong-syntax` folder to your VS Code extensions directory: + - **Windows**: `%USERPROFILE%\.vscode\extensions\` + - **macOS**: `~/.vscode/extensions/` + - **Linux**: `~/.vscode/extensions/` + +2. Restart VS Code + +3. Open any `.test` file and enjoy syntax highlighting! + +4. (Optional) Enable the PlayWrong theme for better colors: + - Press `Ctrl+Shift+P` → "Preferences: Color Theme" + - Select "PlayWrong Theme" + +## Features + +- **Syntax Highlighting**: Keywords, commands, strings, numbers, and variables +- **Comment Support**: Use `#` for line comments and `/* */` for multi-line comments +- **Auto-completion**: Bracket and quote auto-closing +- **Variable Highlighting**: `$VARIABLE` and `${VARIABLE}` syntax +- **Custom Theme**: Optimized colors for PlayWrong syntax + +## Supported Commands + +- `use`, `open`, `wait`, `click`, `fill`, `scroll`, `sleep`, `dump`, `break` +- Element selectors: `element`, `childText`, `class`, `id`, `name`, `href`, `type`, `value`, `child` +- Environment variables: `$PASSWORD`, `${API_KEY}` + +## Example + +```playwrong +/* +Multi-line comment block +This test demonstrates login functionality +*/ +# This is a line comment +use "Chrome" +open "data:text/html,

Comment Test

" +fill element=input type="password" value="$PASSWORD" +click element=button childText="Login" /* inline comment */ +sleep 2000 "wait for page load" +``` \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/README.md b/.vscode/extensions/playwrong-syntax/README.md new file mode 100644 index 0000000..3cef14a --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/README.md @@ -0,0 +1,64 @@ +# PlayWrong Test Language Extension + +A VSCode extension that provides syntax highlighting and real-time linting for PlayWrong test files (`.test` extension). + +## Features + +### Syntax Highlighting +- Commands: `use`, `open`, `wait`, `click`, `scroll`, `fill`, `break`, `sleep`, `dump` +- Parameters: `element`, `name`, `id`, `class`, `href`, `type`, `childText`, `value` +- Strings with environment variable support (`$VARIABLE`) +- Comments: `//`, `#`, `/* */` +- Numbers and HTML elements + +### Real-time Linting +- **Errors**: Invalid commands, syntax errors, missing parameters +- **Warnings**: Semantic issues, best practice violations +- **Info**: Helpful suggestions and environment variable usage + +### Commands +- **PlayWrong: Lint PlayWrong Test File** - Run linter on current file +- **PlayWrong: Lint PlayWrong Test File (Strict)** - Run linter with warnings as errors + +## Usage + +1. Open any `.test` file in VSCode +2. The extension will automatically: + - Apply syntax highlighting + - Show real-time linting feedback in the Problems panel + - Underline errors, warnings, and info messages in the editor + +3. Right-click in a `.test` file to access linting commands from the context menu + +## Installation + +This extension is automatically loaded when you open this workspace. The `.test` files will be automatically associated with the PlayWrong language. + +## Example + +```test +use "Chrome" +open "https://example.com" +wait element=button childText="Login" +click element=button childText="Login" +fill element=input type="email" value="user@example.com" +sleep 2000 "login processing" +dump "login_page" +``` + +## Linting Rules + +### Errors +- Invalid commands +- Unclosed quotes +- Missing required parameters + +### Warnings +- Unknown HTML elements +- Generic element selectors +- Semantic misuse (e.g., `click` on input fields) + +### Info +- Environment variable usage +- Command placement suggestions +- Best practice tips \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/extension.js b/.vscode/extensions/playwrong-syntax/extension.js new file mode 100644 index 0000000..ead7a4b --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/extension.js @@ -0,0 +1,213 @@ +const vscode = require('vscode'); +const path = require('path'); + +// Import the linter from the main project +const TestLinter = require('../../../src/linter'); + +class PlayWrongDiagnosticProvider { + constructor() { + this.linter = new TestLinter(); + this.diagnosticCollection = vscode.languages.createDiagnosticCollection('playwrong'); + } + + provideDiagnostics(document) { + if (document.languageId !== 'playwrong') { + return; + } + + const text = document.getText(); + const lintResult = this.linter.lint(text, document.fileName); + + const diagnostics = []; + + // Convert errors to diagnostics + lintResult.errors.forEach(error => { + const line = Math.max(0, error.line - 1); // Convert to 0-based + const range = new vscode.Range(line, 0, line, Number.MAX_VALUE); + const diagnostic = new vscode.Diagnostic( + range, + error.message, + vscode.DiagnosticSeverity.Error + ); + diagnostic.source = 'PlayWrong Linter'; + diagnostics.push(diagnostic); + }); + + // Convert warnings to diagnostics + lintResult.warnings.forEach(warning => { + const line = Math.max(0, warning.line - 1); // Convert to 0-based + const range = new vscode.Range(line, 0, line, Number.MAX_VALUE); + const diagnostic = new vscode.Diagnostic( + range, + warning.message, + vscode.DiagnosticSeverity.Warning + ); + diagnostic.source = 'PlayWrong Linter'; + diagnostics.push(diagnostic); + }); + + // Convert info to diagnostics (as information) + lintResult.info.forEach(info => { + const line = Math.max(0, info.line - 1); // Convert to 0-based + const range = new vscode.Range(line, 0, line, Number.MAX_VALUE); + const diagnostic = new vscode.Diagnostic( + range, + info.message, + vscode.DiagnosticSeverity.Information + ); + diagnostic.source = 'PlayWrong Linter'; + diagnostics.push(diagnostic); + }); + + this.diagnosticCollection.set(document.uri, diagnostics); + } + + clear() { + this.diagnosticCollection.clear(); + } + + dispose() { + this.diagnosticCollection.dispose(); + } +} + +class PlayWrongExtension { + constructor() { + this.diagnosticProvider = new PlayWrongDiagnosticProvider(); + } + + activate(context) { + console.log('PlayWrong extension is now active!'); + + // Register diagnostic provider + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + this.diagnosticProvider.provideDiagnostics(activeEditor.document); + } + + // Listen for document changes + const onDidChangeTextDocument = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.languageId === 'playwrong') { + this.diagnosticProvider.provideDiagnostics(event.document); + } + }); + + // Listen for document open + const onDidOpenTextDocument = vscode.workspace.onDidOpenTextDocument(document => { + if (document.languageId === 'playwrong') { + this.diagnosticProvider.provideDiagnostics(document); + } + }); + + // Listen for active editor changes + const onDidChangeActiveTextEditor = vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor && editor.document.languageId === 'playwrong') { + this.diagnosticProvider.provideDiagnostics(editor.document); + } + }); + + // Register commands + const lintCommand = vscode.commands.registerCommand('playwrong.lint', () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'playwrong') { + vscode.window.showWarningMessage('Please open a PlayWrong test file (.test) to lint.'); + return; + } + + this.runLintCommand(editor.document, false); + }); + + const lintStrictCommand = vscode.commands.registerCommand('playwrong.lintStrict', () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'playwrong') { + vscode.window.showWarningMessage('Please open a PlayWrong test file (.test) to lint.'); + return; + } + + this.runLintCommand(editor.document, true); + }); + + // Add disposables to context + context.subscriptions.push( + this.diagnosticProvider, + onDidChangeTextDocument, + onDidOpenTextDocument, + onDidChangeActiveTextEditor, + lintCommand, + lintStrictCommand + ); + + // Initial lint for already open documents + vscode.workspace.textDocuments.forEach(document => { + if (document.languageId === 'playwrong') { + this.diagnosticProvider.provideDiagnostics(document); + } + }); + } + + runLintCommand(document, strict) { + const text = document.getText(); + const lintResult = this.diagnosticProvider.linter.lint(text, document.fileName); + + let message = ''; + let hasIssues = false; + + if (lintResult.errors.length > 0) { + message += `❌ ${lintResult.errors.length} error(s)`; + hasIssues = true; + } + + if (lintResult.warnings.length > 0) { + if (message) message += ', '; + message += `⚠️ ${lintResult.warnings.length} warning(s)`; + if (strict) hasIssues = true; + } + + if (lintResult.info.length > 0) { + if (message) message += ', '; + message += `💡 ${lintResult.info.length} info`; + } + + if (!message) { + message = '✅ No issues found'; + } + + const fileName = path.basename(document.fileName); + const fullMessage = `${fileName}: ${message}`; + + if (hasIssues) { + if (lintResult.errors.length > 0) { + vscode.window.showErrorMessage(fullMessage); + } else { + vscode.window.showWarningMessage(fullMessage); + } + } else { + vscode.window.showInformationMessage(fullMessage); + } + + // Update diagnostics + this.diagnosticProvider.provideDiagnostics(document); + } + + deactivate() { + this.diagnosticProvider.clear(); + } +} + +let extension; + +function activate(context) { + extension = new PlayWrongExtension(); + extension.activate(context); +} + +function deactivate() { + if (extension) { + extension.deactivate(); + } +} + +module.exports = { + activate, + deactivate +}; \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/language-configuration.json b/.vscode/extensions/playwrong-syntax/language-configuration.json new file mode 100644 index 0000000..c011762 --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/language-configuration.json @@ -0,0 +1,24 @@ +{ + "comments": { + "lineComment": "#", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["\"", "\""] + ], + "autoClosingPairs": [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["\"", "\""] + ], + "surroundingPairs": [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["\"", "\""] + ] +} \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/package-lock.json b/.vscode/extensions/playwrong-syntax/package-lock.json new file mode 100644 index 0000000..8f94086 --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/package-lock.json @@ -0,0 +1,7 @@ +{ + "name": "playwrong-syntax", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": {} +} \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/package.json b/.vscode/extensions/playwrong-syntax/package.json new file mode 100644 index 0000000..5a212f6 --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/package.json @@ -0,0 +1,67 @@ +{ + "name": "playwrong-syntax", + "displayName": "PlayWrong Test Language", + "description": "Syntax highlighting and linting for PlayWrong test files", + "version": "1.0.0", + "engines": { + "vscode": "^1.74.0" + }, + "categories": [ + "Programming Languages", + "Linters" + ], + "main": "./extension.js", + "activationEvents": [ + "onLanguage:playwrong" + ], + "contributes": { + "languages": [ + { + "id": "playwrong", + "aliases": ["PlayWrong", "playwrong"], + "extensions": [".test"], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "playwrong", + "scopeName": "source.playwrong", + "path": "./syntaxes/playwrong.tmLanguage.json" + } + ], + "themes": [ + { + "label": "PlayWrong Theme", + "uiTheme": "vs-dark", + "path": "./themes/playwrong-theme.json" + } + ], + "commands": [ + { + "command": "playwrong.lint", + "title": "Lint PlayWrong Test File", + "category": "PlayWrong" + }, + { + "command": "playwrong.lintStrict", + "title": "Lint PlayWrong Test File (Strict)", + "category": "PlayWrong" + } + ], + "menus": { + "editor/context": [ + { + "when": "resourceLangId == playwrong", + "command": "playwrong.lint", + "group": "navigation" + }, + { + "when": "resourceLangId == playwrong", + "command": "playwrong.lintStrict", + "group": "navigation" + } + ] + } + } +} \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json b/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json new file mode 100644 index 0000000..89355d4 --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "PlayWrong", + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#strings" + }, + { + "include": "#commands" + }, + { + "include": "#numbers" + }, + { + "include": "#variables" + } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.line.number-sign.playwrong", + "begin": "#", + "end": "$", + "captures": { + "0": { + "name": "punctuation.definition.comment.playwrong" + } + }, + "patterns": [ + { + "name": "comment.line.number-sign.playwrong", + "match": "." + } + ] + }, + { + "name": "comment.block.playwrong", + "begin": "/\\*", + "end": "\\*/", + "captures": { + "0": { + "name": "punctuation.definition.comment.playwrong" + } + }, + "patterns": [ + { + "name": "comment.block.playwrong", + "match": "." + } + ] + } + ] + }, + "commands": { + "patterns": [ + { + "name": "keyword.control.playwrong", + "match": "\\b(use|open|wait|click|fill|scroll|sleep|dump|break)\\b" + }, + { + "name": "entity.name.function.playwrong", + "match": "\\b(element|childText|class|id|name|href|type|value|child)\\b" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.playwrong", + "begin": "\"", + "end": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.playwrong" + } + }, + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.playwrong" + } + }, + "patterns": [ + { + "name": "constant.character.escape.playwrong", + "match": "\\\\." + }, + { + "include": "#variables" + } + ] + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.playwrong", + "match": "\\b\\d+\\b" + } + ] + }, + "variables": { + "patterns": [ + { + "name": "variable.other.playwrong", + "match": "\\$[A-Z_][A-Z0-9_]*" + }, + { + "name": "variable.other.playwrong", + "match": "\\$\\{[A-Z_][A-Z0-9_]*\\}" + } + ] + } + }, + "scopeName": "source.playwrong" +} \ No newline at end of file diff --git a/.vscode/extensions/playwrong-syntax/themes/playwrong-theme.json b/.vscode/extensions/playwrong-syntax/themes/playwrong-theme.json new file mode 100644 index 0000000..7756905 --- /dev/null +++ b/.vscode/extensions/playwrong-syntax/themes/playwrong-theme.json @@ -0,0 +1,75 @@ +{ + "name": "PlayWrong Theme", + "type": "dark", + "colors": {}, + "tokenColors": [ + { + "name": "PlayWrong Comments", + "scope": [ + "comment.line.number-sign.playwrong", + "comment.block.playwrong", + "punctuation.definition.comment.playwrong" + ], + "settings": { + "foreground": "#6A9955", + "fontStyle": "italic" + } + }, + { + "name": "PlayWrong Strings", + "scope": [ + "string.quoted.double.playwrong" + ], + "settings": { + "foreground": "#CE9178" + } + }, + { + "name": "PlayWrong String Punctuation", + "scope": [ + "punctuation.definition.string.begin.playwrong", + "punctuation.definition.string.end.playwrong" + ], + "settings": { + "foreground": "#CE9178" + } + }, + { + "name": "PlayWrong Commands", + "scope": [ + "keyword.control.playwrong" + ], + "settings": { + "foreground": "#569CD6", + "fontStyle": "bold" + } + }, + { + "name": "PlayWrong Parameters", + "scope": [ + "entity.name.function.playwrong" + ], + "settings": { + "foreground": "#DCDCAA" + } + }, + { + "name": "PlayWrong Variables", + "scope": [ + "variable.other.playwrong" + ], + "settings": { + "foreground": "#9CDCFE" + } + }, + { + "name": "PlayWrong Numbers", + "scope": [ + "constant.numeric.playwrong" + ], + "settings": { + "foreground": "#B5CEA8" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..84f4992 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Test Language CLI Headed", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/src/cli.js", + "args": ["step1.test", "Chrome", "--headed", "--screenshot-none"], + "console": "integratedTerminal", + "skipFiles": ["/**"] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..972465b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.test": "playwrong" + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..efeaa6e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,73 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "npm install", + "type": "shell", + "command": "npm", + "args": ["install"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "npm run test", + "type": "shell", + "command": "npm", + "args": ["run", "test"], + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "npm run test:headed", + "type": "shell", + "command": "npm", + "args": ["run", "test:headed"], + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "install browsers", + "type": "shell", + "command": "npm", + "args": ["run", "install-browsers"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + }, + { + "label": "install and run", + "dependsOrder": "sequence", + "dependsOn": ["npm install", "npm run test"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + } + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..868f888 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Custom Test Language with Playwright + +A simple test language that uses Playwright to automate browser testing with custom syntax. + +## Installation + +```bash +npm install +npm run install-browsers +``` + +## Usage + +Run a test file: +```bash +npm test tests/example.test +``` + +Run with a specific profile: +```bash +node src/cli.js tests/example.test Chrome +``` + +Run in headed mode (show browser): +```bash +node src/cli.js tests/example.test Chrome + +npm run test:headed +``` + +Run in headless mode (default): +```bash +node src/cli.js tests/example.test Chrome --headless +``` + +## Test Language Syntax + +### Profiles +- `Chrome` - Desktop Chrome (1280x720) +- `Mobile` - Tablet portrait (768x1024) +- `MobileSmall` - Phone portrait (390x844) + +### Commands + +#### use +Define which profiles to run the test on: +``` +use "Chrome" ( "Mobile" , "MobileSmall") +use "Chrome" +``` + +#### open +Navigate to a URL: +``` +open "https://example.com" +``` + +#### dump +Create a dump folder with context, console, DOM, and screenshot: +``` +dump "step 1" +``` + +#### wait +Wait for an element to appear: +``` +wait element=div childText=Login +wait element=div childText="Multi word text" +wait element=input name=firstName +wait element=button id=submit +wait element=button(child=span class="MuiBadge-badge" childText="1") +``` + +#### click +Click on an element: +``` +click element=div childText=Login +click element=button childText="In den Korb" +click element=button id=submit +click element=button(child=span class="MuiBadge-badge" childText="1") +``` + +#### fill +Fill an input field: +``` +fill element=input name=firstName value=John +fill element=input id=email value=john@example.com +``` + +#### break +Pause execution and wait for user key press: +``` +break +break "Check if the form is filled correctly" +break "Verify the page loaded properly" +``` + +#### sleep +Pause execution for a specified number of milliseconds: +``` +sleep 1000 +sleep 2500 "waiting for animation" +sleep 500 "let page settle" +``` + +### Element Selectors + +You can combine multiple attributes: +- `element=div` - Element type +- `childText=Login` - Element containing text +- `childText="Multi word text"` - Element containing text with spaces (use quotes) +- `name=firstName` - Element with name attribute +- `id=submit` - Element with id attribute +- `class=button` - Element with class attribute +- `class="MuiBadge-badge"` - Element with class containing spaces/special chars (use quotes) +- `href=/path/to/page` - Element with href attribute (for links) + +### Child Element Syntax (NEW) +Target elements that contain specific child elements: +- `element=button(child=span childText="1")` - Button containing span with text "1" +- `element=div(child=a href="/home")` - Div containing link to "/home" +- `element=form(child=input name="email")` - Form containing input with name "email" +- `element=nav(child=button class="menu-toggle")` - Nav containing button with class "menu-toggle" + +### Legacy Child Syntax (Still Supported) +- `child=span class="MuiBadge-badge"` - Find child element with specific attributes (legacy) + +## Output + +Test results are saved in the `test-results` directory, organized by profile name. Each dump creates a subfolder containing: + +- `context.json` - Page context (URL, title, timestamp, viewport) +- `console.json` - Console messages +- `dom.html` - Beautified DOM structure +- `screenshot.png` - Full page screenshot + +## Example Test File + +``` +// Example test +use "Chrome" ( "Mobile" , "MobileSmall") +open "https://example.com" +dump "initial load" +sleep 1000 "let page settle" +wait element=div childText=Example +click element=div childText=Example +sleep 500 "wait for click animation" +dump "after click" +wait element=input name=search +fill element=input name=search value=test +sleep 300 "wait for input to register" +dump "form filled" +break "Final check before ending" +``` + +## Running Modes + +- **Headless Mode** (default): Browser runs in background, faster execution +- **Headed Mode**: Browser window is visible, great for debugging and watching tests run +- **Break Command**: Pauses execution for manual inspection when using headed mode \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..552fd7d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,779 @@ +{ + "name": "test-language", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-language", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "dotenv": "^17.2.0", + "fs-extra": "^11.1.1", + "js-beautify": "^1.14.11", + "path": "^0.12.7", + "playwright": "^1.40.0" + }, + "devDependencies": { + "@types/node": "^20.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "20.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.8.tgz", + "integrity": "sha512-HzbgCY53T6bfu4tT7Aq3TvViJyHjLjPNaAS3HOuMc9pw97KHsUtXNX4L+wu59g1WnjsZSko35MbEqnO58rihhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dotenv": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", + "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1744935 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "test-language", + "version": "1.0.0", + "description": "Custom test language using Playwright", + "main": "src/index.js", + "scripts": { + "test": "node src/cli.js step1.test Chrome --headed --screenshot-none", + "install-browsers": "playwright install chromium", + "lint": "node src/linter-cli.js step1.test", + "lint:strict": "node src/linter-cli.js --strict step1.test", + "lint:verbose": "node src/linter-cli.js --verbose step1.test", + "lint:all": "node src/linter-cli.js step1.test" + }, + "dependencies": { + "dotenv": "^17.2.0", + "fs-extra": "^11.1.1", + "js-beautify": "^1.14.11", + "path": "^0.12.7", + "playwright": "^1.40.0" + }, + "devDependencies": { + "@types/node": "^20.0.0" + }, + "keywords": [ + "playwright", + "testing", + "automation" + ], + "author": "", + "license": "MIT" +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..49efab4 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,36 @@ +const { devices } = require('playwright'); + +module.exports = { + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + trace: 'on-first-retry', + }, + projects: [ + { + name: 'Chrome', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1080 } + }, + }, + { + name: 'Mobile', + use: { + ...devices['iPad'], + viewport: { width: 768, height: 1024 } + }, + }, + { + name: 'MobileSmall', + use: { + ...devices['iPhone 12'], + viewport: { width: 390, height: 844 } + }, + }, + ], +}; \ No newline at end of file diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..6e608f2 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +const fs = require('fs-extra'); +const path = require('path'); +const TestParser = require('./parser'); +const TestExecutor = require('./executor'); +const TestLinter = require('./linter'); + +class TestRunner { + constructor(options = {}) { + this.parser = new TestParser(); + this.executor = new TestExecutor(options); + this.linter = new TestLinter(); + this.options = options; + } + + async runTestFile(filePath, profile = 'Chrome') { + try { + console.log(`Running test file: ${filePath} with profile: ${profile}`); + console.log(`Mode: ${this.executor.headless ? 'Headless' : 'Headed'}`); + + // Read test file + const content = await fs.readFile(filePath, 'utf8'); + + // Optional linting + if (this.options.lint) { + console.log('🔍 Linting test file...'); + const lintResult = this.linter.lint(content, filePath); + + if (lintResult.hasErrors) { + console.log('❌ Linting failed with errors:'); + lintResult.errors.forEach(error => { + console.log(` Line ${error.line}: ${error.message}`); + }); + process.exit(1); + } + + if (lintResult.hasWarnings) { + console.log('⚠️ Linting warnings:'); + lintResult.warnings.forEach(warning => { + console.log(` Line ${warning.line}: ${warning.message}`); + }); + + if (this.options.strict) { + console.log('❌ Strict mode: treating warnings as errors'); + process.exit(1); + } + } + + console.log('✅ Linting passed'); + } + + // Parse commands + const commands = this.parser.parse(content); + console.log(`Parsed ${commands.length} commands`); + + // Find use command to determine profiles + const useCommand = commands.find(cmd => cmd.type === 'use'); + const profilesToRun = useCommand ? useCommand.profiles : [profile]; + + // Run tests for each profile + for (const profileName of profilesToRun) { + console.log(`\n=== Running with profile: ${profileName} ===`); + + // Filter out the use command for execution + const execCommands = commands.filter(cmd => cmd.type !== 'use'); + + // Set output directory for this profile + this.executor.outputDir = path.join('test-results', profileName); + + await this.executor.execute(execCommands, profileName); + console.log(`✓ Completed test run for profile: ${profileName}`); + } + + console.log('\n✅ All tests completed successfully!'); + + } catch (error) { + console.error('❌ Test execution failed:', error.message); + process.exit(1); + } + } +} + +// CLI handling +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage: node src/cli.js [profile] [options]'); + console.log('Profiles: Chrome, Mobile, MobileSmall'); + console.log('Options:'); + console.log(' --headed Run in headed mode (show browser)'); + console.log(' --headless Run in headless mode (default)'); + console.log(' --enable-animations Enable CSS animations (default: disabled)'); + console.log(' --lint Run linter before execution'); + console.log(' --strict Treat linter warnings as errors'); + console.log('Examples:'); + console.log(' node src/cli.js tests/example.test Chrome'); + console.log(' node src/cli.js tests/example.test Chrome --headed'); + console.log(' node src/cli.js tests/example.test Chrome --headed --enable-animations'); + console.log(' node src/cli.js tests/example.test Chrome --lint --strict'); + process.exit(1); + } + + const testFile = args[0]; + const profile = args[1] || 'Chrome'; + const headless = !args.includes('--headed'); + const disableAnimations = !args.includes('--enable-animations'); + const lint = args.includes('--lint'); + const strict = args.includes('--strict'); + + if (!await fs.pathExists(testFile)) { + console.error(`Test file not found: ${testFile}`); + process.exit(1); + } + + const runner = new TestRunner({ headless, disableAnimations, lint, strict }); + await runner.runTestFile(testFile, profile); +} + +// Run if called directly +if (require.main === module) { + main().catch(console.error); +} + +module.exports = TestRunner; \ No newline at end of file diff --git a/src/executor.js b/src/executor.js new file mode 100644 index 0000000..eae8fc1 --- /dev/null +++ b/src/executor.js @@ -0,0 +1,998 @@ +const { chromium } = require('playwright'); +const fs = require('fs-extra'); +const path = require('path'); +const { js: beautify } = require('js-beautify'); +const readline = require('readline'); +require('dotenv').config(); + +class TestExecutor { + constructor(options = {}) { + this.browser = null; + this.context = null; + this.page = null; + this.outputDir = 'test-results'; + this.headless = options.headless !== false; // Default to headless + this.disableAnimations = options.disableAnimations !== false; // Default to disable animations + this.fullPageScreenshots = options.fullPageScreenshots || false; // Default to viewport screenshots + this.enableScreenshots = options.enableScreenshots !== false; // Default to enable screenshots + this.profiles = { + Chrome: { + viewport: { width: 1280, height: 720 }, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }, + Mobile: { + viewport: { width: 768, height: 1024 }, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1' + }, + MobileSmall: { + viewport: { width: 390, height: 844 }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1' + } + }; + } + + async execute(commands, profileName = 'Chrome') { + try { + await this.setup(profileName); + + for (const command of commands) { + await this.executeCommand(command); + } + + } catch (error) { + console.error('Test execution failed:', error); + throw error; + } finally { + await this.cleanup(); + } + } + + async setup(profileName) { + const profile = this.profiles[profileName]; + if (!profile) { + throw new Error(`Unknown profile: ${profileName}`); + } + + // Store current profile for viewport consistency + this.currentProfile = profile; + + this.browser = await chromium.launch({ + headless: this.headless, + args: this.headless ? [] : [ + `--window-size=${profile.viewport.width},${profile.viewport.height + 100}`, // Add space for browser chrome + '--disable-web-security', + '--disable-features=VizDisplayCompositor', + '--disable-infobars', + '--disable-extensions' + ] + }); + + this.context = await this.browser.newContext({ + viewport: profile.viewport, + userAgent: profile.userAgent, + // Disable animations to reduce flickering + reducedMotion: 'reduce', + // Force consistent viewport + screen: profile.viewport, + deviceScaleFactor: 1 + }); + + this.page = await this.context.newPage(); + + // Ensure consistent viewport size + await this.page.setViewportSize(profile.viewport); + + // Add CSS to reduce visual flickering and fix viewport + if (!this.headless) { + const viewportCSS = ` + html { + width: ${profile.viewport.width}px !important; + height: ${profile.viewport.height}px !important; + max-width: ${profile.viewport.width}px !important; + min-width: ${profile.viewport.width}px !important; + overflow-x: hidden !important; + overflow-y: auto !important; + } + + body { + width: ${profile.viewport.width}px !important; + max-width: ${profile.viewport.width}px !important; + min-width: ${profile.viewport.width}px !important; + margin: 0 !important; + padding: 0 !important; + overflow-x: hidden !important; + } + + * { + box-sizing: border-box !important; + } + `; + + const animationCSS = this.disableAnimations ? ` + *, *::before, *::after { + transition-duration: 0s !important; + transition-delay: 0s !important; + animation-duration: 0s !important; + animation-delay: 0s !important; + animation-fill-mode: none !important; + } + ` : ''; + + await this.page.addStyleTag({ + content: viewportCSS + animationCSS + }); + } + + // Capture console messages + this.consoleMessages = []; + this.page.on('console', msg => { + this.consoleMessages.push({ + type: msg.type(), + text: msg.text(), + timestamp: new Date().toISOString() + }); + }); + + // Ensure output directory exists + await fs.ensureDir(this.outputDir); + } + + async executeCommand(command) { + // Create a clean one-line representation of the command + const commandStr = this.formatCommandForOutput(command); + console.log(`Executing: ${commandStr}`); + + switch (command.type) { + case 'use': + // This is handled at the executor level, not per command + break; + + case 'open': + await this.page.goto(command.url, { waitUntil: 'networkidle' }); + // Small delay to ensure page is stable + if (!this.headless) { + await this.page.waitForTimeout(200); + await this.ensureViewportSize(); + } + break; + + case 'dump': + await this.createDump(command.name); + // Ensure viewport stays consistent after dump operations + if (!this.headless) { + await this.page.waitForTimeout(100); + await this.ensureViewportSize(); + } + break; + + case 'wait': + await this.waitForElement(command); + if (!this.headless) { + await this.ensureViewportSize(); + } + break; + + case 'click': + await this.clickElement(command); + // Ensure viewport stays consistent after click + if (!this.headless) { + await this.page.waitForTimeout(100); + await this.ensureViewportSize(); + } + break; + + case 'scroll': + await this.scrollToElement(command); + if (!this.headless) { + await this.ensureViewportSize(); + } + break; + + case 'fill': + await this.fillElement(command); + if (!this.headless) { + await this.ensureViewportSize(); + } + break; + + case 'break': + await this.waitForUserInput(command); + break; + + case 'sleep': + await this.sleep(command); + break; + + default: + console.warn(`Unknown command type: ${command.type}`); + } + } + + async waitForElement(command) { + const selector = this.buildSelector(command); + await this.page.waitForSelector(selector, { timeout: 30000 }); + } + + async clickElement(command) { + const selector = this.buildSelector(command); + + // Add visual feedback for headed mode + if (!this.headless) { + // Get element position for animation + const element = await this.page.locator(selector).first(); + const box = await element.boundingBox(); + + if (box) { + // Inject CSS for click animation + await this.page.addStyleTag({ + content: ` + .click-animation { + position: fixed; + pointer-events: none; + border: 6px solid #ff0000; + border-radius: 50%; + background: rgba(255, 0, 0, 0.3); + z-index: 99999; + animation: clickPulse 2s ease-out; + box-shadow: 0 0 20px rgba(255, 0, 0, 0.8); + } + + @keyframes clickPulse { + 0% { + transform: scale(0.3); + opacity: 1; + border-width: 8px; + } + 25% { + transform: scale(1.5); + opacity: 0.9; + border-width: 6px; + } + 50% { + transform: scale(2.2); + opacity: 0.7; + border-width: 4px; + } + 75% { + transform: scale(2.8); + opacity: 0.4; + border-width: 2px; + } + 100% { + transform: scale(3.5); + opacity: 0; + border-width: 1px; + } + } + ` + }); + + // Calculate center position of the element + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + // Create and show click animation + await this.page.evaluate(({ x, y }) => { + const indicator = document.createElement('div'); + indicator.className = 'click-animation'; + indicator.style.left = (x - 40) + 'px'; + indicator.style.top = (y - 40) + 'px'; + indicator.style.width = '80px'; + indicator.style.height = '80px'; + + document.body.appendChild(indicator); + + // Remove after animation completes + setTimeout(() => { + if (indicator.parentNode) { + indicator.parentNode.removeChild(indicator); + } + }, 2000); + }, { x: centerX, y: centerY }); + + // Small delay to show the animation before clicking + await this.page.waitForTimeout(300); + } + } + + await this.page.click(selector); + } + + async scrollToElement(command) { + const selector = this.buildSelector(command); + + // Add visual scroll animation in headed mode + if (!this.headless) { + // Add CSS for scroll arrow animation + await this.page.addStyleTag({ + content: ` + .scroll-arrow { + position: fixed; + right: 50px; + top: 50%; + transform: translateY(-50%); + font-size: 60px; + color: #2196F3; + z-index: 999999; + pointer-events: none; + animation: scrollBounce 1s ease-in-out infinite; + } + + @keyframes scrollBounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(-50%); + } + 40% { + transform: translateY(-40%); + } + 60% { + transform: translateY(-60%); + } + } + ` + }); + + // Create scroll arrow indicator + await this.page.evaluate(() => { + const arrow = document.createElement('div'); + arrow.className = 'scroll-arrow'; + arrow.innerHTML = '⬇️'; + arrow.id = 'scroll-indicator'; + document.body.appendChild(arrow); + + // Remove arrow after animation + setTimeout(() => { + const element = document.getElementById('scroll-indicator'); + if (element) { + element.remove(); + } + }, 1500); + }); + + // Wait for animation to be visible + await this.page.waitForTimeout(500); + } + + await this.page.locator(selector).scrollIntoViewIfNeeded(); + } + + async fillElement(command) { + const selector = this.buildSelector(command); + let value = command.value || ''; + + // Resolve environment variables in the value + value = this.resolveEnvironmentVariables(value); + + // Clear the field first + await this.page.fill(selector, ''); + + // Focus on the input field + await this.page.focus(selector); + + // Add CSS for typing animation overlay if not already added + await this.page.addStyleTag({ + content: ` + /* Flying letter overlay container */ + #typing-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 999999; + overflow: hidden; + } + + /* Flying letter animation */ + .flying-letter { + position: absolute; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + color: #2196F3; + pointer-events: none; + animation: flyAndShrink 0.6s ease-out forwards; + } + + @keyframes flyAndShrink { + 0% { + transform: scale(3) translateY(-20px); + opacity: 0.8; + } + 30% { + transform: scale(2.2) translateY(-10px); + opacity: 0.9; + } + 60% { + transform: scale(1.5) translateY(-5px); + opacity: 1; + } + 100% { + transform: scale(1) translateY(0); + opacity: 0; + } + } + + /* Input field highlight during typing */ + .typing-active { + box-shadow: 0 0 10px rgba(33, 150, 243, 0.3) !important; + border-color: #2196F3 !important; + transition: all 0.1s ease !important; + } + + /* Hide actual text in input field during typing animation */ + .typing-hidden { + color: transparent !important; + text-shadow: none !important; + -webkit-text-fill-color: transparent !important; + } + + /* Show placeholder-like dots for password fields */ + .typing-password-mask { + background-image: repeating-linear-gradient( + 90deg, + transparent, + transparent 0.5ch, + #666 0.5ch, + #666 1ch + ) !important; + background-size: 1ch 1em !important; + background-repeat: repeat-x !important; + background-position: 0 50% !important; + } + ` + }); + + // Create overlay container for flying letters + await this.page.evaluate(() => { + if (!document.getElementById('typing-overlay')) { + const overlay = document.createElement('div'); + overlay.id = 'typing-overlay'; + document.body.appendChild(overlay); + } + }); + + // Add typing highlight and hide text only for password fields + const isPasswordField = command.htmlType === 'password'; + await this.page.evaluate(({ sel, isPassword }) => { + const element = document.querySelector(sel); + if (element) { + element.classList.add('typing-active'); + if (isPassword) { + element.classList.add('typing-hidden'); + element.classList.add('typing-password-mask'); + } + } + }, { sel: selector, isPassword: isPasswordField }); + + // Type each character with human-like timing and flying animation + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + // Human-like typing speed variation (80-200ms between keystrokes) + const typingDelay = Math.random() * 120 + 80; + + // Create flying letter animation + await this.page.evaluate(({ sel, character, isPassword, currentIndex, totalLength }) => { + const element = document.querySelector(sel); + const overlay = document.getElementById('typing-overlay'); + + if (element && overlay) { + // Get input field position + const rect = element.getBoundingClientRect(); + const inputCenterX = rect.left + rect.width / 2; + const inputCenterY = rect.top + rect.height / 2; + + // Create flying letter + const flyingLetter = document.createElement('span'); + // Show actual character for non-password fields, bullet for passwords + flyingLetter.textContent = isPassword ? '•' : character; + flyingLetter.className = 'flying-letter'; + + // Position at input field center + flyingLetter.style.left = inputCenterX + 'px'; + flyingLetter.style.top = inputCenterY + 'px'; + + // Copy font styles from input + const computedStyle = window.getComputedStyle(element); + flyingLetter.style.fontFamily = computedStyle.fontFamily; + flyingLetter.style.fontSize = computedStyle.fontSize; + flyingLetter.style.fontWeight = computedStyle.fontWeight; + + overlay.appendChild(flyingLetter); + + // Remove flying letter after animation completes + setTimeout(() => { + if (flyingLetter.parentNode) { + flyingLetter.parentNode.removeChild(flyingLetter); + } + }, 600); + } + }, { + sel: selector, + character: char, + isPassword: isPasswordField, + currentIndex: i, + totalLength: value.length + }); + + // Type the character + await this.page.type(selector, char, { delay: 0 }); + + // Wait for the typing delay + await this.page.waitForTimeout(typingDelay); + } + + // Remove typing highlight and restore text visibility + await this.page.evaluate(({ sel }) => { + const element = document.querySelector(sel); + if (element) { + element.classList.remove('typing-active'); + element.classList.remove('typing-hidden'); + element.classList.remove('typing-password-mask'); + } + }, { sel: selector }); + + // Final validation that the value was set correctly + const actualValue = await this.page.inputValue(selector); + if (actualValue !== value) { + console.warn(`Warning: Expected value "${value}" but got "${actualValue}"`); + } + } + + async waitForUserInput(command) { + console.log(`🔶 BREAK: ${command.message}`); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise((resolve) => { + rl.question('', () => { + rl.close(); + resolve(); + }); + }); + } + + async sleep(command) { + const sleepMsg = command.message ? + `💤 SLEEP: ${command.message} (${command.milliseconds}ms)` : + `💤 SLEEP: ${command.milliseconds}ms`; + console.log(sleepMsg); + + // Add visual sleep animation with countdown in headed mode + if (!this.headless) { + await this.page.addStyleTag({ + content: ` + .sleep-indicator { + position: fixed; + top: 50px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 20px 40px; + border-radius: 10px; + font-size: 24px; + font-family: Arial, sans-serif; + z-index: 999999; + pointer-events: none; + text-align: center; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + } + + .sleep-icon { + font-size: 40px; + animation: sleepPulse 2s ease-in-out infinite; + display: block; + margin-bottom: 10px; + } + + @keyframes sleepPulse { + 0%, 100% { + opacity: 0.6; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.1); + } + } + + .countdown { + font-size: 20px; + font-weight: bold; + color: #4CAF50; + } + ` + }); + + // Create sleep indicator with countdown + await this.page.evaluate(({ duration, message }) => { + const indicator = document.createElement('div'); + indicator.className = 'sleep-indicator'; + indicator.id = 'sleep-indicator'; + indicator.innerHTML = ` +
💤
+
SLEEP: ${message || 'waiting'}
+
${Math.ceil(duration / 1000)}s
+ `; + document.body.appendChild(indicator); + + // Update countdown every second + let remaining = Math.ceil(duration / 1000); + const countdownEl = document.getElementById('countdown'); + + const interval = setInterval(() => { + remaining--; + if (countdownEl) { + countdownEl.textContent = remaining + 's'; + } + if (remaining <= 0) { + clearInterval(interval); + const element = document.getElementById('sleep-indicator'); + if (element) { + element.remove(); + } + } + }, 1000); + + }, { duration: command.milliseconds, message: command.message }); + } + + return new Promise((resolve) => { + setTimeout(resolve, command.milliseconds); + }); + } + + formatCommandForOutput(command) { + switch (command.type) { + case 'use': + return `use "${command.profiles.join('" ( "')}"`; + case 'open': + return `open "${command.url}"`; + case 'dump': + return `dump "${command.name}"`; + case 'wait': + return `wait ${this.formatSelector(command)}`; + case 'click': + return `click ${this.formatSelector(command)}`; + case 'scroll': + return `scroll ${this.formatSelector(command)}`; + case 'fill': + // Mask password values for security + const isPasswordField = command.htmlType === 'password'; + const displayValue = isPasswordField ? '*'.repeat(8) : command.value; + return `fill ${this.formatSelector(command)} value="${displayValue}"`; + case 'break': + return `break "${command.message}"`; + case 'sleep': + return command.message ? `sleep ${command.milliseconds} "${command.message}"` : `sleep ${command.milliseconds}`; + default: + return `${command.type} ${JSON.stringify(command)}`; + } + } + + formatSelector(command) { + let selector = `element=${command.element || 'div'}`; + + if (command.childText) { + selector += ` childText="${command.childText}"`; + } + + if (command.name) { + selector += ` name="${command.name}"`; + } + + if (command.id) { + selector += ` id="${command.id}"`; + } + + if (command.class) { + selector += ` class="${command.class}"`; + } + + if (command.href) { + selector += ` href="${command.href}"`; + } + + if (command.child) { + // Handle new parentheses syntax + if (command.child.startsWith('child=')) { + selector += `(${command.child})`; + } else { + selector += `(child=${command.child})`; + } + } + + return selector; + } + + async ensureViewportSize() { + if (!this.currentProfile) return; + + // Get the current profile viewport + const currentViewport = await this.page.viewportSize(); + const targetViewport = this.currentProfile.viewport; + + // Only reset if viewport has changed + if (currentViewport.width !== targetViewport.width || + currentViewport.height !== targetViewport.height) { + console.log(`🔧 Viewport changed from ${currentViewport.width}x${currentViewport.height} to ${targetViewport.width}x${targetViewport.height}, resetting...`); + await this.page.setViewportSize(targetViewport); + + // Re-apply viewport CSS after reset + await this.page.addStyleTag({ + content: ` + html { + width: ${targetViewport.width}px !important; + height: ${targetViewport.height}px !important; + max-width: ${targetViewport.width}px !important; + min-width: ${targetViewport.width}px !important; + } + + body { + width: ${targetViewport.width}px !important; + max-width: ${targetViewport.width}px !important; + min-width: ${targetViewport.width}px !important; + } + ` + }); + } + } + + buildSelector(params) { + let selector = params.element || 'div'; + + if (params.name) { + selector += `[name="${params.name}"]`; + } + + if (params.id) { + selector += `#${params.id}`; + } + + if (params.class) { + selector += `.${params.class.replace(/\s+/g, '.')}`; + } + + if (params.href) { + selector += `[href="${params.href}"]`; + } + + if (params.htmlType) { + selector += `[type="${params.htmlType}"]`; + } + + // Handle child selectors (nested elements) + if (params.child) { + // Parse child selector like "span class="MuiBadge-badge"" + const childSelector = this.parseChildSelector(params.child); + selector += ` ${childSelector}`; + } + + if (params.childText) { + selector += `:has-text("${params.childText}")`; + } + + return selector; + } + + parseChildSelector(childString) { + // Parse child selector - handles both new and legacy syntax + let selector = ''; + + // For new syntax: "child=span class="MuiBadge-badge" childText="1"" + // For legacy syntax: "child=span class="MuiBadge-badge" childText="1"" + + // Extract element name from child=elementName + const elementMatch = childString.match(/child=(\w+)/); + if (elementMatch) { + selector = elementMatch[1]; + } + + // Extract class attribute + const classMatch = childString.match(/class=(?:"([^"]*)"|([^\s]+))/); + if (classMatch) { + const className = classMatch[1] || classMatch[2]; + selector += `.${className.replace(/\s+/g, '.')}`; + } + + // Extract id attribute + const idMatch = childString.match(/id=(?:"([^"]*)"|([^\s]+))/); + if (idMatch) { + const id = idMatch[1] || idMatch[2]; + selector += `#${id}`; + } + + // Extract name attribute + const nameMatch = childString.match(/name=(?:"([^"]*)"|([^\s]+))/); + if (nameMatch) { + const name = nameMatch[1] || nameMatch[2]; + selector += `[name="${name}"]`; + } + + // Extract href attribute + const hrefMatch = childString.match(/href=(?:"([^"]*)"|([^\s]+))/); + if (hrefMatch) { + const href = hrefMatch[1] || hrefMatch[2]; + selector += `[href="${href}"]`; + } + + // Extract childText for the child element + const childTextMatch = childString.match(/childText=(?:"([^"]*)"|([^\s]+))/); + if (childTextMatch) { + const text = childTextMatch[1] || childTextMatch[2]; + selector += `:has-text("${text}")`; + } + + return selector; + } + + async createDump(stepName) { + const dumpDir = path.join(this.outputDir, stepName); + await fs.ensureDir(dumpDir); + + // Store current viewport before dump operations + const currentViewport = await this.page.viewportSize(); + + // Create context dump + const context = { + url: this.page.url(), + title: await this.page.title(), + timestamp: new Date().toISOString(), + viewport: currentViewport + }; + await fs.writeFile(path.join(dumpDir, 'context.json'), JSON.stringify(context, null, 2)); + + // Create console dump + await fs.writeFile(path.join(dumpDir, 'console.json'), JSON.stringify(this.consoleMessages, null, 2)); + + // Create DOM dump (beautified) + const html = await this.page.content(); + const beautifiedHtml = this.beautifyHtml(html); + await fs.writeFile(path.join(dumpDir, 'dom.html'), beautifiedHtml); + + // Create screenshot with viewport lock (if enabled) + if (this.enableScreenshots) { + if (!this.headless) { + // Ensure viewport is stable before screenshot + await this.page.setViewportSize(currentViewport); + await this.page.waitForTimeout(50); // Brief stabilization + } + + const screenshotOptions = { + path: path.join(dumpDir, 'screenshot.png'), + fullPage: this.fullPageScreenshots || this.headless + }; + + // Add clipping for viewport screenshots in headed mode + if (!this.fullPageScreenshots && !this.headless) { + screenshotOptions.clip = { + x: 0, + y: 0, + width: currentViewport.width, + height: currentViewport.height + }; + } + + await this.page.screenshot(screenshotOptions); + + // Restore viewport after screenshot + if (!this.headless) { + await this.page.setViewportSize(currentViewport); + } + } else { + // Create empty screenshot file to indicate screenshots are disabled + await fs.writeFile(path.join(dumpDir, 'screenshot.txt'), 'Screenshots disabled'); + } + } + + beautifyHtml(html) { + let beautified = html; + + // Put head in one line + beautified = beautified.replace(/]*>[\s\S]*?<\/head>/gi, (match) => { + return match.replace(/\s+/g, ' ').trim(); + }); + + // Put script tags in one line + beautified = beautified.replace(/]*>[\s\S]*?<\/script>/gi, (match) => { + return match.replace(/\s+/g, ' ').trim(); + }); + + // Put style tags in one line + beautified = beautified.replace(/]*>[\s\S]*?<\/style>/gi, (match) => { + return match.replace(/\s+/g, ' ').trim(); + }); + + // Put link tags in one line + beautified = beautified.replace(/]*>/gi, (match) => { + return match.replace(/\s+/g, ' ').trim(); + }); + + // Beautify body content with proper tree structure + beautified = beautified.replace(/]*>([\s\S]*?)<\/body>/gi, (match, bodyContent) => { + const beautifiedBody = this.beautifyBodyContent(bodyContent); + return match.replace(bodyContent, beautifiedBody); + }); + + return beautified; + } + + beautifyBodyContent(content) { + // Remove existing whitespace and newlines + let cleaned = content.replace(/>\s+<').trim(); + + // Add newlines and indentation + let result = ''; + let indentLevel = 0; + const indent = ' '; + + // Split by tags + const tokens = cleaned.split(/(<[^>]*>)/); + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.startsWith('<')) { + // This is a tag + if (token.startsWith('')) { + // Self-closing tag + result += '\n' + indent.repeat(indentLevel) + token; + } else { + // Opening tag + result += '\n' + indent.repeat(indentLevel) + token; + // Check if this is a void element that doesn't need closing + const tagName = token.match(/<(\w+)/)?.[1]?.toLowerCase(); + const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']; + if (!voidElements.includes(tagName)) { + indentLevel++; + } + } + } else if (token.trim()) { + // This is text content + const lines = token.split('\n'); + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine) { + result += '\n' + indent.repeat(indentLevel) + trimmedLine; + } + } + } + } + + return result + '\n'; + } + + async cleanup() { + if (this.page) await this.page.close(); + if (this.context) await this.context.close(); + if (this.browser) await this.browser.close(); + } + + resolveEnvironmentVariables(value) { + if (typeof value !== 'string') { + return value; + } + + // Replace $VARIABLE or ${VARIABLE} with environment variable values + return value.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (match, varName) => { + const envValue = process.env[varName]; + if (envValue === undefined) { + console.warn(`Warning: Environment variable ${varName} is not defined`); + return match; // Return original if not found + } + return envValue; + }); + } +} + +module.exports = TestExecutor; \ No newline at end of file diff --git a/src/linter-cli.js b/src/linter-cli.js new file mode 100644 index 0000000..915f351 --- /dev/null +++ b/src/linter-cli.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const TestLinter = require('./linter'); + +class LinterCLI { + constructor() { + this.linter = new TestLinter(); + } + + run(args) { + const options = this.parseArgs(args); + + if (options.help) { + this.showHelp(); + return; + } + + if (options.files.length === 0) { + console.error('Error: No test files specified'); + this.showHelp(); + process.exit(1); + } + + let totalErrors = 0; + let totalWarnings = 0; + let totalInfo = 0; + + for (const file of options.files) { + const result = this.lintFile(file, options); + totalErrors += result.errors.length; + totalWarnings += result.warnings.length; + totalInfo += result.info.length; + } + + // Summary + console.log('\n' + '='.repeat(50)); + console.log(`Summary: ${totalErrors} errors, ${totalWarnings} warnings, ${totalInfo} info`); + + if (totalErrors > 0) { + console.log('❌ Linting failed with errors'); + process.exit(1); + } else if (totalWarnings > 0 && options.strict) { + console.log('⚠️ Linting failed in strict mode due to warnings'); + process.exit(1); + } else { + console.log('✅ Linting passed'); + } + } + + parseArgs(args) { + const options = { + files: [], + strict: false, + verbose: false, + format: 'default', + help: false + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--strict' || arg === '-s') { + options.strict = true; + } else if (arg === '--verbose' || arg === '-v') { + options.verbose = true; + } else if (arg === '--format' || arg === '-f') { + options.format = args[++i] || 'default'; + } else if (arg.startsWith('--')) { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } else { + options.files.push(arg); + } + } + + return options; + } + + lintFile(filePath, options) { + try { + if (!fs.existsSync(filePath)) { + console.error(`Error: File not found: ${filePath}`); + process.exit(1); + } + + const content = fs.readFileSync(filePath, 'utf8'); + const result = this.linter.lint(content, filePath); + + this.displayResults(result, options); + + return result; + } catch (error) { + console.error(`Error reading file ${filePath}: ${error.message}`); + process.exit(1); + } + } + + displayResults(result, options) { + const { errors, warnings, info } = result; + + if (options.format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Default format + console.log(`\n📁 ${result.errors[0]?.file || result.warnings[0]?.file || 'test file'}`); + + // Display errors + if (errors.length > 0) { + console.log('\n❌ Errors:'); + errors.forEach(error => { + console.log(` Line ${error.line}: ${error.message}`); + }); + } + + // Display warnings + if (warnings.length > 0) { + console.log('\n⚠️ Warnings:'); + warnings.forEach(warning => { + console.log(` Line ${warning.line}: ${warning.message}`); + }); + } + + // Display info (only in verbose mode) + if (options.verbose && info.length > 0) { + console.log('\n💡 Info:'); + info.forEach(infoItem => { + console.log(` Line ${infoItem.line}: ${infoItem.message}`); + }); + } + + // File summary + const status = errors.length > 0 ? '❌' : warnings.length > 0 ? '⚠️' : '✅'; + console.log(`\n${status} ${errors.length} errors, ${warnings.length} warnings${options.verbose ? `, ${info.length} info` : ''}`); + } + + showHelp() { + console.log(` +PlayWrong Test Linter + +Usage: node src/linter-cli.js [options] [file2] ... + +Options: + -h, --help Show this help message + -s, --strict Treat warnings as errors + -v, --verbose Show info messages + -f, --format Output format (default|json) + +Examples: + node src/linter-cli.js step1.test + node src/linter-cli.js --strict --verbose *.test + node src/linter-cli.js --format json step1.test + +Exit codes: + 0 - No errors (warnings allowed unless --strict) + 1 - Errors found or warnings in strict mode +`); + } +} + +// Run CLI if called directly +if (require.main === module) { + const cli = new LinterCLI(); + cli.run(process.argv.slice(2)); +} + +module.exports = LinterCLI; \ No newline at end of file diff --git a/src/linter.js b/src/linter.js new file mode 100644 index 0000000..64f9676 --- /dev/null +++ b/src/linter.js @@ -0,0 +1,369 @@ +class TestLinter { + constructor() { + this.rules = []; + this.errors = []; + this.warnings = []; + this.info = []; + + // Valid commands + this.validCommands = ['use', 'open', 'wait', 'click', 'scroll', 'fill', 'break', 'sleep', 'dump']; + + // Valid HTML elements + this.validElements = [ + 'div', 'span', 'button', 'input', 'a', 'form', 'select', 'option', + 'textarea', 'label', 'img', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'table', 'tr', 'td', 'th', 'ul', 'ol', 'li', 'nav', 'header', 'footer', + 'section', 'article', 'aside', 'main' + ]; + + // Valid browser profiles + this.validProfiles = ['Chrome', 'Mobile', 'MobileSmall', 'Firefox', 'Safari', 'Edge']; + + // Required parameters for each command + this.requiredParams = { + 'use': ['profiles'], + 'open': ['url'], + 'wait': ['element'], + 'click': ['element'], + 'scroll': ['element'], + 'fill': ['element', 'value'], + 'break': [], + 'sleep': ['milliseconds'], + 'dump': ['name'] + }; + + // Initialize rules + this.initializeRules(); + } + + initializeRules() { + // Add all linting rules + this.rules = [ + this.validateCommandSyntax.bind(this), + this.validateQuotes.bind(this), + this.validateParameters.bind(this), + this.validateElementSelectors.bind(this), + this.validateVariables.bind(this), + this.validateFlowLogic.bind(this), + this.validateSemanticRules.bind(this) + ]; + } + + removeLineComments(line) { + let result = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = i < line.length - 1 ? line[i + 1] : ''; + const prevChar = i > 0 ? line[i - 1] : ''; + + // Handle quotes + if (char === '"' && prevChar !== '\\' && !inQuote) { + inQuote = true; + quoteChar = char; + result += char; + } else if (char === quoteChar && prevChar !== '\\' && inQuote) { + inQuote = false; + quoteChar = ''; + result += char; + } else if (!inQuote && char === '/' && nextChar === '/') { + // Found // comment outside quotes - stop here + break; + } else if (!inQuote && char === '#') { + // Found # comment outside quotes - stop here + break; + } else { + result += char; + } + } + + return result; + } + + removeMultiLineComments(content) { + // Remove /* ... */ comments, including nested ones + let result = ''; + let i = 0; + let inComment = false; + let commentDepth = 0; + + while (i < content.length) { + if (!inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') { + inComment = true; + commentDepth = 1; + i += 2; + } else if (inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') { + commentDepth++; + i += 2; + } else if (inComment && i < content.length - 1 && content[i] === '*' && content[i + 1] === '/') { + commentDepth--; + if (commentDepth === 0) { + inComment = false; + } + i += 2; + } else if (!inComment) { + result += content[i]; + i++; + } else { + i++; + } + } + + return result; + } + + lint(content, filename = 'test.test') { + this.errors = []; + this.warnings = []; + this.info = []; + this.filename = filename; + + // Remove multi-line comments first (like the actual parser does) + const contentWithoutMultiLineComments = this.removeMultiLineComments(content); + + // Parse content into lines with line numbers + const lines = contentWithoutMultiLineComments.split('\n'); + const cleanedLines = this.preprocessLines(lines); + + // Run all rules + for (const rule of this.rules) { + rule(cleanedLines, lines); + } + + return { + errors: this.errors, + warnings: this.warnings, + info: this.info, + hasErrors: this.errors.length > 0, + hasWarnings: this.warnings.length > 0 + }; + } + + preprocessLines(lines) { + const result = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let cleanLine = line; + + // Remove single line comments (but not inside quotes) + cleanLine = this.removeLineComments(cleanLine); + + result.push({ + original: line, + cleaned: cleanLine.trim(), + lineNumber: i + 1 + }); + } + + return result.filter(line => line.cleaned.length > 0); + } + + addError(message, lineNumber, column = 0) { + this.errors.push({ + type: 'error', + message, + file: this.filename, + line: lineNumber, + column + }); + } + + addWarning(message, lineNumber, column = 0) { + this.warnings.push({ + type: 'warning', + message, + file: this.filename, + line: lineNumber, + column + }); + } + + addInfo(message, lineNumber, column = 0) { + this.info.push({ + type: 'info', + message, + file: this.filename, + line: lineNumber, + column + }); + } + + validateCommandSyntax(lines) { + for (const line of lines) { + const command = line.cleaned.split(' ')[0]; + + if (!this.validCommands.includes(command)) { + this.addError(`Invalid command '${command}'. Valid commands: ${this.validCommands.join(', ')}`, line.lineNumber); + } + } + } + + validateQuotes(lines) { + for (const line of lines) { + const content = line.cleaned; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + const prevChar = i > 0 ? content[i - 1] : ''; + + if (char === '"' && !inQuote && prevChar !== '\\') { + inQuote = true; + quoteChar = char; + } else if (char === quoteChar && inQuote && prevChar !== '\\') { + inQuote = false; + quoteChar = ''; + } + } + + if (inQuote) { + this.addError(`Unclosed quote in line`, line.lineNumber); + } + } + } + + validateParameters(lines) { + for (const line of lines) { + const parts = line.cleaned.split(' '); + const command = parts[0]; + + if (!this.validCommands.includes(command)) continue; + + const required = this.requiredParams[command] || []; + const params = this.parseLineParameters(line.cleaned); + + // Check required parameters + for (const reqParam of required) { + if (reqParam === 'profiles' && command === 'use') { + // Special handling for 'use' command + if (!line.cleaned.includes('"')) { + this.addError(`Command '${command}' requires browser profile in quotes`, line.lineNumber); + } + } else if (reqParam === 'url' && command === 'open') { + if (!line.cleaned.includes('"')) { + this.addError(`Command '${command}' requires URL in quotes`, line.lineNumber); + } + } else if (reqParam === 'name' && command === 'dump') { + if (!line.cleaned.includes('"')) { + this.addError(`Command '${command}' requires name in quotes`, line.lineNumber); + } + } else if (reqParam === 'milliseconds' && command === 'sleep') { + if (!line.cleaned.match(/\d+/)) { + this.addError(`Command '${command}' requires numeric milliseconds`, line.lineNumber); + } + } else if (!params[reqParam]) { + this.addError(`Command '${command}' missing required parameter '${reqParam}'`, line.lineNumber); + } + } + } + } + + validateElementSelectors(lines) { + for (const line of lines) { + const command = line.cleaned.split(' ')[0]; + + if (['wait', 'click', 'scroll', 'fill'].includes(command)) { + const elementMatch = line.cleaned.match(/element=(\w+)/); + + if (elementMatch) { + const element = elementMatch[1]; + if (!this.validElements.includes(element)) { + this.addWarning(`Unknown HTML element '${element}'. Consider using standard HTML elements.`, line.lineNumber); + } + } + } + } + } + + validateVariables(lines) { + for (const line of lines) { + const variables = line.cleaned.match(/\$([A-Z_][A-Z0-9_]*)/g); + + if (variables) { + for (const variable of variables) { + // Check variable naming convention + if (!variable.match(/^\$[A-Z_][A-Z0-9_]*$/)) { + this.addWarning(`Variable '${variable}' should use UPPER_CASE naming convention`, line.lineNumber); + } + + // Info about common variables + if (['$PASSWORD', '$PASSWORDMAIL', '$EMAIL', '$USERNAME'].includes(variable)) { + this.addInfo(`Using environment variable '${variable}' - ensure it's defined in your environment`, line.lineNumber); + } + } + } + } + } + + validateFlowLogic(lines) { + let hasUseCommand = false; + let useLineNumber = 0; + + for (const line of lines) { + const command = line.cleaned.split(' ')[0]; + + if (command === 'use') { + if (hasUseCommand) { + this.addWarning(`Multiple 'use' commands found. Consider using multi-profile syntax instead.`, line.lineNumber); + } + hasUseCommand = true; + useLineNumber = line.lineNumber; + } + } + + if (!hasUseCommand) { + this.addWarning(`No 'use' command found. Tests should specify browser profile.`, 1); + } else if (useLineNumber > 1) { + this.addInfo(`'use' command found at line ${useLineNumber}. Consider placing it at the beginning of the test.`, useLineNumber); + } + } + + validateSemanticRules(lines) { + for (const line of lines) { + const command = line.cleaned.split(' ')[0]; + + // fill should be used with input elements + if (command === 'fill') { + const elementMatch = line.cleaned.match(/element=(\w+)/); + if (elementMatch) { + const element = elementMatch[1]; + if (!['input', 'textarea', 'select'].includes(element)) { + this.addWarning(`'fill' command used with '${element}' element. Consider using 'input', 'textarea', or 'select'.`, line.lineNumber); + } + } + } + + // click should not be used with input elements (use fill instead) + if (command === 'click') { + const elementMatch = line.cleaned.match(/element=(\w+)/); + if (elementMatch) { + const element = elementMatch[1]; + if (['input', 'textarea'].includes(element) && line.cleaned.includes('type=')) { + const typeMatch = line.cleaned.match(/type="?(\w+)"?/); + if (typeMatch && ['text', 'email', 'password', 'number'].includes(typeMatch[1])) { + this.addWarning(`Consider using 'fill' instead of 'click' for text input elements.`, line.lineNumber); + } + } + } + } + } + } + + parseLineParameters(line) { + const params = {}; + const regex = /(\w+)=(?:"([^"]*)"|([^\s]+))/g; + let match; + + while ((match = regex.exec(line)) !== null) { + params[match[1]] = match[2] !== undefined ? match[2] : match[3]; + } + + return params; + } +} + +module.exports = TestLinter; \ No newline at end of file diff --git a/src/parser.js b/src/parser.js new file mode 100644 index 0000000..acd984a --- /dev/null +++ b/src/parser.js @@ -0,0 +1,338 @@ +class TestParser { + constructor() { + this.commands = []; + } + + parse(content) { + // Remove multi-line comments /* ... */ + content = this.removeMultiLineComments(content); + + const lines = content.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//') && !line.startsWith('#')); + + for (const line of lines) { + const command = this.parseLine(line); + if (command) { + this.commands.push(command); + } + } + + return this.commands; + } + + removeMultiLineComments(content) { + // Remove /* ... */ comments, including nested ones + let result = ''; + let i = 0; + let inComment = false; + let commentDepth = 0; + + while (i < content.length) { + if (!inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') { + inComment = true; + commentDepth = 1; + i += 2; + } else if (inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') { + commentDepth++; + i += 2; + } else if (inComment && i < content.length - 1 && content[i] === '*' && content[i + 1] === '/') { + commentDepth--; + if (commentDepth === 0) { + inComment = false; + } + i += 2; + } else if (!inComment) { + result += content[i]; + i++; + } else { + i++; + } + } + + return result; + } + + parseLine(line) { + // Parse use command: use "Chrome" ( "Mobile" , "MobileSmall") or use "Chrome" + if (line.startsWith('use ')) { + // Multi-profile format: use "Chrome" ( "Mobile" , "MobileSmall") + const multiMatch = line.match(/use\s+"([^"]+)"\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/); + if (multiMatch) { + return { + type: 'use', + profiles: [multiMatch[1], multiMatch[2], multiMatch[3]] + }; + } + + // Single profile format: use "Chrome" + const singleMatch = line.match(/use\s+"([^"]+)"/); + if (singleMatch) { + return { + type: 'use', + profiles: [singleMatch[1]] + }; + } + } + + // Parse open command: open "https://xyz.com" + if (line.startsWith('open ')) { + const match = line.match(/open\s+"([^"]+)"/); + if (match) { + return { + type: 'open', + url: match[1] + }; + } + } + + // Parse dump command: dump "step 1" + if (line.startsWith('dump ')) { + const match = line.match(/dump\s+"([^"]+)"/); + if (match) { + return { + type: 'dump', + name: match[1] + }; + } + } + + // Parse wait command: wait element=div childText=Login + if (line.startsWith('wait ')) { + const params = this.parseParameters(line.substring(5)); + return { + type: 'wait', + element: params.element, + // Preserve all other attributes including HTML type + name: params.name, + id: params.id, + class: params.class, + href: params.href, + htmlType: params.type, // Store HTML type as htmlType to avoid conflict + child: params.child, + childText: params.childText + }; + } + + // Parse click command: click element=div childText=Login + if (line.startsWith('click ')) { + const params = this.parseParameters(line.substring(6)); + return { + type: 'click', + element: params.element, + // Preserve all other attributes including HTML type + name: params.name, + id: params.id, + class: params.class, + href: params.href, + htmlType: params.type, // Store HTML type as htmlType to avoid conflict + child: params.child, + childText: params.childText + }; + } + + // Parse scroll command: scroll element=div childText=Login + if (line.startsWith('scroll ')) { + const params = this.parseParameters(line.substring(7)); + return { + type: 'scroll', + element: params.element, + // Preserve all other attributes including HTML type + name: params.name, + id: params.id, + class: params.class, + href: params.href, + htmlType: params.type, // Store HTML type as htmlType to avoid conflict + child: params.child, + childText: params.childText + }; + } + + // Parse fill command: fill element=input name=firstName value=abc + if (line.startsWith('fill ')) { + const params = this.parseParameters(line.substring(5)); + return { + type: 'fill', + element: params.element, + value: params.value, + // Preserve all other attributes including HTML type + name: params.name, + id: params.id, + class: params.class, + href: params.href, + htmlType: params.type, // Store HTML type as htmlType to avoid conflict + child: params.child, + childText: params.childText + }; + } + + // Parse break command: break "waiting for user input" + if (line.startsWith('break')) { + const match = line.match(/break(?:\s+"([^"]+)")?/); + return { + type: 'break', + message: match && match[1] ? match[1] : 'Press any key to continue...' + }; + } + + // Parse sleep command: sleep 1000 or sleep 1000 "waiting for animation" + if (line.startsWith('sleep ')) { + // Handle both: sleep 1000 and sleep 1000 "message" + const match = line.match(/sleep\s+(\d+)(?:\s+"([^"]+)")?/); + if (match) { + return { + type: 'sleep', + milliseconds: parseInt(match[1]), + message: match[2] || null + }; + } + } + + return null; + } + + parseParameters(paramString) { + const params = {}; + + // Handle new parentheses syntax: element=button(child=span class="MuiBadge-badge" childText="1") + const elementWithParensMatch = paramString.match(/element=(\w+)\(([^)]+)\)/); + if (elementWithParensMatch) { + params.element = elementWithParensMatch[1]; + params.child = elementWithParensMatch[2]; + + // Parse any remaining parameters outside the parentheses + const remainingParams = paramString.replace(elementWithParensMatch[0], '').trim(); + if (remainingParams) { + this.parseSimpleParameters(remainingParams, params); + } + } else { + // Handle legacy syntax or simple cases + const elementMatch = paramString.match(/element=(\w+)/); + if (elementMatch) { + params.element = elementMatch[1]; + } + + // Check for legacy child parameter (backward compatibility) + const childMatch = paramString.match(/child=(\w+)/); + if (childMatch) { + // Everything after child= belongs to the child selector + const childStart = paramString.indexOf('child='); + const beforeChild = paramString.substring(0, childStart).trim(); + const childPart = paramString.substring(childStart); + + // Parse the child selector completely + params.child = childPart; + + // Parse any parameters before the child + this.parseSimpleParameters(beforeChild, params); + } else { + // No child, parse all parameters normally + this.parseSimpleParameters(paramString, params); + } + } + + return params; + } + + parseSimpleParameters(paramString, params) { + const regex = /(\w+)=(?:"([^"]*)"|([^\s]+))/g; + let match; + + while ((match = regex.exec(paramString)) !== null) { + // Use quoted value if present, otherwise use unquoted value + params[match[1]] = match[2] !== undefined ? match[2] : match[3]; + } + } + + buildSelector(params) { + let selector = params.element || 'div'; + + if (params.name) { + selector += `[name="${params.name}"]`; + } + + if (params.id) { + selector += `#${params.id}`; + } + + if (params.class) { + selector += `.${params.class.replace(/\s+/g, '.')}`; + } + + if (params.href) { + selector += `[href="${params.href}"]`; + } + + if (params.type) { + selector += `[type="${params.type}"]`; + } + + if (params.htmlType) { + selector += `[type="${params.htmlType}"]`; + } + + // Handle child selectors (nested elements) + if (params.child) { + // Parse child selector like "span class="MuiBadge-badge"" + const childSelector = this.parseChildSelector(params.child); + selector += ` ${childSelector}`; + } + + if (params.childText) { + selector += `:has-text("${params.childText}")`; + } + + return selector; + } + + parseChildSelector(childString) { + // Parse child selector - handles both new and legacy syntax + let selector = ''; + + // For new syntax: "child=span class="MuiBadge-badge" childText="1"" + // For legacy syntax: "child=span class="MuiBadge-badge" childText="1"" + + // Extract element name from child=elementName + const elementMatch = childString.match(/child=(\w+)/); + if (elementMatch) { + selector = elementMatch[1]; + } + + // Extract class attribute + const classMatch = childString.match(/class=(?:"([^"]*)"|([^\s]+))/); + if (classMatch) { + const className = classMatch[1] || classMatch[2]; + selector += `.${className.replace(/\s+/g, '.')}`; + } + + // Extract id attribute + const idMatch = childString.match(/id=(?:"([^"]*)"|([^\s]+))/); + if (idMatch) { + const id = idMatch[1] || idMatch[2]; + selector += `#${id}`; + } + + // Extract name attribute + const nameMatch = childString.match(/name=(?:"([^"]*)"|([^\s]+))/); + if (nameMatch) { + const name = nameMatch[1] || nameMatch[2]; + selector += `[name="${name}"]`; + } + + // Extract href attribute + const hrefMatch = childString.match(/href=(?:"([^"]*)"|([^\s]+))/); + if (hrefMatch) { + const href = hrefMatch[1] || hrefMatch[2]; + selector += `[href="${href}"]`; + } + + // Extract childText for the child element + const childTextMatch = childString.match(/childText=(?:"([^"]*)"|([^\s]+))/); + if (childTextMatch) { + const text = childTextMatch[1] || childTextMatch[2]; + selector += `:has-text("${text}")`; + } + + return selector; + } +} + +module.exports = TestParser; \ No newline at end of file diff --git a/step1.test b/step1.test new file mode 100644 index 0000000..d026fe3 --- /dev/null +++ b/step1.test @@ -0,0 +1,79 @@ +/* +PlayWrong Test - Complete E-commerce Flow +This test demonstrates the full purchase flow from product selection to order completion +with beautiful animations and environment variable support +Part 1: Load Growheads, put one item in the cart and go to checkout +Part 2: Fill in the checkout form +Part 3: Login to the email account +*/ +use "Chrome" + +# Part 1 - Load Growheads, put one item in the cart and go to checkout +open "https://growheads.de" +sleep 2000 "page load" +wait element=a href=/Kategorie/Seeds +click element=a href=/Kategorie/Seeds +sleep 2000 "seed click" +wait element=button childText="In den Korb" +click element=button childText="In den Korb" +sleep 2000 "in korb click" +wait element=span class="MuiBadge-badge" childText="1" +click element=button child=span(class="MuiBadge-badge" childText="1") +sleep 2000 "korb click" +wait element=button childText="Weiter zur Kasse" +click element=button childText="Weiter zur Kasse" +sleep 2000 "weiter click" +wait element=input type="email" +fill element=input type="email" value="autotest@growheads.de" +sleep 2000 "email fill" +wait element=input type="password" +fill element=input type="password" value="$PASSWORD" +sleep 2000 "password fill" +wait element=button childText="ANMELDEN" class="MuiButton-fullWidth" +click element=button childText="ANMELDEN" class="MuiButton-fullWidth" +sleep 3000 "anmelden click" + +# Part 2 - Fill in the checkout form +scroll element=span childText="Vorname" +wait element=input name="firstName" +fill element=input name="firstName" value="Max" +sleep 1000 "vorname fill" +wait element=input name="lastName" +fill element=input name="lastName" value="Muster" +sleep 1000 "nachname fill" +wait element=input name="street" +fill element=input name="street" value="Muster" +sleep 1000 "strasse fill" +wait element=input name="houseNumber" +fill element=input name="houseNumber" value="420" +sleep 1000 "hausnummer fill" +wait element=input name="postalCode" +fill element=input name="postalCode" value="42023" +sleep 1000 "plz fill" +wait element=input name="city" +fill element=input name="city" value="Muster" +sleep 1000 "stadt fill" +wait element=textarea name="note" +fill element=textarea name="note" value="Musteranmerkung" +sleep 1000 "note fill" +scroll element=button childText="Bestellung abschließen" +wait element=label childText="Bestimmungen" +click element=label childText="Bestimmungen" +sleep 1000 "checkbox checked" +wait element=button childText="Bestellung abschließen" +click element=button childText="Bestellung abschließen" +sleep 3000 "order completion" + +# Part 3 - Login to the email account +open "https://mail.growbnb.de/" +sleep 2000 "page load" +wait element=input name="_user" id="rcmloginuser" +fill element=input name="_user" id="rcmloginuser" value="autotest@growheads.de" +sleep 1000 "username fill" +wait element=input name="_pass" id="rcmloginpwd" +fill element=input name="_pass" id="rcmloginpwd" value="$PASSWORDMAIL" +sleep 1000 "password fill" +wait element=button type="submit" id="rcmloginsubmit" +click element=button type="submit" id="rcmloginsubmit" +sleep 3000 "login submit" +dump "email_logged_in" \ No newline at end of file