playforge 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- playforge-1.0.0/LICENSE.md +19 -0
- playforge-1.0.0/PKG-INFO +114 -0
- playforge-1.0.0/README.md +83 -0
- playforge-1.0.0/playforge/__init__.py +13 -0
- playforge-1.0.0/playforge/cli/__init__.py +3 -0
- playforge-1.0.0/playforge/cli/main.py +45 -0
- playforge-1.0.0/playforge/generation/__init__.py +3 -0
- playforge-1.0.0/playforge/generation/render/__init__.py +3 -0
- playforge-1.0.0/playforge/generation/render/generator.py +130 -0
- playforge-1.0.0/playforge/logger/__init__.py +3 -0
- playforge-1.0.0/playforge/logger/logger.py +34 -0
- playforge-1.0.0/playforge/playforge.py +3 -0
- playforge-1.0.0/playforge/recording/__init__.py +3 -0
- playforge-1.0.0/playforge/recording/capture/__init__.py +3 -0
- playforge-1.0.0/playforge/recording/capture/recorder.py +158 -0
- playforge-1.0.0/playforge/workflow/__init__.py +5 -0
- playforge-1.0.0/playforge/workflow/manager.py +79 -0
- playforge-1.0.0/playforge/workflow/models.py +38 -0
- playforge-1.0.0/playforge/workflow/sanitizer.py +70 -0
- playforge-1.0.0/playforge.egg-info/PKG-INFO +114 -0
- playforge-1.0.0/playforge.egg-info/SOURCES.txt +25 -0
- playforge-1.0.0/playforge.egg-info/dependency_links.txt +1 -0
- playforge-1.0.0/playforge.egg-info/entry_points.txt +2 -0
- playforge-1.0.0/playforge.egg-info/requires.txt +3 -0
- playforge-1.0.0/playforge.egg-info/top_level.txt +1 -0
- playforge-1.0.0/pyproject.toml +43 -0
- playforge-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026, ytcalifax <hello@98w.eu>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
14
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
15
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
16
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
17
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
18
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
19
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
playforge-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: playforge
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Interactive Playwright recorder that turns browser actions into code
|
|
5
|
+
Author-email: "ytcalifax (98w)" <hello@98w.eu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ytcalifax/playforge
|
|
8
|
+
Project-URL: Repository, https://github.com/ytcalifax/playforge.git
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/ytcalifax/playforge/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/ytcalifax/playforge/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: playwright,page-object,recorder,testing,automation
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE.md
|
|
27
|
+
Requires-Dist: playwright>=1.62.0.0
|
|
28
|
+
Requires-Dist: ruff>=0.16.4.0
|
|
29
|
+
Requires-Dist: structlog>=26.1.0.0
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# ๐ญ PlayForge
|
|
33
|
+
|
|
34
|
+
> **A Playwright recorder that turns browser actions into reusable Page Object code.**
|
|
35
|
+
|
|
36
|
+
PlayForge records clicks, fills, selects, and reads from a browser session, then writes the result out as Python code you can keep using. It is meant for the boring part of browser automation: set up a page, click through the flow once, and get a generated page object you can reuse instead of hand-writing locators over and over.
|
|
37
|
+
|
|
38
|
+
The project is designed to run as a normal CLI after installation. Install it globally, point it at a URL, interact with the page, and let it capture the flow. When you quit, it writes the generated code to disk.
|
|
39
|
+
|
|
40
|
+
## โจ Features
|
|
41
|
+
|
|
42
|
+
- **๐ฌ Browser Recording**: Capture real user actions from a live page, including clicks, fills, selects, and text reads.
|
|
43
|
+
- **๐งฉ Page Object Generation**: Turn recorded workflows into Python page objects with reusable methods.
|
|
44
|
+
- **๐งน Workflow Splitting**: Split one recording session into multiple generated methods when a flow gets too long.
|
|
45
|
+
- **๐ชต Structured Logging**: Uses `structlog` for simple, consistent CLI and runtime logs.
|
|
46
|
+
- **โก CLI Ready**: Install it globally and run `playforge --help` or `playforge <url>`.
|
|
47
|
+
|
|
48
|
+
## ๐ฅ Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install playforge
|
|
52
|
+
playwright install
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or, to install directly from source:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install .
|
|
59
|
+
playwright install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If you want to use it from a local checkout while developing, install it in editable mode:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e .
|
|
66
|
+
playwright install
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You need `playwright install` so Playwright downloads an actual browser. Without that, the CLI can install fine but has nothing to launch.
|
|
70
|
+
|
|
71
|
+
## ๐ Quick Start
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
playforge https://example.com
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The recorder opens a browser window, waits for you to interact with the page, and collects actions until you quit. Use `split` in the terminal if you want to break the recording into another generated method.
|
|
78
|
+
|
|
79
|
+
### Help
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
playforge --help
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Output file
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
playforge https://example.com -o generated_page.py
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### What gets recorded
|
|
92
|
+
|
|
93
|
+
PlayForge watches for:
|
|
94
|
+
|
|
95
|
+
- clicks on buttons, links, and other interactive elements
|
|
96
|
+
- fills on input fields and text areas
|
|
97
|
+
- select changes
|
|
98
|
+
- double-click reads on readable text elements
|
|
99
|
+
|
|
100
|
+
Each captured workflow becomes a method in the generated page object. Repeated actions are collapsed where possible so the output stays usable instead of turning into a wall of duplicate steps.
|
|
101
|
+
|
|
102
|
+
## ๐ Requirements
|
|
103
|
+
|
|
104
|
+
- Python **3.10+**
|
|
105
|
+
- `playwright`
|
|
106
|
+
- `structlog`
|
|
107
|
+
- `ruff`
|
|
108
|
+
|
|
109
|
+
## ๐ค Contributing
|
|
110
|
+
|
|
111
|
+
Issues and pull requests are welcome. If you hit a weird recorder edge case, open an issue with the page flow and the generated output.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
*Built for browser automation and code generation. MIT Licensed.*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# ๐ญ PlayForge
|
|
2
|
+
|
|
3
|
+
> **A Playwright recorder that turns browser actions into reusable Page Object code.**
|
|
4
|
+
|
|
5
|
+
PlayForge records clicks, fills, selects, and reads from a browser session, then writes the result out as Python code you can keep using. It is meant for the boring part of browser automation: set up a page, click through the flow once, and get a generated page object you can reuse instead of hand-writing locators over and over.
|
|
6
|
+
|
|
7
|
+
The project is designed to run as a normal CLI after installation. Install it globally, point it at a URL, interact with the page, and let it capture the flow. When you quit, it writes the generated code to disk.
|
|
8
|
+
|
|
9
|
+
## โจ Features
|
|
10
|
+
|
|
11
|
+
- **๐ฌ Browser Recording**: Capture real user actions from a live page, including clicks, fills, selects, and text reads.
|
|
12
|
+
- **๐งฉ Page Object Generation**: Turn recorded workflows into Python page objects with reusable methods.
|
|
13
|
+
- **๐งน Workflow Splitting**: Split one recording session into multiple generated methods when a flow gets too long.
|
|
14
|
+
- **๐ชต Structured Logging**: Uses `structlog` for simple, consistent CLI and runtime logs.
|
|
15
|
+
- **โก CLI Ready**: Install it globally and run `playforge --help` or `playforge <url>`.
|
|
16
|
+
|
|
17
|
+
## ๐ฅ Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install playforge
|
|
21
|
+
playwright install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or, to install directly from source:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install .
|
|
28
|
+
playwright install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If you want to use it from a local checkout while developing, install it in editable mode:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
playwright install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You need `playwright install` so Playwright downloads an actual browser. Without that, the CLI can install fine but has nothing to launch.
|
|
39
|
+
|
|
40
|
+
## ๐ Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
playforge https://example.com
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The recorder opens a browser window, waits for you to interact with the page, and collects actions until you quit. Use `split` in the terminal if you want to break the recording into another generated method.
|
|
47
|
+
|
|
48
|
+
### Help
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
playforge --help
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Output file
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
playforge https://example.com -o generated_page.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### What gets recorded
|
|
61
|
+
|
|
62
|
+
PlayForge watches for:
|
|
63
|
+
|
|
64
|
+
- clicks on buttons, links, and other interactive elements
|
|
65
|
+
- fills on input fields and text areas
|
|
66
|
+
- select changes
|
|
67
|
+
- double-click reads on readable text elements
|
|
68
|
+
|
|
69
|
+
Each captured workflow becomes a method in the generated page object. Repeated actions are collapsed where possible so the output stays usable instead of turning into a wall of duplicate steps.
|
|
70
|
+
|
|
71
|
+
## ๐ Requirements
|
|
72
|
+
|
|
73
|
+
- Python **3.10+**
|
|
74
|
+
- `playwright`
|
|
75
|
+
- `structlog`
|
|
76
|
+
- `ruff`
|
|
77
|
+
|
|
78
|
+
## ๐ค Contributing
|
|
79
|
+
|
|
80
|
+
Issues and pull requests are welcome. If you hit a weird recorder edge case, open an issue with the page flow and the generated output.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
*Built for browser automation and code generation. MIT Licensed.*
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from playforge.logger.logger import configure_logging, get_logger
|
|
2
|
+
from playforge.workflow.manager import WorkflowManager
|
|
3
|
+
from playforge.workflow.models import Action, Workflow
|
|
4
|
+
from playforge.workflow.sanitizer import LocatorSanitizer
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Action",
|
|
8
|
+
"LocatorSanitizer",
|
|
9
|
+
"Workflow",
|
|
10
|
+
"WorkflowManager",
|
|
11
|
+
"configure_logging",
|
|
12
|
+
"get_logger",
|
|
13
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from playforge.logger.logger import configure_logging, get_logger
|
|
6
|
+
from playforge.workflow.manager import WorkflowManager
|
|
7
|
+
from playforge.generation.render.generator import CodeGenerator
|
|
8
|
+
from playforge.recording.capture.recorder import InteractiveRecorder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = get_logger(component="cli")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""Run the PlayForge CLI."""
|
|
16
|
+
configure_logging()
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
description="Interactive Playwright Page Object Recorder"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("url", nargs="?", help="Target URL to record against")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-o", "--output", default="generated_page.py", help="Output python file path"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--headless", action="store_true", help="Run browser in headless mode"
|
|
26
|
+
)
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
if not args.url:
|
|
30
|
+
parser.print_help()
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
logger.info("cli_started", url=args.url, output=args.output, headless=args.headless)
|
|
34
|
+
workflow_manager = WorkflowManager()
|
|
35
|
+
recorder = InteractiveRecorder(args.url, workflow_manager, headless=args.headless)
|
|
36
|
+
try:
|
|
37
|
+
completed = recorder.run()
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
logger.info("cli_interrupted")
|
|
40
|
+
return
|
|
41
|
+
if not completed:
|
|
42
|
+
logger.info("cli_interrupted")
|
|
43
|
+
return
|
|
44
|
+
CodeGenerator(workflow_manager).generate(args.output)
|
|
45
|
+
logger.info("cli_finished", output=args.output)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from playforge.logger.logger import get_logger
|
|
6
|
+
from playforge.workflow.manager import WorkflowManager
|
|
7
|
+
from playforge.workflow.models import Action
|
|
8
|
+
from playforge.workflow.sanitizer import LocatorSanitizer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodeGenerator:
|
|
12
|
+
"""Render workflows into a generated Playwright page object."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, workflow_manager: WorkflowManager):
|
|
15
|
+
self.workflow_manager = workflow_manager
|
|
16
|
+
self.logger = get_logger(component="generator")
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def _locator_expr_for_action(act: Action) -> str:
|
|
20
|
+
if act.id:
|
|
21
|
+
if act.class_name:
|
|
22
|
+
classes = "." + ".".join([c for c in act.class_name.split() if c])
|
|
23
|
+
return f"page.locator(\"{classes} [id='{LocatorSanitizer.escape_selector_value(act.id)}']\")"
|
|
24
|
+
return f"page.locator(\"[id='{LocatorSanitizer.escape_selector_value(act.id)}']\")"
|
|
25
|
+
if act.text:
|
|
26
|
+
safe_text = LocatorSanitizer.escape_selector_value(
|
|
27
|
+
LocatorSanitizer.normalize_text(act.text)
|
|
28
|
+
)
|
|
29
|
+
return f'page.locator("{LocatorSanitizer.quote_text_selector(safe_text)}")'
|
|
30
|
+
return f'page.locator("{act.tag_name}")'
|
|
31
|
+
|
|
32
|
+
def generate(self, output_path: str) -> None:
|
|
33
|
+
global_locators = {}
|
|
34
|
+
functions_output = []
|
|
35
|
+
workflows = self.workflow_manager.get_workflows()
|
|
36
|
+
used_names: set[str] = set()
|
|
37
|
+
|
|
38
|
+
for idx, workflow in enumerate(workflows):
|
|
39
|
+
func_name = WorkflowManager.deduce_workflow_name(workflow.actions, idx + 1)
|
|
40
|
+
method_steps = []
|
|
41
|
+
param_list = []
|
|
42
|
+
param_counts = {}
|
|
43
|
+
|
|
44
|
+
for act_idx, act in enumerate(workflow.actions):
|
|
45
|
+
is_lambda = False
|
|
46
|
+
if act.is_lambda_role:
|
|
47
|
+
is_lambda = True
|
|
48
|
+
base_var_name = "ACTION_TYPE"
|
|
49
|
+
loc_expr = "lambda item_text: page.locator(f\"//li[@role]/a[text()='{item_text}']\")"
|
|
50
|
+
param_name = "job_type"
|
|
51
|
+
elif act.id:
|
|
52
|
+
base_var_name = LocatorSanitizer.sanitize_var_name(act.id)
|
|
53
|
+
loc_expr = self._locator_expr_for_action(act)
|
|
54
|
+
param_name = LocatorSanitizer.sanitize_param_name(act.id)
|
|
55
|
+
elif act.text:
|
|
56
|
+
base_var_name = LocatorSanitizer.sanitize_var_name(
|
|
57
|
+
f"{act.tag_name}_{act.text[:25]}"
|
|
58
|
+
)
|
|
59
|
+
loc_expr = self._locator_expr_for_action(act)
|
|
60
|
+
param_name = f"text_{act_idx}"
|
|
61
|
+
else:
|
|
62
|
+
base_var_name = f"ELEMENT_{act_idx}"
|
|
63
|
+
loc_expr = self._locator_expr_for_action(act)
|
|
64
|
+
param_name = f"val_{act_idx}"
|
|
65
|
+
|
|
66
|
+
var_name = LocatorSanitizer.unique_name(base_var_name, used_names)
|
|
67
|
+
global_locators[var_name] = loc_expr
|
|
68
|
+
|
|
69
|
+
if act.type == "click":
|
|
70
|
+
if is_lambda:
|
|
71
|
+
if param_name not in [p.split(":")[0] for p in param_list]:
|
|
72
|
+
param_list.append(f"{param_name}: str")
|
|
73
|
+
method_steps.append(
|
|
74
|
+
f'self.{var_name}({param_name}).wait_for(state="visible", timeout=30000)'
|
|
75
|
+
)
|
|
76
|
+
method_steps.append(f"self.{var_name}({param_name}).click()")
|
|
77
|
+
else:
|
|
78
|
+
method_steps.append(f"self.{var_name}.click()")
|
|
79
|
+
elif act.type in {"fill", "select"}:
|
|
80
|
+
if param_name not in [p.split(":")[0] for p in param_list]:
|
|
81
|
+
param_counts[param_name] = 1
|
|
82
|
+
curr_param = param_name
|
|
83
|
+
else:
|
|
84
|
+
param_counts[param_name] = param_counts.get(param_name, 1) + 1
|
|
85
|
+
curr_param = f"{param_name}_{param_counts[param_name]}"
|
|
86
|
+
param_list.append(f"{curr_param}: str")
|
|
87
|
+
if act.type == "fill":
|
|
88
|
+
method_steps.append(
|
|
89
|
+
f'self.{var_name}.wait_for(state="visible", timeout=30000)'
|
|
90
|
+
)
|
|
91
|
+
method_steps.append(f"self.{var_name}.fill({curr_param})")
|
|
92
|
+
else:
|
|
93
|
+
method_steps.append(
|
|
94
|
+
f"self.{var_name}.select_option({curr_param})"
|
|
95
|
+
)
|
|
96
|
+
elif act.type == "get":
|
|
97
|
+
method_steps.append(
|
|
98
|
+
f'self.{var_name}.wait_for(state="visible", timeout=30000)'
|
|
99
|
+
)
|
|
100
|
+
method_steps.append(
|
|
101
|
+
f"return self.{var_name}.inner_text().strip() or self.{var_name}.text_content() or ''"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
unique_params = []
|
|
105
|
+
seen = set()
|
|
106
|
+
for p in param_list:
|
|
107
|
+
p_name = p.split(":")[0]
|
|
108
|
+
if p_name not in seen:
|
|
109
|
+
seen.add(p_name)
|
|
110
|
+
unique_params.append(p)
|
|
111
|
+
|
|
112
|
+
params_str = ", ".join(["self"] + unique_params)
|
|
113
|
+
func_def = [f" def {func_name}({params_str}):"]
|
|
114
|
+
for step in method_steps:
|
|
115
|
+
func_def.append(f" {step}")
|
|
116
|
+
functions_output.append("\n".join(func_def))
|
|
117
|
+
|
|
118
|
+
code_lines = [
|
|
119
|
+
"from playwright.sync_api import Page",
|
|
120
|
+
"",
|
|
121
|
+
"class GeneratedPage:",
|
|
122
|
+
" def __init__(self, page: Page):",
|
|
123
|
+
" self.page = page",
|
|
124
|
+
]
|
|
125
|
+
for var, expr in global_locators.items():
|
|
126
|
+
code_lines.append(f" self.{var} = {expr}")
|
|
127
|
+
code_lines.append("")
|
|
128
|
+
code_lines.extend(functions_output)
|
|
129
|
+
Path(output_path).write_text("\n".join(code_lines), encoding="utf-8")
|
|
130
|
+
self.logger.info("generated_page_written", output_path=output_path)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_logger_configured = False
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure_logging() -> None:
|
|
13
|
+
global _logger_configured
|
|
14
|
+
if _logger_configured:
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
structlog.configure(
|
|
18
|
+
processors=[
|
|
19
|
+
structlog.processors.add_log_level,
|
|
20
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
21
|
+
structlog.processors.StackInfoRenderer(),
|
|
22
|
+
structlog.processors.format_exc_info,
|
|
23
|
+
structlog.processors.KeyValueRenderer(key_order=["event"]),
|
|
24
|
+
],
|
|
25
|
+
wrapper_class=structlog.make_filtering_bound_logger(20),
|
|
26
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
|
|
27
|
+
cache_logger_on_first_use=True,
|
|
28
|
+
)
|
|
29
|
+
_logger_configured = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_logger(**context: Any):
|
|
33
|
+
configure_logging()
|
|
34
|
+
return structlog.get_logger().bind(**context)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from playwright.sync_api import Error, Page, sync_playwright
|
|
8
|
+
|
|
9
|
+
from playforge.logger.logger import get_logger
|
|
10
|
+
from playforge.workflow.manager import WorkflowManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InteractiveRecorder:
|
|
14
|
+
"""Capture browser interactions and append them to workflows."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self, url: str, workflow_manager: WorkflowManager, headless: bool = False
|
|
18
|
+
):
|
|
19
|
+
self.url = url
|
|
20
|
+
self.workflow_manager = workflow_manager
|
|
21
|
+
self.headless = headless
|
|
22
|
+
self.stop_recording = False
|
|
23
|
+
self.logger = get_logger(component="recorder")
|
|
24
|
+
|
|
25
|
+
def _on_console(self, msg: Any) -> None:
|
|
26
|
+
if not msg.text.startswith("RECORD_ACTION:"):
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
action = json.loads(msg.text.split("RECORD_ACTION:", 1)[1])
|
|
30
|
+
self.workflow_manager.add_action(action)
|
|
31
|
+
self.logger.info(
|
|
32
|
+
"recorded_action",
|
|
33
|
+
action_type=action.get("type"),
|
|
34
|
+
tag_name=action.get("tagName"),
|
|
35
|
+
element_id=action.get("id"),
|
|
36
|
+
)
|
|
37
|
+
except (ValueError, TypeError, KeyError):
|
|
38
|
+
self.logger.warning("record_action_parse_failed")
|
|
39
|
+
|
|
40
|
+
def _attach_recorder(self, page: Page) -> None:
|
|
41
|
+
page.on("console", self._on_console)
|
|
42
|
+
page.add_init_script(r"""
|
|
43
|
+
(() => {
|
|
44
|
+
const sendAction = (data) => console.log("RECORD_ACTION:" + JSON.stringify(data));
|
|
45
|
+
const getText = (el) => {
|
|
46
|
+
if (!el) return '';
|
|
47
|
+
return (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
48
|
+
};
|
|
49
|
+
const getElementId = (el) => {
|
|
50
|
+
if (!el) return '';
|
|
51
|
+
if (el.id) return el.id;
|
|
52
|
+
const labeledBy = el.getAttribute && el.getAttribute('aria-label');
|
|
53
|
+
if (labeledBy) return labeledBy.trim();
|
|
54
|
+
if ('name' in el && typeof el.name === 'string' && el.name.trim()) return el.name.trim();
|
|
55
|
+
if ('placeholder' in el && typeof el.placeholder === 'string' && el.placeholder.trim()) return el.placeholder.trim();
|
|
56
|
+
if (el.getAttribute) {
|
|
57
|
+
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('data-qa');
|
|
58
|
+
if (testId) return testId.trim();
|
|
59
|
+
}
|
|
60
|
+
const text = getText(el);
|
|
61
|
+
if (text) return text;
|
|
62
|
+
return el.tagName ? el.tagName.toLowerCase() : '';
|
|
63
|
+
};
|
|
64
|
+
const isInteractive = (el) => {
|
|
65
|
+
if (!el || !el.tagName) return false;
|
|
66
|
+
const tag = el.tagName.toLowerCase();
|
|
67
|
+
return ['input', 'textarea', 'select', 'button', 'a'].includes(tag) || tag === 'label';
|
|
68
|
+
};
|
|
69
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
70
|
+
document.addEventListener('click', (e) => {
|
|
71
|
+
const el = e.target;
|
|
72
|
+
if (!el || !isInteractive(el)) return;
|
|
73
|
+
sendAction({
|
|
74
|
+
type: 'click',
|
|
75
|
+
tagName: el.tagName ? el.tagName.toLowerCase() : '',
|
|
76
|
+
id: getElementId(el),
|
|
77
|
+
className: typeof el.className === 'string' ? el.className.trim() : '',
|
|
78
|
+
text: getText(el),
|
|
79
|
+
isLambdaRole: Boolean(el.tagName && el.tagName.toLowerCase() === 'a' && el.parentElement && el.parentElement.getAttribute('role')),
|
|
80
|
+
value: el.value || ''
|
|
81
|
+
});
|
|
82
|
+
}, true);
|
|
83
|
+
document.addEventListener('dblclick', (e) => {
|
|
84
|
+
const el = e.target;
|
|
85
|
+
if (!el) return;
|
|
86
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
87
|
+
if (!['label', 'p', 'span', 'div', 'li', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'small'].includes(tag)) return;
|
|
88
|
+
const textVal = getText(el);
|
|
89
|
+
if (!textVal) return;
|
|
90
|
+
sendAction({ type: 'get', tagName: tag, id: getElementId(el), className: typeof el.className === 'string' ? el.className.trim() : '', text: textVal, isLambdaRole: false, value: '' });
|
|
91
|
+
}, true);
|
|
92
|
+
document.addEventListener('change', (e) => {
|
|
93
|
+
const el = e.target;
|
|
94
|
+
if (!el) return;
|
|
95
|
+
const isSelect = el.tagName && el.tagName.toLowerCase() === 'select';
|
|
96
|
+
sendAction({ type: isSelect ? 'select' : 'fill', tagName: el.tagName ? el.tagName.toLowerCase() : '', id: getElementId(el), className: typeof el.className === 'string' ? el.className.trim() : '', text: getText(el), isLambdaRole: false, value: el.value || '' });
|
|
97
|
+
}, true);
|
|
98
|
+
});
|
|
99
|
+
})();
|
|
100
|
+
""")
|
|
101
|
+
|
|
102
|
+
def run(self) -> bool:
|
|
103
|
+
self.logger.info("recorder_started", url=self.url, headless=self.headless)
|
|
104
|
+
interrupted = False
|
|
105
|
+
try:
|
|
106
|
+
with sync_playwright() as p:
|
|
107
|
+
browser = p.chromium.launch(headless=self.headless)
|
|
108
|
+
context = browser.new_context(ignore_https_errors=True)
|
|
109
|
+
page = context.new_page()
|
|
110
|
+
self._attach_recorder(page)
|
|
111
|
+
context.on("page", self._attach_recorder)
|
|
112
|
+
try:
|
|
113
|
+
page.goto(self.url, wait_until="domcontentloaded")
|
|
114
|
+
except Error:
|
|
115
|
+
self.logger.warning("initial_navigation_failed", url=self.url)
|
|
116
|
+
|
|
117
|
+
self.logger.info(
|
|
118
|
+
"recorder_ready",
|
|
119
|
+
commands="split: new function block, clear: remove last action, quit: stop and generate",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def terminal_listener() -> None:
|
|
123
|
+
while not self.stop_recording:
|
|
124
|
+
try:
|
|
125
|
+
cmd = input().strip().lower()
|
|
126
|
+
if cmd in {"quit", ""}:
|
|
127
|
+
self.stop_recording = True
|
|
128
|
+
break
|
|
129
|
+
if cmd == "split":
|
|
130
|
+
self.workflow_manager.split_workflow()
|
|
131
|
+
self.logger.info("workflow_split")
|
|
132
|
+
elif cmd == "clear":
|
|
133
|
+
self.workflow_manager.clear_last_action()
|
|
134
|
+
self.logger.info("workflow_action_cleared")
|
|
135
|
+
else:
|
|
136
|
+
self.logger.warning("unknown_command", command=cmd)
|
|
137
|
+
except (KeyboardInterrupt, EOFError):
|
|
138
|
+
self.stop_recording = True
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
threading.Thread(target=terminal_listener, daemon=True).start()
|
|
142
|
+
while not self.stop_recording:
|
|
143
|
+
try:
|
|
144
|
+
page.wait_for_timeout(100)
|
|
145
|
+
except Error:
|
|
146
|
+
break
|
|
147
|
+
try:
|
|
148
|
+
context.close()
|
|
149
|
+
browser.close()
|
|
150
|
+
except Error:
|
|
151
|
+
self.logger.warning("browser_close_failed")
|
|
152
|
+
except KeyboardInterrupt:
|
|
153
|
+
interrupted = True
|
|
154
|
+
self.stop_recording = True
|
|
155
|
+
self.logger.info("recorder_interrupted", url=self.url)
|
|
156
|
+
|
|
157
|
+
self.logger.info("recorder_stopped", url=self.url, interrupted=interrupted)
|
|
158
|
+
return not interrupted
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import Action, Workflow
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WorkflowManager:
|
|
7
|
+
"""Collect and shape recorded actions into workflows."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._workflows: list[Workflow] = [Workflow()]
|
|
11
|
+
|
|
12
|
+
def add_action(self, action: dict) -> None:
|
|
13
|
+
normalized = Action.from_mapping(action)
|
|
14
|
+
if not normalized.type:
|
|
15
|
+
return
|
|
16
|
+
if normalized.type not in {"click", "fill", "select", "get"}:
|
|
17
|
+
return
|
|
18
|
+
if (
|
|
19
|
+
normalized.type == "click"
|
|
20
|
+
and not normalized.id
|
|
21
|
+
and not normalized.text
|
|
22
|
+
and not normalized.class_name
|
|
23
|
+
and normalized.tag_name in {"span", "div", "i", "b", "p"}
|
|
24
|
+
):
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
current = self._workflows[-1].actions
|
|
28
|
+
if current:
|
|
29
|
+
last = current[-1]
|
|
30
|
+
if (
|
|
31
|
+
last.type == normalized.type
|
|
32
|
+
and last.id == normalized.id
|
|
33
|
+
and last.tag_name == normalized.tag_name
|
|
34
|
+
and last.text == normalized.text
|
|
35
|
+
):
|
|
36
|
+
return
|
|
37
|
+
if normalized.type in {"fill", "select"} and last.type == "click":
|
|
38
|
+
if (
|
|
39
|
+
last.id == normalized.id and last.tag_name == normalized.tag_name
|
|
40
|
+
) or (not normalized.id and last.tag_name == normalized.tag_name):
|
|
41
|
+
current.pop()
|
|
42
|
+
|
|
43
|
+
current.append(normalized)
|
|
44
|
+
|
|
45
|
+
def split_workflow(self) -> None:
|
|
46
|
+
self._workflows.append(Workflow())
|
|
47
|
+
|
|
48
|
+
def clear_last_action(self) -> None:
|
|
49
|
+
current = self._workflows[-1].actions
|
|
50
|
+
if current:
|
|
51
|
+
current.pop()
|
|
52
|
+
|
|
53
|
+
def get_workflows(self) -> list[Workflow]:
|
|
54
|
+
return [workflow for workflow in self._workflows if workflow.actions]
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def deduce_workflow_name(actions: list[Action], index: int) -> str:
|
|
58
|
+
if not actions:
|
|
59
|
+
return f"sequence_{index}"
|
|
60
|
+
block_text = " ".join(f"{act.text} {act.id}" for act in actions).lower()
|
|
61
|
+
if "log" in block_text or "signin" in block_text:
|
|
62
|
+
return "log_in"
|
|
63
|
+
if "add" in block_text or "create" in block_text:
|
|
64
|
+
if (
|
|
65
|
+
"job" in block_text
|
|
66
|
+
or "backup" in block_text
|
|
67
|
+
or "retention" in block_text
|
|
68
|
+
):
|
|
69
|
+
return "create_backup_job"
|
|
70
|
+
return "create_item"
|
|
71
|
+
for act in reversed(actions):
|
|
72
|
+
if act.type == "click":
|
|
73
|
+
raw_name = act.text.strip() or act.id.strip()
|
|
74
|
+
if raw_name:
|
|
75
|
+
cleaned = "".join(
|
|
76
|
+
c if c.isalnum() else "_" for c in raw_name
|
|
77
|
+
).lower()
|
|
78
|
+
return "_".join(filter(None, cleaned.split("_")))[:30]
|
|
79
|
+
return f"sequence_{index}"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class Action:
|
|
9
|
+
"""Normalized action captured from the browser."""
|
|
10
|
+
|
|
11
|
+
type: str
|
|
12
|
+
tag_name: str = ""
|
|
13
|
+
id: str = ""
|
|
14
|
+
class_name: str = ""
|
|
15
|
+
text: str = ""
|
|
16
|
+
is_lambda_role: bool = False
|
|
17
|
+
value: str = ""
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_mapping(cls, action: dict[str, Any]) -> "Action":
|
|
21
|
+
"""Build an action from raw recorder payload data."""
|
|
22
|
+
|
|
23
|
+
return cls(
|
|
24
|
+
type=str(action.get("type", "")).strip().lower(),
|
|
25
|
+
tag_name=str(action.get("tagName", "") or "").strip().lower(),
|
|
26
|
+
id=str(action.get("id", "") or "").strip(),
|
|
27
|
+
class_name=str(action.get("className", "") or "").strip(),
|
|
28
|
+
text=str(action.get("text", "") or "").replace("\xa0", " ").strip(),
|
|
29
|
+
is_lambda_role=bool(action.get("isLambdaRole", False)),
|
|
30
|
+
value=str(action.get("value", "") or ""),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class Workflow:
|
|
36
|
+
"""Ordered actions that belong to one generated workflow."""
|
|
37
|
+
|
|
38
|
+
actions: list[Action] = field(default_factory=list)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LocatorSanitizer:
|
|
8
|
+
"""Normalize text and build safe Python identifiers."""
|
|
9
|
+
|
|
10
|
+
READABLE_TAGS = {
|
|
11
|
+
"label",
|
|
12
|
+
"p",
|
|
13
|
+
"span",
|
|
14
|
+
"div",
|
|
15
|
+
"li",
|
|
16
|
+
"td",
|
|
17
|
+
"th",
|
|
18
|
+
"h1",
|
|
19
|
+
"h2",
|
|
20
|
+
"h3",
|
|
21
|
+
"h4",
|
|
22
|
+
"h5",
|
|
23
|
+
"h6",
|
|
24
|
+
"strong",
|
|
25
|
+
"small",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def normalize_text(value: Any) -> str:
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
text = str(value).replace("\xa0", " ").strip()
|
|
33
|
+
return " ".join(text.split())
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def escape_selector_value(value: str) -> str:
|
|
37
|
+
return value.replace("\\", "\\\\").replace("'", "\\'")
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def quote_text_selector(value: str) -> str:
|
|
41
|
+
return f"text='{LocatorSanitizer.escape_selector_value(value)}'"
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def sanitize_var_name(text: str) -> str:
|
|
45
|
+
cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", str(text).strip())
|
|
46
|
+
cleaned = "_".join(filter(None, cleaned.split("_"))).upper()
|
|
47
|
+
if not cleaned:
|
|
48
|
+
cleaned = "ELEMENT"
|
|
49
|
+
if cleaned[0].isdigit():
|
|
50
|
+
cleaned = f"EL_{cleaned}"
|
|
51
|
+
return cleaned
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def sanitize_param_name(id_str: str) -> str:
|
|
55
|
+
if "." in id_str:
|
|
56
|
+
id_str = id_str.split(".")[-1]
|
|
57
|
+
s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", id_str)
|
|
58
|
+
cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
59
|
+
cleaned = "".join([c if c.isalnum() else "_" for c in cleaned])
|
|
60
|
+
return "_".join(filter(None, cleaned.split("_"))) or "val"
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def unique_name(base_name: str, used_names: set[str]) -> str:
|
|
64
|
+
candidate = base_name
|
|
65
|
+
suffix = 2
|
|
66
|
+
while candidate in used_names:
|
|
67
|
+
candidate = f"{base_name}_{suffix}"
|
|
68
|
+
suffix += 1
|
|
69
|
+
used_names.add(candidate)
|
|
70
|
+
return candidate
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: playforge
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Interactive Playwright recorder that turns browser actions into code
|
|
5
|
+
Author-email: "ytcalifax (98w)" <hello@98w.eu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ytcalifax/playforge
|
|
8
|
+
Project-URL: Repository, https://github.com/ytcalifax/playforge.git
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/ytcalifax/playforge/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/ytcalifax/playforge/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: playwright,page-object,recorder,testing,automation
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE.md
|
|
27
|
+
Requires-Dist: playwright>=1.62.0.0
|
|
28
|
+
Requires-Dist: ruff>=0.16.4.0
|
|
29
|
+
Requires-Dist: structlog>=26.1.0.0
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# ๐ญ PlayForge
|
|
33
|
+
|
|
34
|
+
> **A Playwright recorder that turns browser actions into reusable Page Object code.**
|
|
35
|
+
|
|
36
|
+
PlayForge records clicks, fills, selects, and reads from a browser session, then writes the result out as Python code you can keep using. It is meant for the boring part of browser automation: set up a page, click through the flow once, and get a generated page object you can reuse instead of hand-writing locators over and over.
|
|
37
|
+
|
|
38
|
+
The project is designed to run as a normal CLI after installation. Install it globally, point it at a URL, interact with the page, and let it capture the flow. When you quit, it writes the generated code to disk.
|
|
39
|
+
|
|
40
|
+
## โจ Features
|
|
41
|
+
|
|
42
|
+
- **๐ฌ Browser Recording**: Capture real user actions from a live page, including clicks, fills, selects, and text reads.
|
|
43
|
+
- **๐งฉ Page Object Generation**: Turn recorded workflows into Python page objects with reusable methods.
|
|
44
|
+
- **๐งน Workflow Splitting**: Split one recording session into multiple generated methods when a flow gets too long.
|
|
45
|
+
- **๐ชต Structured Logging**: Uses `structlog` for simple, consistent CLI and runtime logs.
|
|
46
|
+
- **โก CLI Ready**: Install it globally and run `playforge --help` or `playforge <url>`.
|
|
47
|
+
|
|
48
|
+
## ๐ฅ Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install playforge
|
|
52
|
+
playwright install
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or, to install directly from source:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install .
|
|
59
|
+
playwright install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If you want to use it from a local checkout while developing, install it in editable mode:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e .
|
|
66
|
+
playwright install
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You need `playwright install` so Playwright downloads an actual browser. Without that, the CLI can install fine but has nothing to launch.
|
|
70
|
+
|
|
71
|
+
## ๐ Quick Start
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
playforge https://example.com
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The recorder opens a browser window, waits for you to interact with the page, and collects actions until you quit. Use `split` in the terminal if you want to break the recording into another generated method.
|
|
78
|
+
|
|
79
|
+
### Help
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
playforge --help
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Output file
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
playforge https://example.com -o generated_page.py
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### What gets recorded
|
|
92
|
+
|
|
93
|
+
PlayForge watches for:
|
|
94
|
+
|
|
95
|
+
- clicks on buttons, links, and other interactive elements
|
|
96
|
+
- fills on input fields and text areas
|
|
97
|
+
- select changes
|
|
98
|
+
- double-click reads on readable text elements
|
|
99
|
+
|
|
100
|
+
Each captured workflow becomes a method in the generated page object. Repeated actions are collapsed where possible so the output stays usable instead of turning into a wall of duplicate steps.
|
|
101
|
+
|
|
102
|
+
## ๐ Requirements
|
|
103
|
+
|
|
104
|
+
- Python **3.10+**
|
|
105
|
+
- `playwright`
|
|
106
|
+
- `structlog`
|
|
107
|
+
- `ruff`
|
|
108
|
+
|
|
109
|
+
## ๐ค Contributing
|
|
110
|
+
|
|
111
|
+
Issues and pull requests are welcome. If you hit a weird recorder edge case, open an issue with the page flow and the generated output.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
*Built for browser automation and code generation. MIT Licensed.*
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
playforge/__init__.py
|
|
5
|
+
playforge/playforge.py
|
|
6
|
+
playforge.egg-info/PKG-INFO
|
|
7
|
+
playforge.egg-info/SOURCES.txt
|
|
8
|
+
playforge.egg-info/dependency_links.txt
|
|
9
|
+
playforge.egg-info/entry_points.txt
|
|
10
|
+
playforge.egg-info/requires.txt
|
|
11
|
+
playforge.egg-info/top_level.txt
|
|
12
|
+
playforge/cli/__init__.py
|
|
13
|
+
playforge/cli/main.py
|
|
14
|
+
playforge/generation/__init__.py
|
|
15
|
+
playforge/generation/render/__init__.py
|
|
16
|
+
playforge/generation/render/generator.py
|
|
17
|
+
playforge/logger/__init__.py
|
|
18
|
+
playforge/logger/logger.py
|
|
19
|
+
playforge/recording/__init__.py
|
|
20
|
+
playforge/recording/capture/__init__.py
|
|
21
|
+
playforge/recording/capture/recorder.py
|
|
22
|
+
playforge/workflow/__init__.py
|
|
23
|
+
playforge/workflow/manager.py
|
|
24
|
+
playforge/workflow/models.py
|
|
25
|
+
playforge/workflow/sanitizer.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
playforge
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "playforge"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Interactive Playwright recorder that turns browser actions into code"
|
|
9
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
|
+
authors = [{name = "ytcalifax (98w)", email = "hello@98w.eu"}]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE.md"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Natural Language :: English",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Testing",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Topic :: Utilities",
|
|
27
|
+
]
|
|
28
|
+
keywords = ["playwright", "page-object", "recorder", "testing", "automation"]
|
|
29
|
+
dependencies = ["playwright>=1.62.0.0", "ruff>=0.16.4.0", "structlog>=26.1.0.0"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
playforge = "playforge.cli.main:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ytcalifax/playforge"
|
|
36
|
+
Repository = "https://github.com/ytcalifax/playforge.git"
|
|
37
|
+
"Bug Tracker" = "https://github.com/ytcalifax/playforge/issues"
|
|
38
|
+
Changelog = "https://github.com/ytcalifax/playforge/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["."]
|
|
42
|
+
include = ["playforge*"]
|
|
43
|
+
exclude = ["*__pycache__*"]
|