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.
Files changed (42) hide show
  1. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/PKG-INFO +36 -21
  2. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/README.md +35 -20
  3. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/SKILL.md +2 -2
  4. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/cli.py +24 -24
  5. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/context.py +14 -3
  6. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/coverage.py +2 -2
  7. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/doctor.py +5 -5
  8. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/explore.py +10 -0
  9. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/ingest.py +3 -3
  10. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/mcp_server.py +22 -12
  11. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/pom.py +1 -1
  12. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/qa_plan.py +7 -7
  13. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/record.py +3 -1
  14. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/runner.py +3 -3
  15. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/scenarios.py +14 -3
  16. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/session_driver.cjs +28 -1
  17. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/pyproject.toml +1 -1
  18. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/.gitignore +0 -0
  19. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/CATALOG.md +0 -0
  20. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/LICENSE +0 -0
  21. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/PRIVACY.md +0 -0
  22. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/__init__.py +0 -0
  23. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/adopt.py +0 -0
  24. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/branding.py +0 -0
  25. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/capture.cjs +0 -0
  26. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/capture_extract.cjs +0 -0
  27. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/catalog.py +0 -0
  28. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/heal_service.py +0 -0
  29. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/human_record.cjs +0 -0
  30. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/mcp_supervisor.cjs +0 -0
  31. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/ordel_config.cjs +0 -0
  32. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/ordel_evidence.cjs +0 -0
  33. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/py.typed +0 -0
  34. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/record.cjs +0 -0
  35. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/communication-and-harnesses.md +0 -0
  36. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/execution-and-triage.md +0 -0
  37. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/intake-and-readiness.md +0 -0
  38. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/maintenance.md +0 -0
  39. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/scenarios-and-oracles.md +0 -0
  40. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/references/tools.md +0 -0
  41. {ordel_cli-0.4.2 → ordel_cli-0.4.4}/ordel_cli/session.py +0 -0
  42. {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.2
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 — a persistent, deterministic "QA brain" your
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 — run `ordel doctor` to check):
123
- - `get_app_context` / `heal_selector` — what Ordel knows + **deterministic self-heal**
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 — Ordel never spends inference).
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
- - `explore` / `record_flow` — drive the real browser to map the app + record a flow.
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` — read a mapped page back (inputs, buttons, links or all): each element's
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` — run a spec via `npx playwright test` and read the evidence-backed verdicts.
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 — no fake success)
158
+ ## In progress (honest, no fake success)
144
159
 
145
- - Single `npx ordel` distribution — the plan is a compiled Python engine binary wrapped
146
- in one npm package; not started (today's install path is `pip`/`uv`, see Dev below).
147
- - `eject` (one-command raw-Playwright export) — scaffold; but there's no lock-in today
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) — the pure deterministic core: fingerprint
168
+ - **`ordel-engine`** (sibling package): the pure deterministic core: fingerprint
154
169
  matching + self-heal, stdlib-only, zero backend.
155
- - **`ordel_cli.store`** — the `.ordel/` file store (the local shell).
156
- - **`ordel_cli.heal_service`** — heal + per-page circuit-breaker.
157
- - **`ordel_cli.mcp_server`** — the local stdio MCP server your agent connects to.
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 — there is no file lock. That
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 (`ordel push`).
175
- - **Browser tools need a local Node + `@playwright/test`** (`explore`/`record_flow`/`run_test`).
176
- They don't ship a browser; run `ordel doctor` — if Node/Playwright/Chromium are missing it
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 — a persistent, deterministic "QA brain" your
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 — run `ordel doctor` to check):
99
- - `get_app_context` / `heal_selector` — what Ordel knows + **deterministic self-heal**
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 — Ordel never spends inference).
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
- - `explore` / `record_flow` — drive the real browser to map the app + record a flow.
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` — read a mapped page back (inputs, buttons, links or all): each element's
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` — run a spec via `npx playwright test` and read the evidence-backed verdicts.
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 — no fake success)
134
+ ## In progress (honest, no fake success)
120
135
 
121
- - Single `npx ordel` distribution — the plan is a compiled Python engine binary wrapped
122
- in one npm package; not started (today's install path is `pip`/`uv`, see Dev below).
123
- - `eject` (one-command raw-Playwright export) — scaffold; but there's no lock-in today
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) — the pure deterministic core: fingerprint
144
+ - **`ordel-engine`** (sibling package): the pure deterministic core: fingerprint
130
145
  matching + self-heal, stdlib-only, zero backend.
131
- - **`ordel_cli.store`** — the `.ordel/` file store (the local shell).
132
- - **`ordel_cli.heal_service`** — heal + per-page circuit-breaker.
133
- - **`ordel_cli.mcp_server`** — the local stdio MCP server your agent connects to.
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 — there is no file lock. That
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 (`ordel push`).
151
- - **Browser tools need a local Node + `@playwright/test`** (`explore`/`record_flow`/`run_test`).
152
- They don't ship a browser; run `ordel doctor` — if Node/Playwright/Chromium are missing it
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. Performance and load testing are outside this workflow (Ordel Cloud
122
- covers them). There is no first-class API testing tool here: do not promise API, security,
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 — local QA automation for your coding agent.")
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 — local QA automation for your coding agent."""
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 — testing context for this repo\n" + body
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 — no account."""
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 — run `ordel init`")
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 — run `ordel init`")
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 — run `ordel init`")
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 — run `ordel init`")
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 — run `ordel explore <url>`)")
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 — map ∩ specs, per page (✓ full / ⚠ partial / ✗ untested)."""
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 — run `ordel init`")
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 — local QA report**",
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 scaffold (M3). 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.
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 (M3), but there's no lock-in — "
560
- "your generated tests are already plain Playwright at tests/*.spec.ts + pages/*.ts; "
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` — no separate install; uv fetches
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 — the server launches via `uvx`, so your client "
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 — add manually to {where}:")
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} — re-run with --client <name>")
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 — grounding for the agent's assertions.
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 — run `ordel init` first")
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 — run `ordel init` first")
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
- return _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))
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/ — run `ordel init` + explore first"}
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 — coverage measures the map against your specs. "
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} — validated on 1.5x/1.6x",
63
- "consider: npm i -D @playwright/test@latest")
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 — all checks passed" if fails == 0 and warns == 0 else
145
- f"{fails} to fix before recording or running, {warns} warning(s) — "
146
- "see fixes above")
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) — out of scope (Ordel does functional/e2e)")
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 — re-record those flows")
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 — ingest a subdir at a time for the rest")
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 — pick the right element id yourself"}
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 — surface as a real failure"}
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}' — the "
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 — call browser_open first")
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 — a gated "
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 — auth may be "
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 — if you expected a "
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
- "— left untouched (your edits are safe). Add the `// ordel:locators` … "
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 — this plans from the map. Map the app first "
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/ — explore first"}
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/ — explore first"}
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/ — explore first"}
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/ — nothing to select"}
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 — nothing selected",
131
- "ascii": f"Regression set — 0 specs: none of {', '.join(changed_pages)} "
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 — map '
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) — nothing to launch.\n"
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 — is Node/Playwright installed in this project?")
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 — is Node/Playwright installed in this project?")
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/ — run `ordel init` first"}
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}' — run `record_flow` first"}
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)} — the check will "
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 res = await handle(msg);
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.2"
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