sel2pw 0.1.1__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.
- sel2pw-0.1.1/LICENSE +21 -0
- sel2pw-0.1.1/PKG-INFO +181 -0
- sel2pw-0.1.1/README.md +156 -0
- sel2pw-0.1.1/pyproject.toml +34 -0
- sel2pw-0.1.1/setup.cfg +4 -0
- sel2pw-0.1.1/src/sel2pw/__init__.py +3 -0
- sel2pw-0.1.1/src/sel2pw/agent.py +230 -0
- sel2pw-0.1.1/src/sel2pw/cli.py +187 -0
- sel2pw-0.1.1/src/sel2pw/gates.py +289 -0
- sel2pw-0.1.1/src/sel2pw/inventory.py +442 -0
- sel2pw-0.1.1/src/sel2pw/ledger.py +75 -0
- sel2pw-0.1.1/src/sel2pw/llm/__init__.py +102 -0
- sel2pw-0.1.1/src/sel2pw/llm/anthropic_provider.py +160 -0
- sel2pw-0.1.1/src/sel2pw/llm/base.py +85 -0
- sel2pw-0.1.1/src/sel2pw/llm/cache.py +130 -0
- sel2pw-0.1.1/src/sel2pw/llm/openai_provider.py +138 -0
- sel2pw-0.1.1/src/sel2pw/manifest.py +93 -0
- sel2pw-0.1.1/src/sel2pw/mcp_server.py +258 -0
- sel2pw-0.1.1/src/sel2pw/prompts/__init__.py +63 -0
- sel2pw-0.1.1/src/sel2pw/prompts/system_prompt.md +76 -0
- sel2pw-0.1.1/src/sel2pw/reports.py +164 -0
- sel2pw-0.1.1/src/sel2pw/tools.py +325 -0
- sel2pw-0.1.1/src/sel2pw/validate.py +124 -0
- sel2pw-0.1.1/src/sel2pw/workspace.py +192 -0
- sel2pw-0.1.1/src/sel2pw.egg-info/PKG-INFO +181 -0
- sel2pw-0.1.1/src/sel2pw.egg-info/SOURCES.txt +35 -0
- sel2pw-0.1.1/src/sel2pw.egg-info/dependency_links.txt +1 -0
- sel2pw-0.1.1/src/sel2pw.egg-info/entry_points.txt +3 -0
- sel2pw-0.1.1/src/sel2pw.egg-info/requires.txt +20 -0
- sel2pw-0.1.1/src/sel2pw.egg-info/top_level.txt +1 -0
- sel2pw-0.1.1/tests/test_agent.py +121 -0
- sel2pw-0.1.1/tests/test_cache.py +65 -0
- sel2pw-0.1.1/tests/test_gates_and_reports.py +106 -0
- sel2pw-0.1.1/tests/test_inventory.py +60 -0
- sel2pw-0.1.1/tests/test_mcp_server.py +72 -0
- sel2pw-0.1.1/tests/test_providers.py +168 -0
- sel2pw-0.1.1/tests/test_workspace_and_tools.py +80 -0
sel2pw-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shan Konduru
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sel2pw-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sel2pw
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Selenium Java -> Python Playwright migration agent (CLI, MCP server, LLM-agnostic agent)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: anthropic
|
|
10
|
+
Requires-Dist: anthropic>=1.0; extra == "anthropic"
|
|
11
|
+
Provides-Extra: openai
|
|
12
|
+
Requires-Dist: openai>=1.40; extra == "openai"
|
|
13
|
+
Provides-Extra: mcp
|
|
14
|
+
Requires-Dist: mcp>=2.0; extra == "mcp"
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Requires-Dist: anthropic>=1.0; extra == "all"
|
|
17
|
+
Requires-Dist: openai>=1.40; extra == "all"
|
|
18
|
+
Requires-Dist: mcp>=2.0; extra == "all"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: anthropic>=1.0; extra == "dev"
|
|
21
|
+
Requires-Dist: openai>=1.40; extra == "dev"
|
|
22
|
+
Requires-Dist: mcp>=2.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# sel2pw — Selenium Java → Python Playwright migration agent
|
|
27
|
+
|
|
28
|
+
[](https://github.com/ShanKonduru/selenium2playwright/actions/workflows/tests.yml)
|
|
29
|
+
|
|
30
|
+
Implements the agent described in
|
|
31
|
+
[`selenium_java_to_playwright_migration_agent_system_prompt.md`](selenium_java_to_playwright_migration_agent_system_prompt.md)
|
|
32
|
+
three ways, all on one shared core:
|
|
33
|
+
|
|
34
|
+
| Surface | Command | Who does the reasoning |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| CLI | `sel2pw inventory / migrate / validate` | Built-in agent + any LLM provider |
|
|
37
|
+
| MCP server | `sel2pw mcp` (or `sel2pw-mcp`) | The MCP host's LLM (Claude Code/Desktop, Copilot, Cursor…), or the built-in agent via `run_migration_agent` |
|
|
38
|
+
| Library | `MigrationAgent(get_provider(...), Workspace.create(...)).run()` | Any `LLMProvider` |
|
|
39
|
+
|
|
40
|
+
## Architecture
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
src/sel2pw/
|
|
44
|
+
workspace.py sandbox: named roots (source=ro, target=rw, --reference=ro), path-escape checks, secret redaction
|
|
45
|
+
inventory.py deterministic Java scan: tests (IDs "Class#method"), lifecycle hooks, page objects,
|
|
46
|
+
dependencies, locators, data-provider rows, constructs to flag (waits, frames, alerts, JS, ...)
|
|
47
|
+
validate.py compile (in-process), pytest --collect-only, pytest run (opt-in only)
|
|
48
|
+
ledger.py structured mappings + issues recorded by the agent
|
|
49
|
+
gates.py deterministic acceptance gates
|
|
50
|
+
reports.py code-generated migration_map.md / migration_validation.md
|
|
51
|
+
manifest.py run fingerprint + manifest under <target>/.sel2pw/runs/
|
|
52
|
+
tools.py ONE tool registry + input validation, used by both the agent and the MCP server
|
|
53
|
+
agent.py provider-agnostic loop (append-only history, gate-driven repair rounds, finalize)
|
|
54
|
+
llm/ base.py (neutral messages) + anthropic/openai adapters + registry + cache.py (record/replay)
|
|
55
|
+
prompts/ system_prompt.md (extracted from the spec) + static TOOL LAYER appendix
|
|
56
|
+
cli.py, mcp_server.py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The spec says *"prompt text alone is not a security boundary"*, so the tool layer enforces it:
|
|
60
|
+
the source root is never writable; no path can leave its root; secrets are redacted before reaching any LLM;
|
|
61
|
+
overwriting a target file that existed before the run is recorded and reported; browser execution
|
|
62
|
+
(`run_check kind=run`) is refused unless the operator passes `--allow-browser-tests`.
|
|
63
|
+
There is no generic shell tool.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[all]" # or .[anthropic] / .[openai] / .[mcp]; .[dev] adds pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## CLI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Deterministic inventory (no LLM, free)
|
|
75
|
+
sel2pw inventory path/to/selenium-project --out inventory.md
|
|
76
|
+
|
|
77
|
+
# Full migration with the built-in agent
|
|
78
|
+
sel2pw migrate --source path/to/selenium-project --target path/to/innoharmony-repo \
|
|
79
|
+
--task "Migrate LoginTest and its page objects" --scope "LoginTest#*"
|
|
80
|
+
|
|
81
|
+
# Static checks on migrated code (add --collect for pytest --collect-only)
|
|
82
|
+
sel2pw validate path/to/innoharmony-repo --collect --python path/to/repo/.venv/Scripts/python
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Useful `migrate` flags: `--reference innoharmony=path/to/framework` (extra read-only root),
|
|
86
|
+
`--allow-browser-tests` (approved non-prod env only), `--python` (target repo's interpreter for pytest),
|
|
87
|
+
`--scope`, `--max-repairs`, `--gate-collect`, `--report-dir`, `--cache`, `--max-turns`, `--system-prompt FILE`.
|
|
88
|
+
Exit code is 0 only when the acceptance gates pass.
|
|
89
|
+
|
|
90
|
+
## Determinism
|
|
91
|
+
|
|
92
|
+
LLM output can't be made bit-for-bit deterministic. Claude Opus 5.5 rejects `temperature`, and OpenAI's `seed`
|
|
93
|
+
is only best effort. sel2pw makes every *outcome* deterministic and every *run* reproducible:
|
|
94
|
+
|
|
95
|
+
| Layer | Mechanism |
|
|
96
|
+
|---|---|
|
|
97
|
+
| **What counts as "done"** | Deterministic acceptance gates: `scope_coverage` (every in-scope test mapped), `mapping_integrity` (target file + symbol exist, blocked/excluded have reasons), `assertion_parity`, `parametrize_parity` (data-provider rows = `parametrize` cases), `disabled_preserved` (stays skipped), `compile`, `lint` (no unjustified sleeps/`networkidle`, no secrets or redacted placeholders), optional `collect`. Failures are sent back to the model for up to `--max-repairs` rounds, and the run fails if they persist. |
|
|
98
|
+
| **Reports** | The agent records structured data (`record_mapping`, `record_issue`). It cannot write `migration_map.md` or `migration_validation.md`. Code renders both, with no timestamps, so identical inputs give byte-identical reports. Text the model wrote is shown in labelled "agent" columns or sections. |
|
|
99
|
+
| **Reproducing a run** | Record/replay cache (`--cache auto` by default) in `<target>/.sel2pw/cache`. It is keyed by a SHA-256 of the provider settings, system prompt, tool schemas and the whole conversation, including tool results. Re-running with the same inputs and the same starting target replays the run exactly without calling the model. `--cache replay` makes any deviation an error, which suits CI. `--cache off` forces a fresh attempt. Tool output is normalized (sorted listings, pytest timings stripped) so keys stay stable. |
|
|
100
|
+
| **Traceability** | Every run writes `<target>/.sel2pw/runs/<run_id>/`: `manifest.json` (model + settings, prompt/tools/task/source-tree/inventory hashes, target tree hash before/after, cache hits, usage, SDK versions), `gates.json`, `ledger.json`, `events.jsonl`. The stable part of the fingerprint is also in the validation report. |
|
|
101
|
+
|
|
102
|
+
Escape hatches the gates recognise (all appear in reports for reviewers): a `justification` on a mapping
|
|
103
|
+
(intentional assertion, case or skip difference), and line comments `# sel2pw: allow-sleep: <reason>`,
|
|
104
|
+
`# sel2pw: allow-networkidle: <reason>`, `# sel2pw: allow-secret` (test data, not credentials).
|
|
105
|
+
|
|
106
|
+
Add `.sel2pw/` to the target's `.gitignore`, or commit `.sel2pw/cache` if you want others to be able to replay your runs.
|
|
107
|
+
|
|
108
|
+
### LLM providers (`sel2pw providers`)
|
|
109
|
+
|
|
110
|
+
| `--provider` | Notes |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `anthropic` (default) | Default model `claude-opus-5-5`. Auth: `ANTHROPIC_API_KEY` or `ant auth login`. |
|
|
113
|
+
| `openai` | `OPENAI_API_KEY`, `--model` required |
|
|
114
|
+
| `azure-openai` | `AZURE_OPENAI_BASE_URL` (v1 endpoint) + `AZURE_OPENAI_API_KEY`; `--model` = deployment |
|
|
115
|
+
| `gemini` | `GEMINI_API_KEY`, OpenAI-compatible endpoint, `--model` required |
|
|
116
|
+
| `ollama` | Local, `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`); use a tool-capable model |
|
|
117
|
+
| `openai-compatible` | vLLM, LM Studio, OpenRouter, LiteLLM… via `SEL2PW_BASE_URL` / `SEL2PW_API_KEY` |
|
|
118
|
+
| `my_pkg.module:MyProvider` | Plugin: any `sel2pw.llm.base.LLMProvider` subclass |
|
|
119
|
+
|
|
120
|
+
Defaults can come from env: `SEL2PW_PROVIDER`, `SEL2PW_MODEL`, `SEL2PW_EFFORT`.
|
|
121
|
+
|
|
122
|
+
Anthropic adapter specifics: streaming with `eager_input_streaming` tools (whole-file writes stream as they are
|
|
123
|
+
generated; inputs are re-validated before running), adaptive thinking with `--effort` (default `high`),
|
|
124
|
+
automatic prompt caching, and **server-side refusal fallbacks enabled by default** (`fallbacks="default"`).
|
|
125
|
+
Pass `--no-fallbacks` to turn them off; you must do that on Bedrock/Vertex/Foundry gateways, where the parameter is rejected.
|
|
126
|
+
|
|
127
|
+
### Adding a provider
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from sel2pw.llm.base import LLMProvider, AssistantTurn, ToolCall
|
|
131
|
+
|
|
132
|
+
class MyProvider(LLMProvider):
|
|
133
|
+
name = "mine"
|
|
134
|
+
def __init__(self, model=None, **kw):
|
|
135
|
+
super().__init__(model or "my-model")
|
|
136
|
+
def complete(self, system, messages, tools) -> AssistantTurn:
|
|
137
|
+
... # convert UserMessage / AssistantTurn(raw) / ToolResultsMessage to your API, call it,
|
|
138
|
+
# return AssistantTurn(text, [ToolCall(id, name, args)], stop_reason, raw=<native turn>)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
sel2pw migrate --provider my_pkg.providers:MyProvider ...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## MCP server
|
|
146
|
+
|
|
147
|
+
Tools: `java_inventory`, `list_files`, `read_file`, `search`, `write_file`, `edit_file`, `run_check`,
|
|
148
|
+
`record_mapping`, `record_issue`, `check_acceptance`, `finalize_migration` (gates + generated reports + run manifest),
|
|
149
|
+
`run_migration_agent` (hide with `--no-agent-tool`). Prompt: `migrate_selenium_to_playwright`.
|
|
150
|
+
Resource: `sel2pw://system-prompt`. The roots, `--scope` and gate options are fixed at server start, so a host
|
|
151
|
+
can't widen the scope. Host-driven runs get the same gates and generated reports through `finalize_migration`.
|
|
152
|
+
|
|
153
|
+
**Claude Code**
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
claude mcp add sel2pw -- sel2pw mcp --source C:/work/selenium-tests --target C:/work/innoharmony-tests
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**VS Code / Cursor / Claude Desktop** (`mcp.json` / `claude_desktop_config.json`)
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"mcpServers": {
|
|
164
|
+
"sel2pw": {
|
|
165
|
+
"command": "C:/myprojects/selenium2playwright/.venv/Scripts/sel2pw.exe",
|
|
166
|
+
"args": ["mcp", "--source", "C:/work/selenium-tests", "--target", "C:/work/innoharmony-tests"]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
HTTP transport: `sel2pw mcp ... --transport streamable-http`.
|
|
173
|
+
|
|
174
|
+
## Tests
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pytest -q
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
No test calls a real LLM. The provider adapters are tested against fake clients, and the MCP tests run the
|
|
181
|
+
server in-process.
|
sel2pw-0.1.1/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# sel2pw — Selenium Java → Python Playwright migration agent
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ShanKonduru/selenium2playwright/actions/workflows/tests.yml)
|
|
4
|
+
|
|
5
|
+
Implements the agent described in
|
|
6
|
+
[`selenium_java_to_playwright_migration_agent_system_prompt.md`](selenium_java_to_playwright_migration_agent_system_prompt.md)
|
|
7
|
+
three ways, all on one shared core:
|
|
8
|
+
|
|
9
|
+
| Surface | Command | Who does the reasoning |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| CLI | `sel2pw inventory / migrate / validate` | Built-in agent + any LLM provider |
|
|
12
|
+
| MCP server | `sel2pw mcp` (or `sel2pw-mcp`) | The MCP host's LLM (Claude Code/Desktop, Copilot, Cursor…), or the built-in agent via `run_migration_agent` |
|
|
13
|
+
| Library | `MigrationAgent(get_provider(...), Workspace.create(...)).run()` | Any `LLMProvider` |
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/sel2pw/
|
|
19
|
+
workspace.py sandbox: named roots (source=ro, target=rw, --reference=ro), path-escape checks, secret redaction
|
|
20
|
+
inventory.py deterministic Java scan: tests (IDs "Class#method"), lifecycle hooks, page objects,
|
|
21
|
+
dependencies, locators, data-provider rows, constructs to flag (waits, frames, alerts, JS, ...)
|
|
22
|
+
validate.py compile (in-process), pytest --collect-only, pytest run (opt-in only)
|
|
23
|
+
ledger.py structured mappings + issues recorded by the agent
|
|
24
|
+
gates.py deterministic acceptance gates
|
|
25
|
+
reports.py code-generated migration_map.md / migration_validation.md
|
|
26
|
+
manifest.py run fingerprint + manifest under <target>/.sel2pw/runs/
|
|
27
|
+
tools.py ONE tool registry + input validation, used by both the agent and the MCP server
|
|
28
|
+
agent.py provider-agnostic loop (append-only history, gate-driven repair rounds, finalize)
|
|
29
|
+
llm/ base.py (neutral messages) + anthropic/openai adapters + registry + cache.py (record/replay)
|
|
30
|
+
prompts/ system_prompt.md (extracted from the spec) + static TOOL LAYER appendix
|
|
31
|
+
cli.py, mcp_server.py
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The spec says *"prompt text alone is not a security boundary"*, so the tool layer enforces it:
|
|
35
|
+
the source root is never writable; no path can leave its root; secrets are redacted before reaching any LLM;
|
|
36
|
+
overwriting a target file that existed before the run is recorded and reported; browser execution
|
|
37
|
+
(`run_check kind=run`) is refused unless the operator passes `--allow-browser-tests`.
|
|
38
|
+
There is no generic shell tool.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e ".[all]" # or .[anthropic] / .[openai] / .[mcp]; .[dev] adds pytest
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## CLI
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Deterministic inventory (no LLM, free)
|
|
50
|
+
sel2pw inventory path/to/selenium-project --out inventory.md
|
|
51
|
+
|
|
52
|
+
# Full migration with the built-in agent
|
|
53
|
+
sel2pw migrate --source path/to/selenium-project --target path/to/innoharmony-repo \
|
|
54
|
+
--task "Migrate LoginTest and its page objects" --scope "LoginTest#*"
|
|
55
|
+
|
|
56
|
+
# Static checks on migrated code (add --collect for pytest --collect-only)
|
|
57
|
+
sel2pw validate path/to/innoharmony-repo --collect --python path/to/repo/.venv/Scripts/python
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Useful `migrate` flags: `--reference innoharmony=path/to/framework` (extra read-only root),
|
|
61
|
+
`--allow-browser-tests` (approved non-prod env only), `--python` (target repo's interpreter for pytest),
|
|
62
|
+
`--scope`, `--max-repairs`, `--gate-collect`, `--report-dir`, `--cache`, `--max-turns`, `--system-prompt FILE`.
|
|
63
|
+
Exit code is 0 only when the acceptance gates pass.
|
|
64
|
+
|
|
65
|
+
## Determinism
|
|
66
|
+
|
|
67
|
+
LLM output can't be made bit-for-bit deterministic. Claude Opus 5.5 rejects `temperature`, and OpenAI's `seed`
|
|
68
|
+
is only best effort. sel2pw makes every *outcome* deterministic and every *run* reproducible:
|
|
69
|
+
|
|
70
|
+
| Layer | Mechanism |
|
|
71
|
+
|---|---|
|
|
72
|
+
| **What counts as "done"** | Deterministic acceptance gates: `scope_coverage` (every in-scope test mapped), `mapping_integrity` (target file + symbol exist, blocked/excluded have reasons), `assertion_parity`, `parametrize_parity` (data-provider rows = `parametrize` cases), `disabled_preserved` (stays skipped), `compile`, `lint` (no unjustified sleeps/`networkidle`, no secrets or redacted placeholders), optional `collect`. Failures are sent back to the model for up to `--max-repairs` rounds, and the run fails if they persist. |
|
|
73
|
+
| **Reports** | The agent records structured data (`record_mapping`, `record_issue`). It cannot write `migration_map.md` or `migration_validation.md`. Code renders both, with no timestamps, so identical inputs give byte-identical reports. Text the model wrote is shown in labelled "agent" columns or sections. |
|
|
74
|
+
| **Reproducing a run** | Record/replay cache (`--cache auto` by default) in `<target>/.sel2pw/cache`. It is keyed by a SHA-256 of the provider settings, system prompt, tool schemas and the whole conversation, including tool results. Re-running with the same inputs and the same starting target replays the run exactly without calling the model. `--cache replay` makes any deviation an error, which suits CI. `--cache off` forces a fresh attempt. Tool output is normalized (sorted listings, pytest timings stripped) so keys stay stable. |
|
|
75
|
+
| **Traceability** | Every run writes `<target>/.sel2pw/runs/<run_id>/`: `manifest.json` (model + settings, prompt/tools/task/source-tree/inventory hashes, target tree hash before/after, cache hits, usage, SDK versions), `gates.json`, `ledger.json`, `events.jsonl`. The stable part of the fingerprint is also in the validation report. |
|
|
76
|
+
|
|
77
|
+
Escape hatches the gates recognise (all appear in reports for reviewers): a `justification` on a mapping
|
|
78
|
+
(intentional assertion, case or skip difference), and line comments `# sel2pw: allow-sleep: <reason>`,
|
|
79
|
+
`# sel2pw: allow-networkidle: <reason>`, `# sel2pw: allow-secret` (test data, not credentials).
|
|
80
|
+
|
|
81
|
+
Add `.sel2pw/` to the target's `.gitignore`, or commit `.sel2pw/cache` if you want others to be able to replay your runs.
|
|
82
|
+
|
|
83
|
+
### LLM providers (`sel2pw providers`)
|
|
84
|
+
|
|
85
|
+
| `--provider` | Notes |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `anthropic` (default) | Default model `claude-opus-5-5`. Auth: `ANTHROPIC_API_KEY` or `ant auth login`. |
|
|
88
|
+
| `openai` | `OPENAI_API_KEY`, `--model` required |
|
|
89
|
+
| `azure-openai` | `AZURE_OPENAI_BASE_URL` (v1 endpoint) + `AZURE_OPENAI_API_KEY`; `--model` = deployment |
|
|
90
|
+
| `gemini` | `GEMINI_API_KEY`, OpenAI-compatible endpoint, `--model` required |
|
|
91
|
+
| `ollama` | Local, `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`); use a tool-capable model |
|
|
92
|
+
| `openai-compatible` | vLLM, LM Studio, OpenRouter, LiteLLM… via `SEL2PW_BASE_URL` / `SEL2PW_API_KEY` |
|
|
93
|
+
| `my_pkg.module:MyProvider` | Plugin: any `sel2pw.llm.base.LLMProvider` subclass |
|
|
94
|
+
|
|
95
|
+
Defaults can come from env: `SEL2PW_PROVIDER`, `SEL2PW_MODEL`, `SEL2PW_EFFORT`.
|
|
96
|
+
|
|
97
|
+
Anthropic adapter specifics: streaming with `eager_input_streaming` tools (whole-file writes stream as they are
|
|
98
|
+
generated; inputs are re-validated before running), adaptive thinking with `--effort` (default `high`),
|
|
99
|
+
automatic prompt caching, and **server-side refusal fallbacks enabled by default** (`fallbacks="default"`).
|
|
100
|
+
Pass `--no-fallbacks` to turn them off; you must do that on Bedrock/Vertex/Foundry gateways, where the parameter is rejected.
|
|
101
|
+
|
|
102
|
+
### Adding a provider
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from sel2pw.llm.base import LLMProvider, AssistantTurn, ToolCall
|
|
106
|
+
|
|
107
|
+
class MyProvider(LLMProvider):
|
|
108
|
+
name = "mine"
|
|
109
|
+
def __init__(self, model=None, **kw):
|
|
110
|
+
super().__init__(model or "my-model")
|
|
111
|
+
def complete(self, system, messages, tools) -> AssistantTurn:
|
|
112
|
+
... # convert UserMessage / AssistantTurn(raw) / ToolResultsMessage to your API, call it,
|
|
113
|
+
# return AssistantTurn(text, [ToolCall(id, name, args)], stop_reason, raw=<native turn>)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
sel2pw migrate --provider my_pkg.providers:MyProvider ...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## MCP server
|
|
121
|
+
|
|
122
|
+
Tools: `java_inventory`, `list_files`, `read_file`, `search`, `write_file`, `edit_file`, `run_check`,
|
|
123
|
+
`record_mapping`, `record_issue`, `check_acceptance`, `finalize_migration` (gates + generated reports + run manifest),
|
|
124
|
+
`run_migration_agent` (hide with `--no-agent-tool`). Prompt: `migrate_selenium_to_playwright`.
|
|
125
|
+
Resource: `sel2pw://system-prompt`. The roots, `--scope` and gate options are fixed at server start, so a host
|
|
126
|
+
can't widen the scope. Host-driven runs get the same gates and generated reports through `finalize_migration`.
|
|
127
|
+
|
|
128
|
+
**Claude Code**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
claude mcp add sel2pw -- sel2pw mcp --source C:/work/selenium-tests --target C:/work/innoharmony-tests
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**VS Code / Cursor / Claude Desktop** (`mcp.json` / `claude_desktop_config.json`)
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"mcpServers": {
|
|
139
|
+
"sel2pw": {
|
|
140
|
+
"command": "C:/myprojects/selenium2playwright/.venv/Scripts/sel2pw.exe",
|
|
141
|
+
"args": ["mcp", "--source", "C:/work/selenium-tests", "--target", "C:/work/innoharmony-tests"]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
HTTP transport: `sel2pw mcp ... --transport streamable-http`.
|
|
148
|
+
|
|
149
|
+
## Tests
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pytest -q
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
No test calls a real LLM. The provider adapters are tested against fake clients, and the MCP tests run the
|
|
156
|
+
server in-process.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sel2pw"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Selenium Java -> Python Playwright migration agent (CLI, MCP server, LLM-agnostic agent)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
anthropic = ["anthropic>=1.0"]
|
|
17
|
+
openai = ["openai>=1.40"]
|
|
18
|
+
mcp = ["mcp>=2.0"]
|
|
19
|
+
all = ["anthropic>=1.0", "openai>=1.40", "mcp>=2.0"]
|
|
20
|
+
dev = ["anthropic>=1.0", "openai>=1.40", "mcp>=2.0", "pytest>=8"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
sel2pw = "sel2pw.cli:main"
|
|
24
|
+
sel2pw-mcp = "sel2pw.mcp_server:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-data]
|
|
30
|
+
sel2pw = ["prompts/*.md"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
norecursedirs = ["fixtures"]
|
sel2pw-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Provider-agnostic migration agent loop.
|
|
2
|
+
|
|
3
|
+
The model works through the tools; when it stops, the harness runs the
|
|
4
|
+
deterministic acceptance gates. Failures go back to the model as a repair
|
|
5
|
+
request (up to ``max_repairs`` rounds). Reports and the run manifest are then
|
|
6
|
+
generated by code, never by the model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from .gates import GateReport
|
|
15
|
+
from .llm.base import (
|
|
16
|
+
AssistantTurn,
|
|
17
|
+
LLMProvider,
|
|
18
|
+
Message,
|
|
19
|
+
ToolDef,
|
|
20
|
+
ToolResult,
|
|
21
|
+
ToolResultsMessage,
|
|
22
|
+
UserMessage,
|
|
23
|
+
)
|
|
24
|
+
from .manifest import RunRecorder, sha256_json, sha256_text
|
|
25
|
+
from .prompts import system_prompt, task_message
|
|
26
|
+
from .tools import ToolBox
|
|
27
|
+
from .workspace import Workspace
|
|
28
|
+
|
|
29
|
+
EventHandler = Callable[[str, dict], None]
|
|
30
|
+
|
|
31
|
+
MAX_TOKEN_CONTINUATIONS = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AgentResult:
|
|
36
|
+
final_text: str
|
|
37
|
+
stop: str # accepted | gates_failed | refusal | max_turns | max_tokens
|
|
38
|
+
turns: int
|
|
39
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
40
|
+
files_written: list[str] = field(default_factory=list)
|
|
41
|
+
modified_existing: list[str] = field(default_factory=list)
|
|
42
|
+
checks_run: list[str] = field(default_factory=list)
|
|
43
|
+
detail: str | None = None
|
|
44
|
+
gates: GateReport | None = None
|
|
45
|
+
reports: list[str] = field(default_factory=list)
|
|
46
|
+
repairs: int = 0
|
|
47
|
+
run_dir: str | None = None
|
|
48
|
+
fingerprint: dict = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def accepted(self) -> bool:
|
|
52
|
+
return self.stop == "accepted"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MigrationAgent:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
provider: LLMProvider,
|
|
59
|
+
workspace: Workspace,
|
|
60
|
+
*,
|
|
61
|
+
allow_browser_tests: bool = False,
|
|
62
|
+
python: str | None = None,
|
|
63
|
+
max_turns: int = 80,
|
|
64
|
+
max_repairs: int = 3,
|
|
65
|
+
scope: list[str] | None = None,
|
|
66
|
+
gate_collect: bool = False,
|
|
67
|
+
report_dir: str = "",
|
|
68
|
+
system_prompt_path: str | None = None,
|
|
69
|
+
record_run: bool = True,
|
|
70
|
+
on_event: EventHandler | None = None,
|
|
71
|
+
):
|
|
72
|
+
self.provider = provider
|
|
73
|
+
self.ws = workspace
|
|
74
|
+
self.allow_browser_tests = allow_browser_tests
|
|
75
|
+
self.toolbox = ToolBox(workspace, allow_browser_tests=allow_browser_tests, python=python,
|
|
76
|
+
scope=scope, gate_collect=gate_collect, report_dir=report_dir)
|
|
77
|
+
self.max_turns = max_turns
|
|
78
|
+
self.max_repairs = max_repairs
|
|
79
|
+
self.system = system_prompt(system_prompt_path)
|
|
80
|
+
self.recorder = RunRecorder(workspace.root("target").path) if record_run else None
|
|
81
|
+
user_handler = on_event or (lambda kind, data: None)
|
|
82
|
+
|
|
83
|
+
def emit(kind: str, data: dict) -> None:
|
|
84
|
+
if self.recorder:
|
|
85
|
+
self.recorder.event(kind, data)
|
|
86
|
+
user_handler(kind, data)
|
|
87
|
+
|
|
88
|
+
self.on_event = emit
|
|
89
|
+
self.tools = [ToolDef(s.name, s.description, s.input_schema) for s in self.toolbox.specs.values()]
|
|
90
|
+
|
|
91
|
+
# -- fingerprint ---------------------------------------------------
|
|
92
|
+
def fingerprint(self, task_text: str) -> dict:
|
|
93
|
+
from .manifest import tree_hash
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
**{f"llm.{k}": v for k, v in self.provider.fingerprint().items()},
|
|
97
|
+
"system_prompt_sha256": sha256_text(self.system),
|
|
98
|
+
"tools_sha256": sha256_json([[t.name, t.description, t.input_schema] for t in self.tools]),
|
|
99
|
+
"task_sha256": sha256_text(task_text),
|
|
100
|
+
"source_tree_sha256": tree_hash(self.ws.root("source").path),
|
|
101
|
+
"inventory_sha256": sha256_text(self.toolbox.inventory.to_json()),
|
|
102
|
+
"scope": ",".join(self.toolbox.scope or ["*"]),
|
|
103
|
+
"gate_collect": self.toolbox.gate_collect,
|
|
104
|
+
"browser_tests_allowed": self.allow_browser_tests,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# -- loop ----------------------------------------------------------
|
|
108
|
+
def run(self, task: str | None = None) -> AgentResult:
|
|
109
|
+
from .manifest import tree_hash
|
|
110
|
+
|
|
111
|
+
first = task_message(task, self.ws.describe(), self.allow_browser_tests, self.toolbox.scope)
|
|
112
|
+
fingerprint = self.fingerprint(first)
|
|
113
|
+
target_before = tree_hash(self.ws.root("target").path)
|
|
114
|
+
messages: list[Message] = [UserMessage(first)]
|
|
115
|
+
usage: dict[str, int] = {}
|
|
116
|
+
continuations = repairs = 0
|
|
117
|
+
last: AssistantTurn | None = None
|
|
118
|
+
stop, detail, gates = "max_turns", f"stopped after {self.max_turns} turns", None
|
|
119
|
+
turn = 0
|
|
120
|
+
|
|
121
|
+
for turn in range(1, self.max_turns + 1):
|
|
122
|
+
last = self.provider.complete(self.system, messages, self.tools)
|
|
123
|
+
messages.append(last)
|
|
124
|
+
for k, v in last.usage.items():
|
|
125
|
+
usage[k] = usage.get(k, 0) + v
|
|
126
|
+
self.on_event("assistant", {"turn": turn, "text": last.text, "stop": last.stop_reason, "tool_calls": len(last.tool_calls)})
|
|
127
|
+
|
|
128
|
+
if last.stop_reason == "refusal":
|
|
129
|
+
stop, detail = "refusal", last.detail
|
|
130
|
+
break
|
|
131
|
+
if last.stop_reason == "pause":
|
|
132
|
+
continue # server paused a long turn; re-send to resume
|
|
133
|
+
|
|
134
|
+
if not last.tool_calls:
|
|
135
|
+
if last.stop_reason == "max_tokens" and continuations < MAX_TOKEN_CONTINUATIONS:
|
|
136
|
+
continuations += 1
|
|
137
|
+
messages.append(UserMessage("Your previous reply was cut off at the output limit. Continue from where you stopped."))
|
|
138
|
+
continue
|
|
139
|
+
if last.stop_reason == "max_tokens":
|
|
140
|
+
stop, detail = "max_tokens", "output limit reached repeatedly"
|
|
141
|
+
break
|
|
142
|
+
gates = self.toolbox.evaluate_gates()
|
|
143
|
+
self.on_event("gates", {"turn": turn, "passed": gates.passed,
|
|
144
|
+
"failed": [r.name for r in gates.results if r.status == "fail"]})
|
|
145
|
+
if gates.passed:
|
|
146
|
+
stop, detail = "accepted", None
|
|
147
|
+
break
|
|
148
|
+
if repairs >= self.max_repairs:
|
|
149
|
+
stop, detail = "gates_failed", f"gates still failing after {repairs} repair round(s)"
|
|
150
|
+
break
|
|
151
|
+
repairs += 1
|
|
152
|
+
messages.append(UserMessage(gates.feedback()))
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
messages.append(ToolResultsMessage(self._run_tools(turn, last)))
|
|
156
|
+
|
|
157
|
+
if gates is None or stop not in ("accepted", "gates_failed"):
|
|
158
|
+
gates = self.toolbox.evaluate_gates()
|
|
159
|
+
reports = self.toolbox.write_reports(gates, fingerprint, last.text if last else "")
|
|
160
|
+
result = AgentResult(
|
|
161
|
+
final_text=last.text if last else "",
|
|
162
|
+
stop=stop,
|
|
163
|
+
turns=turn,
|
|
164
|
+
usage=usage,
|
|
165
|
+
files_written=list(self.ws.written),
|
|
166
|
+
modified_existing=list(self.ws.modified_existing),
|
|
167
|
+
checks_run=list(self.toolbox.checks_run),
|
|
168
|
+
detail=detail,
|
|
169
|
+
gates=gates,
|
|
170
|
+
reports=reports,
|
|
171
|
+
repairs=repairs,
|
|
172
|
+
fingerprint=fingerprint,
|
|
173
|
+
)
|
|
174
|
+
if self.recorder:
|
|
175
|
+
self._write_manifest(result, target_before)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def _run_tools(self, turn: int, last: AssistantTurn) -> list[ToolResult]:
|
|
179
|
+
results = []
|
|
180
|
+
for call in last.tool_calls:
|
|
181
|
+
if last.stop_reason == "max_tokens":
|
|
182
|
+
# A truncated tool input can still parse as a valid partial object - never run it.
|
|
183
|
+
output, is_error = (
|
|
184
|
+
"Not executed: your reply hit the output limit, so this tool input may be truncated. "
|
|
185
|
+
"Re-issue the call; split large files into smaller write_file/edit_file calls.",
|
|
186
|
+
True,
|
|
187
|
+
)
|
|
188
|
+
elif call.parse_error:
|
|
189
|
+
output, is_error = f"Not executed: {call.parse_error}. Re-issue the call with valid JSON arguments.", True
|
|
190
|
+
else:
|
|
191
|
+
self.on_event("tool_call", {"turn": turn, "name": call.name, "arguments": _summarize_args(call.arguments)})
|
|
192
|
+
output, is_error = self.toolbox.call(call.name, call.arguments)
|
|
193
|
+
self.on_event("tool_result", {"turn": turn, "name": call.name, "is_error": is_error, "preview": output[:200]})
|
|
194
|
+
results.append(ToolResult(call.id, call.name, output, is_error))
|
|
195
|
+
return results
|
|
196
|
+
|
|
197
|
+
def _write_manifest(self, result: AgentResult, target_before: str) -> None:
|
|
198
|
+
from .manifest import tree_hash
|
|
199
|
+
|
|
200
|
+
assert self.recorder is not None
|
|
201
|
+
cache = {}
|
|
202
|
+
if hasattr(self.provider, "hits"):
|
|
203
|
+
cache = {"mode": getattr(self.provider, "mode", None), "hits": self.provider.hits, "misses": self.provider.misses}
|
|
204
|
+
self.recorder.write("ledger.json", self.toolbox.ledger.to_dict())
|
|
205
|
+
self.recorder.write("gates.json", result.gates.to_dict() if result.gates else {})
|
|
206
|
+
self.recorder.manifest(result.fingerprint, {
|
|
207
|
+
"stop": result.stop,
|
|
208
|
+
"detail": result.detail,
|
|
209
|
+
"turns": result.turns,
|
|
210
|
+
"repairs": result.repairs,
|
|
211
|
+
"usage": result.usage,
|
|
212
|
+
"cache": cache,
|
|
213
|
+
"files_written": result.files_written,
|
|
214
|
+
"modified_existing": result.modified_existing,
|
|
215
|
+
"reports": result.reports,
|
|
216
|
+
"checks_run": result.checks_run,
|
|
217
|
+
"target_tree_sha256_before": target_before,
|
|
218
|
+
"target_tree_sha256_after": tree_hash(self.ws.root("target").path),
|
|
219
|
+
})
|
|
220
|
+
result.run_dir = str(self.recorder.run_dir)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _summarize_args(args: object) -> dict:
|
|
224
|
+
if not isinstance(args, dict):
|
|
225
|
+
return {"_": str(args)[:80]}
|
|
226
|
+
out = {}
|
|
227
|
+
for k, v in args.items():
|
|
228
|
+
s = v if isinstance(v, (int, bool)) else str(v)
|
|
229
|
+
out[k] = s if not isinstance(s, str) or len(s) <= 80 else f"<{len(s)} chars>"
|
|
230
|
+
return out
|