ordel-cli 0.4.2__tar.gz → 0.4.4__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.
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/PKG-INFO +36 -21
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/README.md +35 -20
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/SKILL.md +2 -2
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/cli.py +24 -24
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/context.py +14 -3
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/coverage.py +2 -2
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/doctor.py +5 -5
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/explore.py +10 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/ingest.py +3 -3
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/mcp_server.py +22 -12
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/pom.py +1 -1
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/qa_plan.py +7 -7
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/record.py +3 -1
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/runner.py +3 -3
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/scenarios.py +14 -3
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/session_driver.cjs +28 -1
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/pyproject.toml +1 -1
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/.gitignore +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/CATALOG.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/LICENSE +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/PRIVACY.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/__init__.py +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/adopt.py +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/branding.py +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/capture.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/capture_extract.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/catalog.py +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/heal_service.py +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/human_record.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/mcp_supervisor.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/ordel_config.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/ordel_evidence.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/py.typed +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/record.cjs +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/communication-and-harnesses.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/execution-and-triage.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/intake-and-readiness.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/maintenance.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/scenarios-and-oracles.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/tools.md +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/session.py +0 -0
- {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ordel-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Ordel free CLI — local QA-automation for individual devs. Drives the deterministic ordel-engine and exposes it to a BYO coding agent over MCP. No account, no DB, no Ordel LLM.
|
|
5
5
|
Project-URL: Homepage, https://ordel.io
|
|
6
6
|
Author: Ordel
|
|
@@ -24,13 +24,28 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
# ordel (free CLI)
|
|
26
26
|
|
|
27
|
-
Local QA automation for individual devs
|
|
27
|
+
Local QA automation for individual devs: a persistent, deterministic "QA brain" your
|
|
28
28
|
own coding agent (Claude Code / Cursor / Copilot) drives over MCP. **No account, no DB,
|
|
29
29
|
no Ordel LLM.** Your agent is the brain; Ordel is the memory + determinism.
|
|
30
30
|
|
|
31
31
|
> Status: **early build**. The engine loop (map → record → generate → run) works
|
|
32
32
|
> locally today; a single-command `npx ordel` distribution is planned but not started.
|
|
33
33
|
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
You need [uv](https://docs.astral.sh/uv/). No separate install step: `uvx` fetches and runs
|
|
37
|
+
the CLI on demand. In your project folder:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uvx --from ordel-cli ordel doctor # checks Node, @playwright/test and Chromium, with fixes
|
|
41
|
+
uvx --from ordel-cli ordel init # creates .ordel/ and ORDEL.md
|
|
42
|
+
uvx --from ordel-cli ordel mcp-install --client claude-code --write # connects your agent
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Other clients: `--client cursor`, `copilot`, `codex` or `claude-desktop`.
|
|
46
|
+
To keep `ordel` on your PATH instead, run `uv tool install ordel-cli`; then every command
|
|
47
|
+
below works as plain `ordel ...`.
|
|
48
|
+
|
|
34
49
|
## What works today (local, anonymous)
|
|
35
50
|
|
|
36
51
|
```bash
|
|
@@ -119,12 +134,12 @@ full `ordel.run/v1` document, which is also recorded under `.ordel/runs/`.
|
|
|
119
134
|
| 3 | nothing failed, but at least one test is unjudged |
|
|
120
135
|
|
|
121
136
|
Then your coding agent, via MCP, drives the loop (needs Node + `@playwright/test` in the
|
|
122
|
-
project
|
|
123
|
-
- `get_app_context` / `heal_selector
|
|
137
|
+
project; run `ordel doctor` to check):
|
|
138
|
+
- `get_app_context` / `heal_selector`: what Ordel knows + **deterministic self-heal**
|
|
124
139
|
of a broken selector (fingerprint match, no LLM; ambiguous cases return `needs_agent`
|
|
125
|
-
with ranked candidates for your agent's *own* LLM to resolve
|
|
140
|
+
with ranked candidates for your agent's *own* LLM to resolve; Ordel never spends inference).
|
|
126
141
|
Every resolved heal is recorded in `.ordel/heals.json` pending human review.
|
|
127
|
-
- `
|
|
142
|
+
- `explore_app` / `record_flow`: drive the real browser to map the app + record a flow.
|
|
128
143
|
A value typed into a password-like field (type=password, or named/labelled password,
|
|
129
144
|
secret, token or api key; a step can also say `"secret": true`) is used for the recording
|
|
130
145
|
and never written to disk: the flow keeps a reference, and every generated spec reads it
|
|
@@ -133,28 +148,28 @@ project — run `ordel doctor` to check):
|
|
|
133
148
|
init` gitignores it, and `run_test` loads it). Unset, those tests are skipped and the run
|
|
134
149
|
reports `blocked` with the variable's name; Ordel scrubs the value from everything it
|
|
135
150
|
stores, and `run_test` lists any spec or note you copied it into (`secret_leaks`).
|
|
136
|
-
- `get_page
|
|
151
|
+
- `get_page`: read a mapped page back (inputs, buttons, links or all): each element's
|
|
137
152
|
test id, name, type, placeholder, href, `<select>` options and visible text, exact case.
|
|
138
|
-
- `generate_scenarios` / `generate_invariant` / `generate_perf_check` / `generate_pom
|
|
153
|
+
- `generate_scenarios` / `generate_invariant` / `generate_perf_check` / `generate_pom`:
|
|
139
154
|
turn artifacts into runnable specs (happy/negative/boundary, data-integrity, latency, POM).
|
|
140
|
-
- `run_test
|
|
155
|
+
- `run_test`: run a spec via `npx playwright test` and read the evidence-backed verdicts.
|
|
141
156
|
- QA-mind planning: `coverage_report` / `risk_rank` / `plan_tests` / `regression_set`.
|
|
142
157
|
|
|
143
|
-
## In progress (honest
|
|
158
|
+
## In progress (honest, no fake success)
|
|
144
159
|
|
|
145
|
-
- Single `npx ordel` distribution
|
|
146
|
-
in one npm package; not started (today's install path is `
|
|
147
|
-
- `eject` (one-command raw-Playwright export)
|
|
160
|
+
- Single `npx ordel` distribution: the plan is a compiled Python engine binary wrapped
|
|
161
|
+
in one npm package; not started (today's install path is `uv`, see Quick start above).
|
|
162
|
+
- `eject` (one-command raw-Playwright export): not built yet, but there's no lock-in today
|
|
148
163
|
either: generated tests are already plain `tests/*.spec.ts` + `pages/*.ts` on disk.
|
|
149
164
|
- Team sync + hosted dashboard = the paid upgrade (this CLI stays free & local).
|
|
150
165
|
|
|
151
166
|
## The pieces
|
|
152
167
|
|
|
153
|
-
- **`ordel-engine`** (sibling package)
|
|
168
|
+
- **`ordel-engine`** (sibling package): the pure deterministic core: fingerprint
|
|
154
169
|
matching + self-heal, stdlib-only, zero backend.
|
|
155
|
-
- **`ordel_cli.store
|
|
156
|
-
- **`ordel_cli.heal_service
|
|
157
|
-
- **`ordel_cli.mcp_server
|
|
170
|
+
- **`ordel_cli.store`**: the `.ordel/` file store (the local shell).
|
|
171
|
+
- **`ordel_cli.heal_service`**: heal + per-page circuit-breaker.
|
|
172
|
+
- **`ordel_cli.mcp_server`**: the local stdio MCP server your agent connects to.
|
|
158
173
|
|
|
159
174
|
## Dev
|
|
160
175
|
|
|
@@ -169,11 +184,11 @@ pytest packages/ordel-cli/tests packages/ordel-engine/tests -q
|
|
|
169
184
|
- **Single-writer.** The `.ordel/` store is for one dev on one machine. Writes are
|
|
170
185
|
*atomic* (temp-file + `os.replace`, so a crash can't corrupt a file), and a corrupted
|
|
171
186
|
`graph.json` is reported cleanly (never silently overwritten). But two processes
|
|
172
|
-
writing the *same* page concurrently is last-writer-wins
|
|
187
|
+
writing the *same* page concurrently is last-writer-wins; there is no file lock. That
|
|
173
188
|
is deliberate: a single-user local CLI doesn't warrant lock files / their failure
|
|
174
|
-
modes. Team-scale concurrency is the hosted product's job
|
|
175
|
-
- **Browser tools need a local Node + `@playwright/test`** (`
|
|
176
|
-
They don't ship a browser; run `ordel doctor
|
|
189
|
+
modes. Team-scale concurrency is the hosted product's job.
|
|
190
|
+
- **Browser tools need a local Node + `@playwright/test`** (`explore_app`/`record_flow`/`run_test`).
|
|
191
|
+
They don't ship a browser; run `ordel doctor`: if Node/Playwright/Chromium are missing it
|
|
177
192
|
tells you the exact command to fix. Without them these tools report the missing dependency,
|
|
178
193
|
never fake success.
|
|
179
194
|
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
# ordel (free CLI)
|
|
2
2
|
|
|
3
|
-
Local QA automation for individual devs
|
|
3
|
+
Local QA automation for individual devs: a persistent, deterministic "QA brain" your
|
|
4
4
|
own coding agent (Claude Code / Cursor / Copilot) drives over MCP. **No account, no DB,
|
|
5
5
|
no Ordel LLM.** Your agent is the brain; Ordel is the memory + determinism.
|
|
6
6
|
|
|
7
7
|
> Status: **early build**. The engine loop (map → record → generate → run) works
|
|
8
8
|
> locally today; a single-command `npx ordel` distribution is planned but not started.
|
|
9
9
|
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
You need [uv](https://docs.astral.sh/uv/). No separate install step: `uvx` fetches and runs
|
|
13
|
+
the CLI on demand. In your project folder:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uvx --from ordel-cli ordel doctor # checks Node, @playwright/test and Chromium, with fixes
|
|
17
|
+
uvx --from ordel-cli ordel init # creates .ordel/ and ORDEL.md
|
|
18
|
+
uvx --from ordel-cli ordel mcp-install --client claude-code --write # connects your agent
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Other clients: `--client cursor`, `copilot`, `codex` or `claude-desktop`.
|
|
22
|
+
To keep `ordel` on your PATH instead, run `uv tool install ordel-cli`; then every command
|
|
23
|
+
below works as plain `ordel ...`.
|
|
24
|
+
|
|
10
25
|
## What works today (local, anonymous)
|
|
11
26
|
|
|
12
27
|
```bash
|
|
@@ -95,12 +110,12 @@ full `ordel.run/v1` document, which is also recorded under `.ordel/runs/`.
|
|
|
95
110
|
| 3 | nothing failed, but at least one test is unjudged |
|
|
96
111
|
|
|
97
112
|
Then your coding agent, via MCP, drives the loop (needs Node + `@playwright/test` in the
|
|
98
|
-
project
|
|
99
|
-
- `get_app_context` / `heal_selector
|
|
113
|
+
project; run `ordel doctor` to check):
|
|
114
|
+
- `get_app_context` / `heal_selector`: what Ordel knows + **deterministic self-heal**
|
|
100
115
|
of a broken selector (fingerprint match, no LLM; ambiguous cases return `needs_agent`
|
|
101
|
-
with ranked candidates for your agent's *own* LLM to resolve
|
|
116
|
+
with ranked candidates for your agent's *own* LLM to resolve; Ordel never spends inference).
|
|
102
117
|
Every resolved heal is recorded in `.ordel/heals.json` pending human review.
|
|
103
|
-
- `
|
|
118
|
+
- `explore_app` / `record_flow`: drive the real browser to map the app + record a flow.
|
|
104
119
|
A value typed into a password-like field (type=password, or named/labelled password,
|
|
105
120
|
secret, token or api key; a step can also say `"secret": true`) is used for the recording
|
|
106
121
|
and never written to disk: the flow keeps a reference, and every generated spec reads it
|
|
@@ -109,28 +124,28 @@ project — run `ordel doctor` to check):
|
|
|
109
124
|
init` gitignores it, and `run_test` loads it). Unset, those tests are skipped and the run
|
|
110
125
|
reports `blocked` with the variable's name; Ordel scrubs the value from everything it
|
|
111
126
|
stores, and `run_test` lists any spec or note you copied it into (`secret_leaks`).
|
|
112
|
-
- `get_page
|
|
127
|
+
- `get_page`: read a mapped page back (inputs, buttons, links or all): each element's
|
|
113
128
|
test id, name, type, placeholder, href, `<select>` options and visible text, exact case.
|
|
114
|
-
- `generate_scenarios` / `generate_invariant` / `generate_perf_check` / `generate_pom
|
|
129
|
+
- `generate_scenarios` / `generate_invariant` / `generate_perf_check` / `generate_pom`:
|
|
115
130
|
turn artifacts into runnable specs (happy/negative/boundary, data-integrity, latency, POM).
|
|
116
|
-
- `run_test
|
|
131
|
+
- `run_test`: run a spec via `npx playwright test` and read the evidence-backed verdicts.
|
|
117
132
|
- QA-mind planning: `coverage_report` / `risk_rank` / `plan_tests` / `regression_set`.
|
|
118
133
|
|
|
119
|
-
## In progress (honest
|
|
134
|
+
## In progress (honest, no fake success)
|
|
120
135
|
|
|
121
|
-
- Single `npx ordel` distribution
|
|
122
|
-
in one npm package; not started (today's install path is `
|
|
123
|
-
- `eject` (one-command raw-Playwright export)
|
|
136
|
+
- Single `npx ordel` distribution: the plan is a compiled Python engine binary wrapped
|
|
137
|
+
in one npm package; not started (today's install path is `uv`, see Quick start above).
|
|
138
|
+
- `eject` (one-command raw-Playwright export): not built yet, but there's no lock-in today
|
|
124
139
|
either: generated tests are already plain `tests/*.spec.ts` + `pages/*.ts` on disk.
|
|
125
140
|
- Team sync + hosted dashboard = the paid upgrade (this CLI stays free & local).
|
|
126
141
|
|
|
127
142
|
## The pieces
|
|
128
143
|
|
|
129
|
-
- **`ordel-engine`** (sibling package)
|
|
144
|
+
- **`ordel-engine`** (sibling package): the pure deterministic core: fingerprint
|
|
130
145
|
matching + self-heal, stdlib-only, zero backend.
|
|
131
|
-
- **`ordel_cli.store
|
|
132
|
-
- **`ordel_cli.heal_service
|
|
133
|
-
- **`ordel_cli.mcp_server
|
|
146
|
+
- **`ordel_cli.store`**: the `.ordel/` file store (the local shell).
|
|
147
|
+
- **`ordel_cli.heal_service`**: heal + per-page circuit-breaker.
|
|
148
|
+
- **`ordel_cli.mcp_server`**: the local stdio MCP server your agent connects to.
|
|
134
149
|
|
|
135
150
|
## Dev
|
|
136
151
|
|
|
@@ -145,11 +160,11 @@ pytest packages/ordel-cli/tests packages/ordel-engine/tests -q
|
|
|
145
160
|
- **Single-writer.** The `.ordel/` store is for one dev on one machine. Writes are
|
|
146
161
|
*atomic* (temp-file + `os.replace`, so a crash can't corrupt a file), and a corrupted
|
|
147
162
|
`graph.json` is reported cleanly (never silently overwritten). But two processes
|
|
148
|
-
writing the *same* page concurrently is last-writer-wins
|
|
163
|
+
writing the *same* page concurrently is last-writer-wins; there is no file lock. That
|
|
149
164
|
is deliberate: a single-user local CLI doesn't warrant lock files / their failure
|
|
150
|
-
modes. Team-scale concurrency is the hosted product's job
|
|
151
|
-
- **Browser tools need a local Node + `@playwright/test`** (`
|
|
152
|
-
They don't ship a browser; run `ordel doctor
|
|
165
|
+
modes. Team-scale concurrency is the hosted product's job.
|
|
166
|
+
- **Browser tools need a local Node + `@playwright/test`** (`explore_app`/`record_flow`/`run_test`).
|
|
167
|
+
They don't ship a browser; run `ordel doctor`: if Node/Playwright/Chromium are missing it
|
|
153
168
|
tells you the exact command to fix. Without them these tools report the missing dependency,
|
|
154
169
|
never fake success.
|
|
155
170
|
|
|
@@ -118,8 +118,8 @@ These are where agents most often go wrong. The references expand on them.
|
|
|
118
118
|
## Scope
|
|
119
119
|
|
|
120
120
|
Ordel covers browser-based functional testing. Unit and component tests stay with you and the
|
|
121
|
-
project's own framework.
|
|
122
|
-
|
|
121
|
+
project's own framework. Load testing is outside this workflow (Ordel Cloud covers it);
|
|
122
|
+
`generate_perf_check` only asserts that a page's median render time stays within a budget. There is no first-class API testing tool here: do not promise API, security,
|
|
123
123
|
accessibility or migration assurance from browser checks. Never claim exhaustive coverage or
|
|
124
124
|
defect-free software. No account, cloud upload or endorsement is needed for any of this.
|
|
125
125
|
|
|
@@ -58,7 +58,7 @@ def _resolve_launcher() -> str:
|
|
|
58
58
|
return str(cand)
|
|
59
59
|
return sys.executable # last resort — user may need `ordel` on PATH
|
|
60
60
|
|
|
61
|
-
app = typer.Typer(add_completion=False, help="Ordel
|
|
61
|
+
app = typer.Typer(add_completion=False, help="Ordel: local QA automation for your coding agent.")
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def _print_version(value: bool) -> None:
|
|
@@ -72,7 +72,7 @@ def _main(
|
|
|
72
72
|
version: bool = typer.Option(False, "--version", callback=_print_version, is_eager=True,
|
|
73
73
|
help="Print the installed version and exit"),
|
|
74
74
|
) -> None:
|
|
75
|
-
"""Ordel
|
|
75
|
+
"""Ordel: local QA automation for your coding agent."""
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
# The agent's operating manual has ONE source, the SKILL.md shipped in this package:
|
|
@@ -98,7 +98,7 @@ def _build_ordel_md() -> str:
|
|
|
98
98
|
for name, text in _SKILL_REFS.items():
|
|
99
99
|
title = text.split("\n", 1)[0].lstrip("# ").strip()
|
|
100
100
|
sections = [sec.replace(f"`{name}`", f'the "{title}" section') for sec in sections]
|
|
101
|
-
return ("# ORDEL.md
|
|
101
|
+
return ("# ORDEL.md - testing context for this repo\n" + body
|
|
102
102
|
+ "".join(f"\n---\n\n{sec}" for sec in sections))
|
|
103
103
|
|
|
104
104
|
|
|
@@ -115,7 +115,7 @@ def _store() -> OrdelStore:
|
|
|
115
115
|
|
|
116
116
|
@app.command()
|
|
117
117
|
def init() -> None:
|
|
118
|
-
"""Create `.ordel/` + an ORDEL.md bridge file. Anonymous
|
|
118
|
+
"""Create `.ordel/` + an ORDEL.md bridge file. Anonymous - no account."""
|
|
119
119
|
store = _store()
|
|
120
120
|
try:
|
|
121
121
|
store.init()
|
|
@@ -173,7 +173,7 @@ def ingest(path: str = typer.Argument("", help="a spec file or dir (relative); o
|
|
|
173
173
|
Unit tests (Vitest/Jest) are skipped as out of scope. Then `ordel scenarios <flow>` etc."""
|
|
174
174
|
store = _store()
|
|
175
175
|
if not store.exists:
|
|
176
|
-
typer.echo("no .ordel/ here
|
|
176
|
+
typer.echo("no .ordel/ here - run `ordel init`")
|
|
177
177
|
raise typer.Exit(1)
|
|
178
178
|
from ordel_cli.ingest import ingest as _ingest
|
|
179
179
|
typer.echo(_ingest(store, path)["ascii"])
|
|
@@ -237,7 +237,7 @@ def catalog_export(dest: str = typer.Argument(..., help="file to write, e.g. cat
|
|
|
237
237
|
|
|
238
238
|
store = _store()
|
|
239
239
|
if not store.exists:
|
|
240
|
-
typer.echo("no .ordel/ here
|
|
240
|
+
typer.echo("no .ordel/ here - run `ordel init`")
|
|
241
241
|
raise typer.Exit(1)
|
|
242
242
|
try:
|
|
243
243
|
res = export_catalog(store, Path(dest))
|
|
@@ -272,7 +272,7 @@ def status() -> None:
|
|
|
272
272
|
"""Show local coverage: pages, elements, run history."""
|
|
273
273
|
store = _store()
|
|
274
274
|
if not store.exists:
|
|
275
|
-
typer.echo("no .ordel/ here
|
|
275
|
+
typer.echo("no .ordel/ here - run `ordel init`")
|
|
276
276
|
raise typer.Exit(1)
|
|
277
277
|
try:
|
|
278
278
|
pages = store.pages()
|
|
@@ -294,7 +294,7 @@ def graph(
|
|
|
294
294
|
"""Print the local app graph (.ordel/graph.json), or an ASCII tree with --ascii."""
|
|
295
295
|
store = _store()
|
|
296
296
|
if not store.exists:
|
|
297
|
-
typer.echo("no .ordel/ here
|
|
297
|
+
typer.echo("no .ordel/ here - run `ordel init`")
|
|
298
298
|
raise typer.Exit(1)
|
|
299
299
|
if not ascii_:
|
|
300
300
|
typer.echo((store.dir / "graph.json").read_text(encoding="utf-8"))
|
|
@@ -303,7 +303,7 @@ def graph(
|
|
|
303
303
|
|
|
304
304
|
rows = store.pages_summary()
|
|
305
305
|
if not rows:
|
|
306
|
-
typer.echo("(empty map
|
|
306
|
+
typer.echo("(empty map - run `ordel explore <url>`)")
|
|
307
307
|
return
|
|
308
308
|
# the same view an agent shows its user after a mapping step
|
|
309
309
|
typer.echo(map_tree(rows, limit=None))
|
|
@@ -311,7 +311,7 @@ def graph(
|
|
|
311
311
|
|
|
312
312
|
@app.command()
|
|
313
313
|
def coverage(fmt: str = typer.Option("ascii", help="ascii | markdown | mermaid")) -> None:
|
|
314
|
-
"""What's TESTED vs NOT
|
|
314
|
+
"""What's TESTED vs NOT - map ∩ specs, per page (✓ full / ⚠ partial / ✗ untested)."""
|
|
315
315
|
from ordel_cli.coverage import coverage_report
|
|
316
316
|
store = _store()
|
|
317
317
|
res = coverage_report(store)
|
|
@@ -426,7 +426,7 @@ def report(
|
|
|
426
426
|
Ordel shows but does not verify. It makes no claim the store can't back."""
|
|
427
427
|
store = _store()
|
|
428
428
|
if not store.exists:
|
|
429
|
-
typer.echo("no .ordel/ here
|
|
429
|
+
typer.echo("no .ordel/ here - run `ordel init`")
|
|
430
430
|
raise typer.Exit(1)
|
|
431
431
|
try:
|
|
432
432
|
pages = store.pages()
|
|
@@ -494,7 +494,7 @@ def report(
|
|
|
494
494
|
typer.echo(json.dumps(doc, indent=2))
|
|
495
495
|
return
|
|
496
496
|
lines = [
|
|
497
|
-
"**Ordel
|
|
497
|
+
"**Ordel: local QA report**",
|
|
498
498
|
"",
|
|
499
499
|
"| Metric | Value |",
|
|
500
500
|
"|---|---|",
|
|
@@ -552,12 +552,12 @@ def report(
|
|
|
552
552
|
def eject() -> None:
|
|
553
553
|
"""Point at your already-plain-Playwright artifacts (there's no lock-in to leave).
|
|
554
554
|
|
|
555
|
-
The one-command export is
|
|
556
|
-
Ordel generates is already a plain Playwright file on disk
|
|
557
|
-
`pages/*.ts`
|
|
555
|
+
The one-command export is not built yet. But nothing traps you meanwhile: every test
|
|
556
|
+
Ordel generates is already a plain Playwright file on disk - `tests/*.spec.ts` +
|
|
557
|
+
`pages/*.ts` - copy them anywhere and run them with `npx playwright test`, no Ordel needed.
|
|
558
558
|
"""
|
|
559
|
-
typer.echo("eject: the one-command export is not wired yet
|
|
560
|
-
"
|
|
559
|
+
typer.echo("eject: the one-command export is not wired yet, but there is no lock-in. "
|
|
560
|
+
"Your generated tests are already plain Playwright at tests/*.spec.ts + pages/*.ts: "
|
|
561
561
|
"copy them and run `npx playwright test`.")
|
|
562
562
|
raise typer.Exit(2)
|
|
563
563
|
|
|
@@ -670,7 +670,7 @@ def mcp_install(
|
|
|
670
670
|
) -> None:
|
|
671
671
|
"""Print (or --write) the MCP config that points a coding agent at the local Ordel server.
|
|
672
672
|
|
|
673
|
-
The server runs via `uvx --from ordel-cli ordel mcp-serve`
|
|
673
|
+
The server runs via `uvx --from ordel-cli ordel mcp-serve` - no separate install; uv fetches
|
|
674
674
|
+ caches on first spawn. Add --installed to use a persistent `uv tool install` instead.
|
|
675
675
|
`--skill` installs the agent skill where the client loads repo skills
|
|
676
676
|
(.claude/skills/ordel-cli/ for Claude Code, .agents/skills/ordel-cli/ for Codex).
|
|
@@ -682,7 +682,7 @@ def mcp_install(
|
|
|
682
682
|
inv = _mcp_invocation(installed)
|
|
683
683
|
# uv preflight: without it, `uvx ...` fails and the client shows only an opaque spawn error.
|
|
684
684
|
if not installed and shutil.which("uv") is None:
|
|
685
|
-
typer.echo("⚠ `uv` is not on your PATH
|
|
685
|
+
typer.echo("⚠ `uv` is not on your PATH - the server launches via `uvx`, so your client "
|
|
686
686
|
"can't start it until you install uv:")
|
|
687
687
|
typer.echo(" macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
|
688
688
|
typer.echo(" Windows: irm https://astral.sh/uv/install.ps1 | iex")
|
|
@@ -706,7 +706,7 @@ def mcp_install(
|
|
|
706
706
|
"your Claude Desktop config (claude_desktop_config.json)"
|
|
707
707
|
if name == "claude-desktop" else "~/.codex/config.toml")
|
|
708
708
|
if write:
|
|
709
|
-
typer.echo(f"# {name} uses a global config
|
|
709
|
+
typer.echo(f"# {name} uses a global config - add manually to {where}:")
|
|
710
710
|
else:
|
|
711
711
|
typer.echo(f"# {name}: add to {where}"
|
|
712
712
|
+ (" (or: --write)" if spec["path"] else ""))
|
|
@@ -717,7 +717,7 @@ def mcp_install(
|
|
|
717
717
|
typer.echo(_install_skill(name))
|
|
718
718
|
if not client:
|
|
719
719
|
others = ", ".join(k for k in _MCP_CLIENTS if k != "claude-code")
|
|
720
|
-
typer.echo(f"# other clients: {others}
|
|
720
|
+
typer.echo(f"# other clients: {others} - re-run with --client <name>")
|
|
721
721
|
typer.echo("\nFirst run: enable the `ordel-cli` server once (one-time approval).\n"
|
|
722
722
|
" Claude Code: `/mcp` -> enable. Cursor / Copilot: enable it in MCP/tools settings.")
|
|
723
723
|
if "codex" in names:
|
|
@@ -777,13 +777,13 @@ def explore(
|
|
|
777
777
|
"""Explore a running app page and build the local map (.ordel/graph.json).
|
|
778
778
|
|
|
779
779
|
Drives the project's Playwright to capture the page's meaningful elements so the
|
|
780
|
-
map reflects the real rendered app
|
|
780
|
+
map reflects the real rendered app - grounding for the agent's assertions.
|
|
781
781
|
"""
|
|
782
782
|
from ordel_cli.explore import explore as _explore
|
|
783
783
|
|
|
784
784
|
store = OrdelStore(Path.cwd())
|
|
785
785
|
if not store.exists:
|
|
786
|
-
typer.echo("no .ordel/ here
|
|
786
|
+
typer.echo("no .ordel/ here - run `ordel init` first")
|
|
787
787
|
raise typer.Exit(2)
|
|
788
788
|
res = _explore(store, url, page_id=page or None)
|
|
789
789
|
if res["status"] == "explored":
|
|
@@ -804,7 +804,7 @@ def pom(
|
|
|
804
804
|
|
|
805
805
|
store = OrdelStore(Path.cwd())
|
|
806
806
|
if not store.exists:
|
|
807
|
-
typer.echo("no .ordel/ here
|
|
807
|
+
typer.echo("no .ordel/ here - run `ordel init` first")
|
|
808
808
|
raise typer.Exit(2)
|
|
809
809
|
res = generate_pom(store, page_id)
|
|
810
810
|
if res["status"] == "generated":
|
|
@@ -97,6 +97,17 @@ def assess_context(store: OrdelStore) -> dict[str, Any]:
|
|
|
97
97
|
if low.endswith(_SRC_SUFFIXES):
|
|
98
98
|
source_files += 1
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
res = _assess(ContextScan(pages=pages, flows=flows, runs=runs, functional_specs=functional,
|
|
101
|
+
ordel_specs=ordel, unit_specs=unit, unknown_specs=unknown,
|
|
102
|
+
page_objects=page_objects, has_ci=has_ci, source_files=source_files))
|
|
103
|
+
# FINDINGS-042 #3: get_app_context said initialized:false while this said "ok, MAP next" in
|
|
104
|
+
# the same folder, and mapping then failed. Both now say the same thing.
|
|
105
|
+
res["initialized"] = store.exists
|
|
106
|
+
if not store.exists:
|
|
107
|
+
before = str(res.get("required_next_phase", ""))
|
|
108
|
+
res["required_next_phase"] = ("INIT - this folder has no .ordel/ yet: run `ordel init` "
|
|
109
|
+
"here first (it creates .ordel/ and ORDEL.md); nothing can be "
|
|
110
|
+
"mapped, recorded or run until then")
|
|
111
|
+
if isinstance(res.get("ascii"), str) and before:
|
|
112
|
+
res["ascii"] = res["ascii"].replace(before, res["required_next_phase"])
|
|
113
|
+
return res
|
|
@@ -38,7 +38,7 @@ def coverage_report(store: OrdelStore, force: bool = False) -> dict[str, Any]:
|
|
|
38
38
|
"""Compute coverage of the map by the project's specs. Returns
|
|
39
39
|
{status, summary, ascii, markdown, mermaid}."""
|
|
40
40
|
if not store.exists:
|
|
41
|
-
return {"status": "no_map", "note": "no .ordel/
|
|
41
|
+
return {"status": "no_map", "note": "no .ordel/ - run `ordel init` + explore first"}
|
|
42
42
|
try:
|
|
43
43
|
# T2P-2/BUG-2215: read the whole graph ONCE (a corrupt/malformed/too-deep
|
|
44
44
|
# graph degrades to a clean store_error here, not a raw traceback; and a
|
|
@@ -48,7 +48,7 @@ def coverage_report(store: OrdelStore, force: bool = False) -> dict[str, Any]:
|
|
|
48
48
|
return {"status": "store_error", "note": str(e)}
|
|
49
49
|
if not all_els and not force: # readiness gate: nothing to measure coverage against
|
|
50
50
|
return {"status": "not_ready", "missing": "map",
|
|
51
|
-
"note": "no pages captured yet
|
|
51
|
+
"note": "no pages captured yet - coverage measures the map against your specs. "
|
|
52
52
|
"Map the app first (explore_app / capture_state), or pass force=true."}
|
|
53
53
|
specs = _read_specs(store.root)
|
|
54
54
|
host = ""
|
|
@@ -59,8 +59,8 @@ def check_playwright(cwd: Path) -> Check:
|
|
|
59
59
|
return Check("Playwright", "warn", "installed but version unreadable",
|
|
60
60
|
"reinstall: npm i -D @playwright/test@latest")
|
|
61
61
|
if _ver_tuple(v)[:2] < _PW_OK_MIN:
|
|
62
|
-
return Check("Playwright", "warn", f"{v}
|
|
63
|
-
"
|
|
62
|
+
return Check("Playwright", "warn", f"{v} is older than 1.40",
|
|
63
|
+
"upgrade: npm i -D @playwright/test@latest")
|
|
64
64
|
return Check("Playwright", "ok", f"{v}")
|
|
65
65
|
|
|
66
66
|
|
|
@@ -141,9 +141,9 @@ class Report:
|
|
|
141
141
|
lines.append(f" {_MARK[c.status]} {c.name:<{w}}{tail}{fix}")
|
|
142
142
|
fails = sum(c.status == "fail" for c in self.checks)
|
|
143
143
|
warns = sum(c.status == "warn" for c in self.checks)
|
|
144
|
-
summary = ("ready
|
|
145
|
-
f"{fails} to fix before recording or running, {warns} warning(s)
|
|
146
|
-
"
|
|
144
|
+
summary = ("ready: all checks passed" if fails == 0 and warns == 0 else
|
|
145
|
+
f"{fails} to fix before recording or running, {warns} warning(s). "
|
|
146
|
+
"See the fixes above.")
|
|
147
147
|
return "\n".join(lines) + f"\n {summary}"
|
|
148
148
|
|
|
149
149
|
|
|
@@ -140,6 +140,16 @@ def persist_capture(store: OrdelStore, doc: dict[str, Any], page_id: str,
|
|
|
140
140
|
The fingerprint transform is the pure ``fingerprints_from_capture``; this adapter only
|
|
141
141
|
writes the result to the store."""
|
|
142
142
|
fps, url, title = fingerprints_from_capture(doc, fallback_url=fallback_url)
|
|
143
|
+
# FINDINGS-042 #1: after a failed navigation the browser sits on chrome-error://chromewebdata/;
|
|
144
|
+
# mapping it under the agent's page_id took that id from the real page, which was then saved
|
|
145
|
+
# under a site-qualified name, and coverage counted an error page.
|
|
146
|
+
scheme = url.split(":", 1)[0].lower() if ":" in url else ""
|
|
147
|
+
if scheme not in ("http", "https", "file"):
|
|
148
|
+
return {"status": "not_mapped", "page_id": page_id, "url": url, "title": title,
|
|
149
|
+
"elements": 0,
|
|
150
|
+
"note": (f"nothing was mapped: the browser is on {url or 'no page'}, not a page of "
|
|
151
|
+
"the app (a navigation failed, or nothing is loaded yet); goto a working "
|
|
152
|
+
"address and capture again")}
|
|
143
153
|
# A capture replaces its page's elements. When the id already belongs to ANOTHER site (a
|
|
144
154
|
# second app's root is HomePage too), replacing it would silently discard that site's map:
|
|
145
155
|
# save under a site-qualified id and say so. The same site re-captured still refreshes.
|
|
@@ -99,16 +99,16 @@ def ingest(store: OrdelStore, path: str = "") -> dict[str, Any]:
|
|
|
99
99
|
lines.append(f" {cypress_specs} of them Cypress: parsed into the same flow format, so "
|
|
100
100
|
"`ordel scenarios <flow>` turns each into a Playwright spec")
|
|
101
101
|
if skipped_unit:
|
|
102
|
-
lines.append(f" skipped {skipped_unit} unit test(s)
|
|
102
|
+
lines.append(f" skipped {skipped_unit} unit test(s) - out of scope (Ordel does functional/e2e)")
|
|
103
103
|
if skipped_ordel:
|
|
104
104
|
lines.append(f" skipped {skipped_ordel} Ordel-generated spec(s) (already ours)")
|
|
105
105
|
if unparsed:
|
|
106
|
-
lines.append(f" {unparsed} action line(s) used idioms we couldn't parse
|
|
106
|
+
lines.append(f" {unparsed} action line(s) used idioms we couldn't parse - re-record those flows")
|
|
107
107
|
lines.append(f" assertions carried into the flows: {kept_asserts}"
|
|
108
108
|
+ (f"; NOT carried: {dropped_asserts} (the original specs still check them, "
|
|
109
109
|
"the generated scenarios do not)" if dropped_asserts else ""))
|
|
110
110
|
if capped:
|
|
111
|
-
lines.append(f" stopped at the {_MAX_FLOWS}-flow cap
|
|
111
|
+
lines.append(f" stopped at the {_MAX_FLOWS}-flow cap - ingest a subdir at a time for the rest")
|
|
112
112
|
if flows_created:
|
|
113
113
|
lines.append(" next: generate_scenarios <flow> (adds negative/boundary on top) or "
|
|
114
114
|
"generate_invariant; re-record a flow to capture its final_url + strengthen")
|
|
@@ -40,7 +40,7 @@ from ordel_cli.runner import run_and_record
|
|
|
40
40
|
from ordel_cli.store import OrdelStore, StoreError
|
|
41
41
|
|
|
42
42
|
_INSTRUCTIONS = (
|
|
43
|
-
"Ordel is this repo's durable QA engine. For any testing task use these tools
|
|
43
|
+
"Ordel is this repo's durable QA engine. For any testing task use these tools - "
|
|
44
44
|
"do NOT hand-roll Playwright. Call get_app_context first; heal_selector recovers "
|
|
45
45
|
"broken selectors deterministically. "
|
|
46
46
|
"Work in the open, one step at a time: before the first tool call, show the user a short "
|
|
@@ -89,7 +89,7 @@ _FAILURE_STATUSES: frozenset[str] = frozenset({
|
|
|
89
89
|
"unknown_element", "record_failed", "capture_failed", "http_error", "no_playwright",
|
|
90
90
|
"no_node", "timeout", "no_session", "session_error", "nav_failed", "failed",
|
|
91
91
|
"circuit_open", "error", "exists", "action_failed", "ambiguous", "not_actionable",
|
|
92
|
-
"invalid_target", "flow_rejected",
|
|
92
|
+
"invalid_target", "flow_rejected", "not_mapped",
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
|
|
@@ -368,10 +368,10 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
368
368
|
out = heals.heal(page_id, _fp(broken), cands)
|
|
369
369
|
except AmbiguityError as e:
|
|
370
370
|
return {"status": "needs_agent", "candidates": e.candidates, "best": e.best,
|
|
371
|
-
"note": "ambiguous
|
|
371
|
+
"note": "ambiguous - pick the right element id yourself"}
|
|
372
372
|
except HealFailedError as e:
|
|
373
373
|
return {"status": "failed", "best": e.best,
|
|
374
|
-
"note": "element gone from this snapshot
|
|
374
|
+
"note": "element gone from this snapshot - surface as a real failure"}
|
|
375
375
|
except CircuitOpenError as e:
|
|
376
376
|
return {"status": "circuit_open", "fails": e.fails, "note": str(e)}
|
|
377
377
|
return _healed(page_id, str(broken.get("id") or broken.get("accessible_name_canon") or ""),
|
|
@@ -480,7 +480,7 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
480
480
|
u = urlparse(start)
|
|
481
481
|
if u.scheme and u.netloc:
|
|
482
482
|
base_url = f"{u.scheme}://{u.netloc}"
|
|
483
|
-
note = (f"base_url defaulted to {base_url} from recorded flow '{name}'
|
|
483
|
+
note = (f"base_url defaulted to {base_url} from recorded flow '{name}' - the "
|
|
484
484
|
"specs use relative URLs; pass base_url to run against another target")
|
|
485
485
|
break
|
|
486
486
|
doc = run_and_record(store, spec=spec or None,
|
|
@@ -734,7 +734,7 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
734
734
|
# distinct from invalid_input (dogfood V): "no session" is a workflow state, not
|
|
735
735
|
# the APP rejecting input — the old label read as a validation failure, misleading
|
|
736
736
|
# exactly while testing validation.
|
|
737
|
-
raise _NoSession("no browser session
|
|
737
|
+
raise _NoSession("no browser session - call browser_open first")
|
|
738
738
|
return _session[0]
|
|
739
739
|
|
|
740
740
|
def _acted(raw: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -786,7 +786,7 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
786
786
|
_session[0] = sess
|
|
787
787
|
out = {"status": "opened", **res}
|
|
788
788
|
if res.get("auth_expired"):
|
|
789
|
-
out["warning"] = ("saved auth (.ordel/state.json) has EXPIRED cookies
|
|
789
|
+
out["warning"] = ("saved auth (.ordel/state.json) has EXPIRED cookies - a gated "
|
|
790
790
|
"goto will bounce to login. Log in again + save_auth.")
|
|
791
791
|
return out
|
|
792
792
|
return _guard(_do)
|
|
@@ -808,10 +808,10 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
808
808
|
notes = []
|
|
809
809
|
if res.get("redirected"):
|
|
810
810
|
notes.append(f"redirected: requested {res.get('requested')} but landed on "
|
|
811
|
-
f"{res.get('url')}" + (" (looks like a login page
|
|
811
|
+
f"{res.get('url')}" + (" (looks like a login page - auth may be "
|
|
812
812
|
"missing/expired; re-run save_auth)" if res.get("looks_like_login") else ""))
|
|
813
813
|
if res.get("slow_load"):
|
|
814
|
-
notes.append("page committed but network stayed active (analytics/ws/ads)
|
|
814
|
+
notes.append("page committed but network stayed active (analytics/ws/ads) - "
|
|
815
815
|
"loaded OK, not a failure")
|
|
816
816
|
if notes:
|
|
817
817
|
res["warning"] = " | ".join(notes)
|
|
@@ -911,6 +911,9 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
911
911
|
Returns {page_id, url, title, elements}. No LLM."""
|
|
912
912
|
from ordel_cli.explore import page_id_from_url, persist_capture
|
|
913
913
|
def _do() -> dict[str, Any]:
|
|
914
|
+
if not store.exists:
|
|
915
|
+
return {"status": "not_initialized",
|
|
916
|
+
"note": "this folder has no .ordel/ yet: run `ordel init` here first"}
|
|
914
917
|
doc = _sess().capture()
|
|
915
918
|
if not doc.get("ok", True):
|
|
916
919
|
return {"status": "session_error", "note": doc.get("error", "capture failed")}
|
|
@@ -928,7 +931,7 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
928
931
|
f"page_id={twins[0]!r} to update that page instead of keeping "
|
|
929
932
|
"two copies")
|
|
930
933
|
if doc.get("looks_like_login"):
|
|
931
|
-
res["warning"] = ("this page looks like a LOGIN page
|
|
934
|
+
res["warning"] = ("this page looks like a LOGIN page - if you expected a "
|
|
932
935
|
f"different page, you may be mapping the wrong state under '{pid}' "
|
|
933
936
|
"(auth missing/expired?)")
|
|
934
937
|
return res
|
|
@@ -963,14 +966,21 @@ def build_server(root: str | Path = ".") -> FastMCP:
|
|
|
963
966
|
if isinstance(results, tuple) and len(results) == 2 and isinstance(results[1], dict):
|
|
964
967
|
content, structured = results
|
|
965
968
|
blocks = list(content)
|
|
969
|
+
failed = _is_failure(name, structured)
|
|
970
|
+
if failed and not structured.get("ascii"):
|
|
971
|
+
# FINDINGS-042 #4: a failure is a milestone the person must see too; the skill's
|
|
972
|
+
# checkpoints paste `ascii`, which only successes carried.
|
|
973
|
+
why = str(structured.get("note") or structured.get("error") or "").strip()
|
|
974
|
+
first = why.splitlines()[0][:300] if why else ""
|
|
975
|
+
structured = {**structured, "ascii": f"✗ {name}: {structured.get('status') or 'failed'}"
|
|
976
|
+
+ (f" - {first}" if first else "")}
|
|
966
977
|
summary = structured.get("ascii")
|
|
967
978
|
if isinstance(summary, str) and summary:
|
|
968
979
|
# The step summary as its own first, plain-text block: a host that shows tool
|
|
969
980
|
# output shows the map or the verdicts, not an escaped JSON line (the 0.4.0
|
|
970
981
|
# evaluation's F5: the summary reached the agent but not the person).
|
|
971
982
|
blocks = [TextContent(type="text", text=summary), *blocks]
|
|
972
|
-
return CallToolResult(content=blocks, structuredContent=structured,
|
|
973
|
-
isError=_is_failure(name, structured))
|
|
983
|
+
return CallToolResult(content=blocks, structuredContent=structured, isError=failed)
|
|
974
984
|
return results
|
|
975
985
|
|
|
976
986
|
mcp._mcp_server.call_tool(validate_input=False)(_wire_call_tool)
|
|
@@ -41,7 +41,7 @@ def generate_pom(store: OrdelStore, page_id: str, write: bool = True) -> dict[st
|
|
|
41
41
|
result["status"] = "exists_unmanaged"
|
|
42
42
|
result["note"] = (
|
|
43
43
|
f"pages/{result['class_name']}.ts already exists WITHOUT Ordel's managed block "
|
|
44
|
-
"
|
|
44
|
+
"- left untouched (your edits are safe). Add the `// ordel:locators` … "
|
|
45
45
|
"`// ordel:end` markers where locators should refresh, or delete the file to "
|
|
46
46
|
"regenerate from scratch.")
|
|
47
47
|
result["path"] = p.relative_to(store.root).as_posix()
|
|
@@ -33,7 +33,7 @@ def _require_map(store: OrdelStore, force: bool) -> dict[str, Any] | None:
|
|
|
33
33
|
try:
|
|
34
34
|
if not store.pages():
|
|
35
35
|
return {"status": "not_ready", "missing": "map",
|
|
36
|
-
"note": "no pages captured yet
|
|
36
|
+
"note": "no pages captured yet - this plans from the map. Map the app first "
|
|
37
37
|
"(explore_app, or browser_open + goto/click + capture_state), then "
|
|
38
38
|
"retry. Pass force=true to run against the empty map anyway."}
|
|
39
39
|
except StoreError:
|
|
@@ -70,7 +70,7 @@ def _scored_rows(store: OrdelStore) -> list[dict[str, Any]]:
|
|
|
70
70
|
def risk_rank(store: OrdelStore, force: bool = False) -> dict[str, Any]:
|
|
71
71
|
"""Rank mapped pages by risk. Returns {status, rows, ascii}."""
|
|
72
72
|
if not store.exists:
|
|
73
|
-
return {"status": "no_map", "note": "no .ordel/
|
|
73
|
+
return {"status": "no_map", "note": "no .ordel/ - explore first"}
|
|
74
74
|
if gate := _require_map(store, force):
|
|
75
75
|
return gate
|
|
76
76
|
try:
|
|
@@ -83,7 +83,7 @@ def risk_rank(store: OrdelStore, force: bool = False) -> dict[str, Any]:
|
|
|
83
83
|
def plan_tests(store: OrdelStore, force: bool = False) -> dict[str, Any]:
|
|
84
84
|
"""A prioritized page-by-page test plan. Returns {status, rows, ascii}."""
|
|
85
85
|
if not store.exists:
|
|
86
|
-
return {"status": "no_map", "note": "no .ordel/
|
|
86
|
+
return {"status": "no_map", "note": "no .ordel/ - explore first"}
|
|
87
87
|
if gate := _require_map(store, force):
|
|
88
88
|
return gate
|
|
89
89
|
try:
|
|
@@ -112,12 +112,12 @@ def regression_set(store: OrdelStore, changed_pages: list[str] | None = None,
|
|
|
112
112
|
any element on a changed page). Empty/None changed -> full set. Returns
|
|
113
113
|
{status, specs, changed, ascii}."""
|
|
114
114
|
if not store.exists:
|
|
115
|
-
return {"status": "no_map", "note": "no .ordel/
|
|
115
|
+
return {"status": "no_map", "note": "no .ordel/ - explore first"}
|
|
116
116
|
if gate := _require_map(store, force):
|
|
117
117
|
return gate
|
|
118
118
|
spec_map = _read_specs_map(store.root)
|
|
119
119
|
if not spec_map:
|
|
120
|
-
return {"status": "no_specs", "note": "no specs in tests/
|
|
120
|
+
return {"status": "no_specs", "note": "no specs in tests/ - nothing to select"}
|
|
121
121
|
all_pages = set(store.pages())
|
|
122
122
|
# A caller passing specific pages means "just these" — if a typo'd id matched nothing,
|
|
123
123
|
# do NOT silently widen to the whole suite (that would re-run everything, the opposite
|
|
@@ -127,8 +127,8 @@ def regression_set(store: OrdelStore, changed_pages: list[str] | None = None,
|
|
|
127
127
|
unknown = [p for p in changed_pages if p not in all_pages]
|
|
128
128
|
if not targets:
|
|
129
129
|
return {"status": "unknown_pages", "unknown": unknown, "specs": [], "changed": [],
|
|
130
|
-
"note": f"none of {changed_pages} are in the map
|
|
131
|
-
"ascii": f"Regression set
|
|
130
|
+
"note": f"none of {changed_pages} are in the map - nothing selected",
|
|
131
|
+
"ascii": f"Regression set - 0 specs: none of {', '.join(changed_pages)} "
|
|
132
132
|
"are mapped pages"}
|
|
133
133
|
else:
|
|
134
134
|
targets, unknown = store.pages(), []
|
|
@@ -94,7 +94,7 @@ def _selector_testid_hint(store: OrdelStore, steps: list[dict[str, Any]]) -> str
|
|
|
94
94
|
if not hit:
|
|
95
95
|
return ""
|
|
96
96
|
ex = hit[0]
|
|
97
|
-
return (f' hint: {", ".join("#" + h for h in hit)} is a data-testid, not a DOM id
|
|
97
|
+
return (f' hint: {", ".join("#" + h for h in hit)} is a data-testid, not a DOM id - map '
|
|
98
98
|
f'keys are testids. Target it with {{"testid": "{ex}"}} instead of '
|
|
99
99
|
f'{{"selector": "#{ex}"}}.')
|
|
100
100
|
|
|
@@ -484,6 +484,8 @@ def record_human(store: OrdelStore, name: str, url: str, headed: bool = True,
|
|
|
484
484
|
# the id the page was actually saved under (another site may already hold the plain one)
|
|
485
485
|
saved = persist_capture(store, {**page, "url": page_url}, page_id_from_url(page_url),
|
|
486
486
|
fallback_url=page_url)
|
|
487
|
+
if saved.get("status") == "not_mapped":
|
|
488
|
+
continue
|
|
487
489
|
page_ids[page_url] = str(saved["page_id"])
|
|
488
490
|
for s in steps:
|
|
489
491
|
u = strip_query(str(s.get("url", "")))
|
|
@@ -90,7 +90,7 @@ def _ephemeral_config(cwd: str, base_url: str) -> str:
|
|
|
90
90
|
" testIgnore: ['**/node_modules/**', '**/.ordel/**'],\n"
|
|
91
91
|
" fullyParallel: true,\n"
|
|
92
92
|
f" use: {{ baseURL: {json.dumps(base_url)} }},\n"
|
|
93
|
-
" // The target app is already running (external)
|
|
93
|
+
" // The target app is already running (external) - nothing to launch.\n"
|
|
94
94
|
"});\n"
|
|
95
95
|
)
|
|
96
96
|
fd, path = tempfile.mkstemp(prefix="ordel.pw.", suffix=".config.cjs", dir=cwd)
|
|
@@ -133,7 +133,7 @@ def run_playwright(cwd: str, spec: str | None = None,
|
|
|
133
133
|
cwd = str(Path(cwd).resolve())
|
|
134
134
|
npx = _npx_argv()
|
|
135
135
|
if npx is None:
|
|
136
|
-
return error_outcome("npx not found
|
|
136
|
+
return error_outcome("npx not found - is Node/Playwright installed in this project?")
|
|
137
137
|
args = [*npx, "playwright", "test", f"--reporter=json,{_EVIDENCE_REPORTER}"]
|
|
138
138
|
if headed:
|
|
139
139
|
args.append("--headed")
|
|
@@ -186,7 +186,7 @@ def run_playwright(cwd: str, spec: str | None = None,
|
|
|
186
186
|
except FileNotFoundError:
|
|
187
187
|
# npx is pre-resolved via _npx_argv; reaching here means the resolved launcher
|
|
188
188
|
# (or cmd.exe on Windows) could not be spawned.
|
|
189
|
-
return error_outcome("could not launch npx
|
|
189
|
+
return error_outcome("could not launch npx - is Node/Playwright installed in this project?")
|
|
190
190
|
except subprocess.TimeoutExpired as e:
|
|
191
191
|
# Surface what actually ran and whatever it printed before the hang - the
|
|
192
192
|
# generic "timed out after Ns" message hid the command line and any partial
|
|
@@ -47,11 +47,11 @@ def _load_flow_doc(store: OrdelStore, flow_name: str) -> tuple[dict[str, Any] |
|
|
|
47
47
|
flow file exists, valid JSON, top-level is an object, ``steps`` is a list of
|
|
48
48
|
objects."""
|
|
49
49
|
if not store.exists:
|
|
50
|
-
return None, {"status": "no_map", "note": "no .ordel/
|
|
50
|
+
return None, {"status": "no_map", "note": "no .ordel/ - run `ordel init` first"}
|
|
51
51
|
path = store.dir / "flows" / f"{flow_name}.json"
|
|
52
52
|
if not path.exists():
|
|
53
53
|
return None, {"status": "unknown_flow",
|
|
54
|
-
"note": f"no recorded flow '{flow_name}'
|
|
54
|
+
"note": f"no recorded flow '{flow_name}' - run `record_flow` first"}
|
|
55
55
|
try:
|
|
56
56
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
57
57
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
@@ -81,6 +81,17 @@ def _load_flow_doc(store: OrdelStore, flow_name: str) -> tuple[dict[str, Any] |
|
|
|
81
81
|
return None, {"status": "invalid_flow",
|
|
82
82
|
"note": f"flow '{flow_name}': step {i} field '{key}' must be a "
|
|
83
83
|
f"string, number or boolean, got {type(value).__name__}"}
|
|
84
|
+
# FINDINGS-042 #5: a hand-written or 0.3-era flow with "transitions": 4 crashed with
|
|
85
|
+
# "'int' object is not iterable" when the scenarios read each step's page.
|
|
86
|
+
transitions = doc.get("transitions")
|
|
87
|
+
if transitions is not None and (not isinstance(transitions, list)
|
|
88
|
+
or not all(isinstance(t, dict) for t in transitions)):
|
|
89
|
+
return None, {"status": "invalid_flow",
|
|
90
|
+
"note": f"flow '{flow_name}': 'transitions' must be a list of step records "
|
|
91
|
+
f"(got {type(transitions).__name__}); record the flow again"}
|
|
92
|
+
if not isinstance(doc.get("final_url", ""), str):
|
|
93
|
+
return None, {"status": "invalid_flow",
|
|
94
|
+
"note": f"flow '{flow_name}': 'final_url' must be a string"}
|
|
84
95
|
return doc, None
|
|
85
96
|
|
|
86
97
|
|
|
@@ -157,7 +168,7 @@ def generate_invariant(store: OrdelStore, flow_name: str, parts: str, total: str
|
|
|
157
168
|
missing = [t for t in (parts, total) if t not in known]
|
|
158
169
|
if missing:
|
|
159
170
|
result.setdefault("warnings", []).append(
|
|
160
|
-
f"testid(s) not in the captured map: {', '.join(missing)}
|
|
171
|
+
f"testid(s) not in the captured map: {', '.join(missing)} - the check will "
|
|
161
172
|
"match 0 cells and fail at runtime. Re-capture the page or fix the testid "
|
|
162
173
|
"(map keys are the element testids).")
|
|
163
174
|
nm = name or f"{flow_name}-invariant"
|
|
@@ -563,6 +563,26 @@ function requirePage(msg) {
|
|
|
563
563
|
if (needsPage && !page) throw new Error("no open session — call 'open' first");
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// FINDINGS-042 #2: a click whose request never answered held the chain for as long as the
|
|
567
|
+
// server took (forever, on a hung login POST), so every later command - current_url included -
|
|
568
|
+
// timed out behind it and the session was wedged. Each page command now answers within this
|
|
569
|
+
// deadline (under the MCP side's 60 s) and frees the chain; open and close keep their own.
|
|
570
|
+
const STEP_DEADLINE_MS = 45000;
|
|
571
|
+
function withDeadline(work, cmd) {
|
|
572
|
+
work.catch(() => {}); // an abandoned step may still fail later: never an unhandled rejection
|
|
573
|
+
let timer;
|
|
574
|
+
const deadline = new Promise((_, reject) => {
|
|
575
|
+
timer = setTimeout(() => {
|
|
576
|
+
const e = new Error(`'${cmd}' did not finish within ${STEP_DEADLINE_MS / 1000}s: the page is still `
|
|
577
|
+
+ 'waiting (a request that has not answered, or a page that never settles). The session is '
|
|
578
|
+
+ 'still usable: current_url, goto another address, or browser_close.');
|
|
579
|
+
e.deadline = true;
|
|
580
|
+
reject(e);
|
|
581
|
+
}, STEP_DEADLINE_MS);
|
|
582
|
+
});
|
|
583
|
+
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
|
|
584
|
+
}
|
|
585
|
+
|
|
566
586
|
// Serialize commands so overlapping stdin lines can't race the single page.
|
|
567
587
|
let chain = Promise.resolve();
|
|
568
588
|
const rl = readline.createInterface({ input: process.stdin });
|
|
@@ -574,10 +594,17 @@ rl.on('line', (line) => {
|
|
|
574
594
|
try { msg = JSON.parse(text); } catch (e) { write({ ok: false, error: 'bad json' }); return; }
|
|
575
595
|
try {
|
|
576
596
|
requirePage(msg);
|
|
577
|
-
const
|
|
597
|
+
const bounded = msg.cmd !== 'open' && msg.cmd !== 'close';
|
|
598
|
+
const res = bounded ? await withDeadline(handle(msg), msg.cmd) : await handle(msg);
|
|
578
599
|
write({ id: msg.id, ...res });
|
|
579
600
|
if (msg.cmd === 'close') await shutdown(0);
|
|
580
601
|
} catch (e) {
|
|
602
|
+
if (e && e.deadline) { // no element diagnosis: probing the busy page could hang again
|
|
603
|
+
let url = '';
|
|
604
|
+
try { url = page ? page.url() : ''; } catch (_) { /* page gone */ }
|
|
605
|
+
write({ id: msg.id, ok: false, error_kind: 'no_response', url, error: e.message });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
581
608
|
const res = ELEMENT_CMDS.has(msg.cmd) && page ? await diagnose(msg, e)
|
|
582
609
|
: { ok: false, error: String((e && e.message) || e).replace(ANSI, '') };
|
|
583
610
|
write({ id: msg.id, ...res });
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ordel-cli"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.4"
|
|
8
8
|
description = "Ordel free CLI — local QA-automation for individual devs. Drives the deterministic ordel-engine and exposes it to a BYO coding agent over MCP. No account, no DB, no Ordel LLM."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|