Genesis
This commit is contained in:
75
.cursor/rules/projectinfo.mdc
Normal file
75
.cursor/rules/projectinfo.mdc
Normal file
@@ -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/<dumpname>/` 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
|
||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
PASSWORD=this is not a password but it has whitespace
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
test-results/
|
||||
playwright-report/
|
||||
test-results.json
|
||||
.env
|
||||
*.log
|
||||
tests/
|
||||
47
.vscode/extensions/README.md
vendored
Normal file
47
.vscode/extensions/README.md
vendored
Normal file
@@ -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,<html><body><h1>Comment Test</h1></body></html>"
|
||||
fill element=input type="password" value="$PASSWORD"
|
||||
click element=button childText="Login" /* inline comment */
|
||||
sleep 2000 "wait for page load"
|
||||
```
|
||||
64
.vscode/extensions/playwrong-syntax/README.md
vendored
Normal file
64
.vscode/extensions/playwrong-syntax/README.md
vendored
Normal file
@@ -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
|
||||
213
.vscode/extensions/playwrong-syntax/extension.js
vendored
Normal file
213
.vscode/extensions/playwrong-syntax/extension.js
vendored
Normal file
@@ -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
|
||||
};
|
||||
24
.vscode/extensions/playwrong-syntax/language-configuration.json
vendored
Normal file
24
.vscode/extensions/playwrong-syntax/language-configuration.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"comments": {
|
||||
"lineComment": "#",
|
||||
"blockComment": ["/*", "*/"]
|
||||
},
|
||||
"brackets": [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
["\"", "\""]
|
||||
],
|
||||
"autoClosingPairs": [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
["\"", "\""]
|
||||
],
|
||||
"surroundingPairs": [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
["\"", "\""]
|
||||
]
|
||||
}
|
||||
7
.vscode/extensions/playwrong-syntax/package-lock.json
generated
vendored
Normal file
7
.vscode/extensions/playwrong-syntax/package-lock.json
generated
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "playwrong-syntax",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {}
|
||||
}
|
||||
67
.vscode/extensions/playwrong-syntax/package.json
vendored
Normal file
67
.vscode/extensions/playwrong-syntax/package.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
120
.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json
vendored
Normal file
120
.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
75
.vscode/extensions/playwrong-syntax/themes/playwrong-theme.json
vendored
Normal file
75
.vscode/extensions/playwrong-syntax/themes/playwrong-theme.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal file
@@ -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": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.test": "playwrong"
|
||||
}
|
||||
}
|
||||
73
.vscode/tasks.json
vendored
Normal file
73
.vscode/tasks.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
160
README.md
Normal file
160
README.md
Normal file
@@ -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
|
||||
779
package-lock.json
generated
Normal file
779
package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
package.json
Normal file
31
package.json
Normal file
@@ -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"
|
||||
}
|
||||
36
playwright.config.js
Normal file
36
playwright.config.js
Normal file
@@ -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 }
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
126
src/cli.js
Normal file
126
src/cli.js
Normal file
@@ -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 <test-file> [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;
|
||||
998
src/executor.js
Normal file
998
src/executor.js
Normal file
@@ -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 = `
|
||||
<div class="sleep-icon">💤</div>
|
||||
<div>SLEEP: ${message || 'waiting'}</div>
|
||||
<div class="countdown" id="countdown">${Math.ceil(duration / 1000)}s</div>
|
||||
`;
|
||||
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(/<head[^>]*>[\s\S]*?<\/head>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Put script tags in one line
|
||||
beautified = beautified.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Put style tags in one line
|
||||
beautified = beautified.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Put link tags in one line
|
||||
beautified = beautified.replace(/<link[^>]*>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Beautify body content with proper tree structure
|
||||
beautified = beautified.replace(/<body[^>]*>([\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+</g, '><').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('</')) {
|
||||
// Closing tag
|
||||
indentLevel--;
|
||||
result += '\n' + indent.repeat(indentLevel) + token;
|
||||
} else if (token.endsWith('/>')) {
|
||||
// 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;
|
||||
172
src/linter-cli.js
Normal file
172
src/linter-cli.js
Normal file
@@ -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] <file1> [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;
|
||||
369
src/linter.js
Normal file
369
src/linter.js
Normal file
@@ -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;
|
||||
338
src/parser.js
Normal file
338
src/parser.js
Normal file
@@ -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;
|
||||
79
step1.test
Normal file
79
step1.test
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user