ordel-cli 0.2.1__tar.gz → 0.2.2__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 (30) hide show
  1. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/PKG-INFO +3 -3
  2. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/cli.py +56 -22
  3. ordel_cli-0.2.2/ordel_cli/context.py +99 -0
  4. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/coverage.py +5 -1
  5. ordel_cli-0.2.2/ordel_cli/ingest.py +95 -0
  6. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/mcp_server.py +57 -24
  7. ordel_cli-0.2.2/ordel_cli/pom.py +56 -0
  8. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/qa_plan.py +27 -3
  9. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/record.cjs +6 -1
  10. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/pyproject.toml +3 -3
  11. ordel_cli-0.2.1/ordel_cli/pom.py +0 -38
  12. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/.gitignore +0 -0
  13. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/LICENSE +0 -0
  14. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/PRIVACY.md +0 -0
  15. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/README.md +0 -0
  16. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/__init__.py +0 -0
  17. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/branding.py +0 -0
  18. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/capture.cjs +0 -0
  19. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/capture_extract.cjs +0 -0
  20. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/doctor.py +0 -0
  21. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/explore.py +0 -0
  22. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/heal_service.py +0 -0
  23. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/mcp_supervisor.cjs +0 -0
  24. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/py.typed +0 -0
  25. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/record.py +0 -0
  26. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/runner.py +0 -0
  27. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/scenarios.py +0 -0
  28. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/session.py +0 -0
  29. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/session_driver.cjs +0 -0
  30. {ordel_cli-0.2.1 → ordel_cli-0.2.2}/ordel_cli/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ordel-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -14,8 +14,8 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Topic :: Software Development :: Testing
15
15
  Requires-Python: >=3.11
16
16
  Requires-Dist: mcp>=1.0
17
- Requires-Dist: ordel-engine==0.2.1
18
- Requires-Dist: ordel-tools==0.2.1
17
+ Requires-Dist: ordel-engine==0.2.2
18
+ Requires-Dist: ordel-tools==0.2.2
19
19
  Requires-Dist: typer>=0.12
20
20
  Provides-Extra: dev
21
21
  Requires-Dist: pytest>=8; extra == 'dev'
@@ -42,34 +42,44 @@ app = typer.Typer(add_completion=False, help="Ordel — local QA automation for
42
42
 
43
43
  _ORDEL_MD = """# ORDEL.md — testing context for this repo
44
44
 
45
- Tests here are backed by **Ordel**, a deterministic QA engine over MCP (zero Ordel LLM
46
- YOU are the brain, Ordel is the truth). Use Ordel's tools rather than hand-rolling
47
- Playwright. Call `get_app_context` first.
45
+ Tests here are backed by **Ordel**, a deterministic QA engine over MCP (zero Ordel LLM).
46
+ **Division of labor:** YOU navigate and judge ambiguity; **Ordel decides correctness.** Don't
47
+ eyeball whether the app "looks right" — route every correctness claim through an Ordel oracle
48
+ (a generated spec's pass/fail, an invariant, a fingerprint match). Agent-authored assertions
49
+ are notoriously weak; Ordel's are deterministic. Call `get_app_context` first.
48
50
 
49
51
  > First run: enable the `ordel-cli` MCP server once — the client won't expose Ordel's tools
50
52
  > until you approve it. Claude Code: `/mcp` → enable. Cursor / Copilot: enable it in the
51
53
  > MCP/tools settings where you added `.mcp.json`. After a NEW Ordel tool ships, reconnect the
52
54
  > server to pick it up (or use the equivalent `ordel <cmd>` CLI, which is always current).
53
55
 
54
- ## The point: FIND BUGS
55
- Mapping/recording is the MEANS, not the goal. The value is USING the artifacts to run
56
- tests + scenarios that expose defects. Don't stop at "the app is mapped" — drive the loop:
57
-
58
- risk_rank ─▶ plan_tests ─▶ capture/record ─▶ generate_scenarios ─▶ run_test
59
- ▲ │
60
- └──────────── regression_set ◀── coverage_report ◀────────────────┘
61
-
62
- - `risk_rank` / `plan_tests` where a bug would hurt most, and what to test first.
63
- - `generate_scenarios <flow>` turn a recorded flow into happy+NEGATIVE+boundary+replay
64
- specs. A FAILING negative/boundary test = a real bug (app accepted bad input). THIS is
65
- the outcome; run the generated spec, don't just read it.
66
- - `generate_invariant <flow> parts total` a DATA-INTEGRITY test: after replaying the flow,
67
- assert sum|min|max|count of the labeled cells == the total. Catches bugs URL/path tests
68
- can't (e.g. a discount applied twice: charged total != sum of line items).
69
- - `generate_perf_check <flow> budget_ms` a PERFORMANCE-BUDGET test: median render <= budget.
70
- Catches latency regressions / O(n^2) cliffs. (CLI: `ordel invariant`/`ordel perf` too.)
71
- - `coverage_report` which mapped elements a spec asserts vs gaps (ASCII, in-chat).
72
- - `regression_set <changed pages>`after a change, the exact specs to re-run (not all).
56
+ ## The method — don't stop at "mapped", FIND BUGS
57
+ Mapping is the MEANS; the value is USING the map to run tests that expose defects.
58
+
59
+ orient ─▶ map ─▶ risk_rank ─▶ plan_tests ─▶ per-surface: record ─▶ generate_* ─▶ run_test
60
+ ▲ (checklists) │
61
+ └───────────── regression_set ◀── coverage_report ◀───────────────────────────┘
62
+
63
+ 1. **Orient** — `assess_context` (run-mode, lifecycle, existing assets + the REQUIRED next phase;
64
+ it detects any pre-existing FUNCTIONAL suite to reuse and flags unit tests as out of scope —
65
+ Ordel builds functional/e2e, not units) then `get_app_context` (what's already mapped).
66
+ 2. **Map** YOU crawl, Ordel captures (see below). Reach every surface, not just the landing.
67
+ 3. **Prioritize** `risk_rank` (where a bug would hurt most).
68
+ 4. **Plan** — `plan_tests` classifies each page into a SURFACE (auth / form / table / search /
69
+ cart / detail) and returns that surface's **CHECKLIST** — the exact things to test, each
70
+ item naming the tool that does it. WORK THE CHECKLIST; don't improvise coverage from memory.
71
+ 5. **Execute each checklist item** with the tool it names:
72
+ - `generate_scenarios <flow>` happy + NEGATIVE (per constrained field) + boundary + replay.
73
+ A FAILING negative = a real bug (the app accepted bad input). RUN it; don't just read it.
74
+ - `generate_invariant <flow> parts total` data-integrity (sum|min|max|count == total);
75
+ catches what URL tests can't (a discount applied twice: total != sum of line items).
76
+ - `generate_perf_check <flow> budget_ms` — median render <= budget (latency cliffs).
77
+ - `run_test` — execute + record pass/fail. This is where a regression is CAUGHT.
78
+ 6. **Measure + maintain** — `coverage_report` (map ∩ specs, real gaps), `regression_set
79
+ <changed pages>` (after a change, the exact specs to re-run, not all).
80
+
81
+ The generated specs already classify gate-vs-filter, test each constrained field in isolation,
82
+ and default the run target from the recorded flow — trust their pass/fail over your own reading.
73
83
 
74
84
  ## Map the app (YOU crawl, Ordel captures)
75
85
  Reach auth-gated, wizard, and SPA states that have no unique URL:
@@ -127,6 +137,30 @@ def doctor() -> None:
127
137
  raise typer.Exit(1)
128
138
 
129
139
 
140
+ @app.command()
141
+ def assess() -> None:
142
+ """Orient: run-mode, lifecycle, existing assets + the required next phase.
143
+
144
+ Functional/e2e is Ordel's domain; unit tests (Vitest/Jest) are detected and flagged out of
145
+ scope. Reads the local project STRUCTURE only (counts/presence), no code comprehension."""
146
+ store = _store()
147
+ from ordel_cli.context import assess_context
148
+ typer.echo(assess_context(store)["ascii"])
149
+
150
+
151
+ @app.command()
152
+ def ingest(path: str = typer.Argument("", help="a spec file or dir (relative); omit for the whole project")) -> None:
153
+ """Import an existing FUNCTIONAL Playwright suite into Ordel flows (REUSE, don't re-record).
154
+
155
+ Unit tests (Vitest/Jest) are skipped as out of scope. Then `ordel scenarios <flow>` etc."""
156
+ store = _store()
157
+ if not store.exists:
158
+ typer.echo("no .ordel/ here — run `ordel init`")
159
+ raise typer.Exit(1)
160
+ from ordel_cli.ingest import ingest as _ingest
161
+ typer.echo(_ingest(store, path)["ascii"])
162
+
163
+
130
164
  @app.command()
131
165
  def status() -> None:
132
166
  """Show local coverage: pages, elements, run history."""
@@ -0,0 +1,99 @@
1
+ """assess_context adapter — the ORIENT step's I/O.
2
+
3
+ Reads the .ordel store state + does a read-only walk of the project (node_modules/.git/.ordel and
4
+ other build/output dirs excluded) to count specs, page-objects, CI, and app source, then hands the
5
+ counts to the pure ``ordel_tools.context.assess_context``. No writes; bounded work.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ordel_tools.context import ContextScan
15
+ from ordel_tools.context import assess_context as _assess
16
+
17
+ from ordel_cli.store import OrdelStore, StoreError
18
+
19
+ _SPEC_SUFFIXES = (".spec.ts", ".spec.js", ".spec.mjs", ".test.ts", ".test.js")
20
+ _SRC_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".vue", ".svelte")
21
+ _POM_HINTS = ("page.ts", "page.js", ".po.ts", ".po.js")
22
+ _SKIP_DIRS = {"node_modules", ".git", ".ordel", "dist", "build", ".venv", "venv", ".next",
23
+ "coverage", "__pycache__", ".pytest_cache", "playwright-report", "test-results"}
24
+ _MAX_SPEC_READS = 500 # cap header reads on huge suites; beyond this, classify by directory only
25
+
26
+ # Import/usage cues in a spec's header that name its test runner (all lowercased).
27
+ _FUNCTIONAL_CUES = ("@playwright/test", "cypress", "cy.visit", "cy.get", "@wdio", "webdriverio",
28
+ "selenium-webdriver", "puppeteer")
29
+ _UNIT_CUES = ("vitest", "@jest/globals", "@testing-library", "from 'jest'", "require('jest",
30
+ "mocha", "node:test", "'ava'", "\"ava\"")
31
+
32
+
33
+ def _classify_spec(head: str, posix_dir: str) -> str:
34
+ """Classify a *.spec/*.test file as ordel | functional | unit | unknown from its header
35
+ imports (deterministic). Unit and functional share the extension, so imports — not the name —
36
+ decide. Ordel builds functional/e2e; unit is out of scope."""
37
+ if "by ordel" in head: # "Generated by Ordel" (scenarios/invariant/perf) or "Recorded by Ordel"
38
+ return "ordel"
39
+ if any(c in head for c in _FUNCTIONAL_CUES):
40
+ return "functional"
41
+ if any(c in head for c in _UNIT_CUES):
42
+ return "unit"
43
+ # weak directory fallback when the header didn't name a runner (or was beyond the read cap)
44
+ if any(seg in posix_dir for seg in ("/e2e", "/cypress", "/playwright", "/integration")):
45
+ return "functional"
46
+ if any(seg in posix_dir for seg in ("/__tests__", "/unit")) or "/src/" in posix_dir:
47
+ return "unit"
48
+ return "unknown"
49
+
50
+
51
+ def assess_context(store: OrdelStore) -> dict[str, Any]:
52
+ """Scan the project + store and return the pure classifier's orientation."""
53
+ root = store.root
54
+ try:
55
+ pages = len(store.pages()) if store.exists else 0
56
+ flows = len(store.flows()) if store.exists else 0
57
+ runs = len(store.runs()) if store.exists else 0
58
+ except StoreError:
59
+ pages = flows = runs = 0
60
+
61
+ functional = ordel = unit = unknown = page_objects = source_files = 0
62
+ reads = 0
63
+ has_ci = False
64
+ for dirpath, dirnames, filenames in os.walk(root):
65
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
66
+ posix_dir = Path(dirpath).as_posix()
67
+ if "/.github/workflows" in posix_dir:
68
+ has_ci = True
69
+ for fn in filenames:
70
+ low = fn.lower()
71
+ if low.startswith("playwright.config."):
72
+ has_ci = True
73
+ if low.endswith(_SPEC_SUFFIXES):
74
+ head = ""
75
+ if reads < _MAX_SPEC_READS:
76
+ reads += 1
77
+ try:
78
+ head = (Path(dirpath) / fn).read_text(errors="ignore")[:600].lower()
79
+ except OSError:
80
+ head = ""
81
+ kind = _classify_spec(head, posix_dir)
82
+ if kind == "ordel":
83
+ functional += 1
84
+ ordel += 1
85
+ elif kind == "functional":
86
+ functional += 1
87
+ elif kind == "unit":
88
+ unit += 1
89
+ else:
90
+ unknown += 1
91
+ continue # a spec is not also a POM or generic source
92
+ if low.endswith(_POM_HINTS):
93
+ page_objects += 1
94
+ if low.endswith(_SRC_SUFFIXES):
95
+ source_files += 1
96
+
97
+ return _assess(ContextScan(pages=pages, flows=flows, runs=runs, functional_specs=functional,
98
+ ordel_specs=ordel, unit_specs=unit, unknown_specs=unknown,
99
+ page_objects=page_objects, has_ci=has_ci, source_files=source_files))
@@ -34,7 +34,7 @@ def _read_specs(root: Path) -> str:
34
34
  return "\n".join(parts).lower()
35
35
 
36
36
 
37
- def coverage_report(store: OrdelStore) -> dict[str, Any]:
37
+ 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:
@@ -46,6 +46,10 @@ def coverage_report(store: OrdelStore) -> dict[str, Any]:
46
46
  all_els = store.load_all_page_elements()
47
47
  except StoreError as e:
48
48
  return {"status": "store_error", "note": str(e)}
49
+ if not all_els and not force: # readiness gate: nothing to measure coverage against
50
+ return {"status": "not_ready", "missing": "map",
51
+ "note": "no pages captured yet — coverage measures the map against your specs. "
52
+ "Map the app first (explore_app / capture_state), or pass force=true."}
49
53
  specs = _read_specs(store.root)
50
54
  host = ""
51
55
  for pg in store.pages_summary():
@@ -0,0 +1,95 @@
1
+ """ingest adapter — the REUSE step's I/O.
2
+
3
+ Find existing FUNCTIONAL specs (Playwright/Cypress — not unit, not Ordel-generated), parse each
4
+ into Ordel flow docs, and save them so the agent can generate_scenarios / generate_invariant on
5
+ top instead of re-recording. Read-only on the app; the only writes are .ordel/flows/*.json.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ordel_tools.ingest import flow_name_for, parse_playwright_spec
15
+
16
+ from ordel_cli.context import _SKIP_DIRS, _SPEC_SUFFIXES, _classify_spec
17
+ from ordel_cli.store import OrdelStore
18
+
19
+ _MAX_FLOWS = 200 # bound the store: a huge suite ingests up to this many flows, then stops + says so
20
+
21
+
22
+ def ingest(store: OrdelStore, path: str = "") -> dict[str, Any]:
23
+ """Ingest functional specs under ``path`` (a file or dir, relative to the project) or the whole
24
+ project when omitted. Returns a summary + the created flow names."""
25
+ if not store.exists:
26
+ return {"status": "not_initialized", "note": "run `ordel init` first"}
27
+ root = store.root
28
+
29
+ targets: list[Path] = []
30
+ if path:
31
+ p = Path(path) if os.path.isabs(path) else (root / path)
32
+ if p.is_file():
33
+ targets = [p]
34
+ elif p.is_dir():
35
+ targets = [f for f in p.rglob("*") if f.name.lower().endswith(_SPEC_SUFFIXES)]
36
+ else:
37
+ return {"status": "not_found", "note": f"no such file/dir: {path}"}
38
+ else:
39
+ for dirpath, dirnames, filenames in os.walk(root):
40
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
41
+ targets += [Path(dirpath) / fn for fn in filenames
42
+ if fn.lower().endswith(_SPEC_SUFFIXES)]
43
+
44
+ ingested = flows_created = unparsed = skipped_unit = skipped_ordel = 0
45
+ created: list[str] = []
46
+ capped = False
47
+ for f in sorted(targets):
48
+ try:
49
+ src = f.read_text(errors="ignore")
50
+ except OSError:
51
+ continue
52
+ kind = _classify_spec(src[:600].lower(), f.parent.as_posix())
53
+ if kind == "ordel":
54
+ skipped_ordel += 1
55
+ continue
56
+ if kind == "unit":
57
+ skipped_unit += 1
58
+ continue
59
+ parsed = parse_playwright_spec(src)
60
+ unparsed += parsed["unparsed_actions"]
61
+ if not parsed["tests"]:
62
+ continue
63
+ ingested += 1
64
+ for i, t in enumerate(parsed["tests"]):
65
+ if flows_created >= _MAX_FLOWS:
66
+ capped = True
67
+ break
68
+ name = flow_name_for(f.name, t["title"], i)
69
+ uniq, n = name, 1
70
+ while uniq in created:
71
+ n += 1
72
+ uniq = f"{name[:60]}-{n}"
73
+ start = next((s["url"] for s in t["steps"] if s.get("op") == "goto"), "")
74
+ store.save_flow(uniq, {"name": uniq, "source": "ingested", "spec": f.name,
75
+ "start": start, "final_url": "", "steps": t["steps"]})
76
+ created.append(uniq)
77
+ flows_created += 1
78
+ if capped:
79
+ break
80
+
81
+ lines = [f"Ingested {ingested} functional spec(s) → {flows_created} flow(s)"]
82
+ if skipped_unit:
83
+ lines.append(f" skipped {skipped_unit} unit test(s) — out of scope (Ordel does functional/e2e)")
84
+ if skipped_ordel:
85
+ lines.append(f" skipped {skipped_ordel} Ordel-generated spec(s) (already ours)")
86
+ if unparsed:
87
+ lines.append(f" {unparsed} action line(s) used idioms we couldn't parse — re-record those flows")
88
+ if capped:
89
+ lines.append(f" stopped at the {_MAX_FLOWS}-flow cap — ingest a subdir at a time for the rest")
90
+ if flows_created:
91
+ lines.append(" next: generate_scenarios <flow> (adds negative/boundary on top) or "
92
+ "generate_invariant; re-record a flow to capture its final_url + strengthen")
93
+ return {"status": "ok", "specs_ingested": ingested, "flows_created": flows_created,
94
+ "flows": created, "skipped_unit": skipped_unit, "skipped_ordel": skipped_ordel,
95
+ "unparsed_actions": unparsed, "ascii": "\n".join(lines)}
@@ -55,7 +55,7 @@ FREE_TIER_TOOLS: frozenset[str] = frozenset({
55
55
  # map / record / heal
56
56
  "explore_app", "record_flow", "heal_selector", "heal_element",
57
57
  # local context + runs
58
- "get_app_context", "list_runs", "run_test",
58
+ "get_app_context", "assess_context", "ingest", "list_runs", "run_test",
59
59
  # deterministic codegen (no Ordel LLM)
60
60
  "generate_scenarios", "generate_invariant", "generate_perf_check", "generate_pom",
61
61
  # QA-mind planning (pure, map+specs derived)
@@ -110,6 +110,30 @@ def build_server(root: str | Path = ".") -> FastMCP:
110
110
  except StoreError as e:
111
111
  return {"status": "store_error", "note": str(e)}
112
112
 
113
+ @mcp.tool()
114
+ def assess_context() -> dict[str, Any]:
115
+ """ORIENT — assess this project's testing context BEFORE planning or generating. Reads the
116
+ .ordel map state + a read-only STRUCTURAL scan of the project (file presence/counts only —
117
+ no code comprehension, zero egress) and returns: run_mode (codebase-present vs
118
+ artifacts-only), lifecycle (implementation vs maintenance), existing assets, and the
119
+ REQUIRED NEXT PHASE. Ordel builds FUNCTIONAL / e2e coverage — unit tests (Vitest/Jest/
120
+ testing-library) are detected and reported OUT OF SCOPE, never counted as the suite. Call
121
+ this first (with get_app_context) so you don't jump to generation prematurely. No LLM."""
122
+ from ordel_cli.context import assess_context as _assess_context
123
+ return _sign(_assess_context(store))
124
+
125
+ @mcp.tool()
126
+ def ingest(path: str = "") -> dict[str, Any]:
127
+ """REUSE — import an existing FUNCTIONAL suite instead of re-recording it. Parses the
128
+ common Playwright idioms (goto / getByTestId|getByRole|getByLabel|getByText|locator +
129
+ fill|click|check|selectOption / toHaveURL) out of each spec into an Ordel flow (one per
130
+ `test(...)`), so you can generate_scenarios (add negative/boundary) or generate_invariant
131
+ on top. `path` narrows to a file or directory (relative to the project); omit to scan the
132
+ whole project. SKIPS unit tests (Vitest/Jest — out of scope) and Ordel's own specs; counts
133
+ any idioms it couldn't parse (re-record those). Writes .ordel/flows/*.json only. No LLM."""
134
+ from ordel_cli.ingest import ingest as _ingest
135
+ return _sign(_ingest(store, path))
136
+
113
137
  @mcp.tool()
114
138
  def heal_selector(page_id: str, broken: dict[str, Any], candidates: list[Any]) -> dict[str, Any]:
115
139
  """Deterministically heal a broken selector. `broken` is the recorded
@@ -287,24 +311,27 @@ def build_server(root: str | Path = ".") -> FastMCP:
287
311
 
288
312
  @mcp.tool()
289
313
  def generate_pom(page_id: str, write: bool = True) -> dict[str, Any]:
290
- """Generate a typed Playwright Page Object Model from the local map for `page_id`.
291
- Deterministic codegen: one stable locator per interactive element, picking the most
292
- robust selector available (testid > role+name > static id > text). Writes
293
- pages/<Class>.ts. Requires the page to have been explored first. Returns
294
- {status, class_name, locators, path, source}. No LLM."""
314
+ """Generate — or UPDATE IN PLACE — a typed Playwright Page Object Model from the local map
315
+ for `page_id`. Deterministic codegen: one stable locator per interactive element, most
316
+ robust selector first (testid > role+name > static id > text). Writes pages/<Class>.ts. On
317
+ re-run it refreshes ONLY Ordel's managed locator block (`// ordel:locators` … `// ordel:end`)
318
+ and KEEPS your own methods/imports; a hand-written POM with no markers is left untouched
319
+ (status `exists_unmanaged`). Requires the page explored first. Returns {status (generated|
320
+ updated|exists_unmanaged), class_name, locators, path, source}. No LLM."""
295
321
  from ordel_cli.pom import generate_pom as _gp
296
322
  return _sign(_gp(store, page_id, write=write))
297
323
 
298
324
  @mcp.tool()
299
- def coverage_report() -> dict[str, Any]:
325
+ def coverage_report(force: bool = False) -> dict[str, Any]:
300
326
  """What's TESTED vs NOT — cross-reference the captured map against the project's
301
327
  Playwright specs (tests/**/*.spec.ts), per page: which mapped elements a spec
302
328
  asserts and which are gaps. Deterministic (no LLM). Returns {status, summary,
303
329
  ascii, markdown, mermaid} — render `ascii` inline in chat (✓ full / ⚠ partial /
304
- ✗ untested); `markdown` is a matrix, `mermaid` a colored app-map. This is the
305
- QA-mind answering 'where are we exposed?' before shipping."""
330
+ ✗ untested); `markdown` is a matrix, `mermaid` a colored app-map. Returns `not_ready`
331
+ on an empty map (map the app first) unless force=true. The QA-mind answering
332
+ 'where are we exposed?' before shipping."""
306
333
  from ordel_cli.coverage import coverage_report as _cov
307
- return _sign(_cov(store))
334
+ return _sign(_cov(store, force))
308
335
 
309
336
  @mcp.tool()
310
337
  def generate_scenarios(flow_name: str, write: bool = True) -> dict[str, Any]:
@@ -351,31 +378,37 @@ def build_server(root: str | Path = ".") -> FastMCP:
351
378
  return _sign(_gp(store, flow_name, budget_ms, samples=samples, name=name, write=write))
352
379
 
353
380
  @mcp.tool()
354
- def risk_rank() -> dict[str, Any]:
381
+ def risk_rank(force: bool = False) -> dict[str, Any]:
355
382
  """Rank mapped pages by RISK so effort goes where a bug would hurt most.
356
- Deterministic (no LLM): score = sensitive-inputs·5 + untested·4 + forms·2 +
383
+ Deterministic (no LLM): score = sensitive-inputs·5 + untested·4 + inputs·2 +
357
384
  past-failures·3 + interactive-surface. Returns {status, rows, ascii} — render
358
- `ascii` (HIGH/MED/LOW bands). The QA-mind answering 'what should I test first?'."""
385
+ `ascii` (HIGH/MED/LOW bands). Returns `not_ready` on an empty map (map the app first)
386
+ unless force=true. The QA-mind answering 'what should I test first?'."""
359
387
  from ordel_cli.qa_plan import risk_rank as _rr
360
- return _sign(_rr(store))
388
+ return _sign(_rr(store, force))
361
389
 
362
390
  @mcp.tool()
363
- def plan_tests() -> dict[str, Any]:
364
- """A prioritized, page-by-page TEST PLAN from the map + coverage (highest risk
365
- first): which kind of test each page needs (record+generate_scenarios for untested
366
- forms, extend coverage, smoke, spot-check), sensitive pages flagged PRIORITY.
367
- Deterministic. Returns {status, rows, ascii}. The judgment the coding agent lacks."""
391
+ def plan_tests(force: bool = False) -> dict[str, Any]:
392
+ """A prioritized, page-by-page TEST PLAN from the map + coverage (highest risk first).
393
+ Classifies each page into a SURFACE (auth / form / table / search / cart / detail) and
394
+ returns that surface's CHECKLIST — the concrete things to test, each item naming the Ordel
395
+ tool that performs it (generate_scenarios / generate_invariant / …); sensitive pages
396
+ flagged PRIORITY. WORK THE CHECKLIST rather than improvising coverage. Returns `not_ready`
397
+ on an empty map (map the app first) unless force=true. Deterministic (no LLM). Returns
398
+ {status, rows[{page, score, surface, checklist[]}], ascii}."""
368
399
  from ordel_cli.qa_plan import plan_tests as _pt
369
- return _sign(_pt(store))
400
+ return _sign(_pt(store, force))
370
401
 
371
402
  @mcp.tool()
372
- def regression_set(changed_pages: list[str] | None = None) -> dict[str, Any]:
403
+ def regression_set(changed_pages: list[str] | None = None,
404
+ force: bool = False) -> dict[str, Any]:
373
405
  """The exact specs to RE-RUN after a change (impact selection): a spec is selected
374
406
  if it asserts any element on a changed page. Pass `changed_pages` (page_ids) or omit
375
- for the full set. Deterministic. Returns {status, specs, changed, ascii}. Turns
376
- 'something changed' into 'run precisely these tests', not the whole suite."""
407
+ for the full set. Returns `not_ready` on an empty map unless force=true. Deterministic.
408
+ Returns {status, specs, changed, ascii}. Turns 'something changed' into 'run precisely
409
+ these tests', not the whole suite."""
377
410
  from ordel_cli.qa_plan import regression_set as _rs
378
- return _sign(_rs(store, changed_pages))
411
+ return _sign(_rs(store, changed_pages, force))
379
412
 
380
413
  # ── Option B: persistent agent-driven browser session ──
381
414
  # The AGENT is the crawler; these give it a live browser + deterministic capture,
@@ -0,0 +1,56 @@
1
+ """Generate a Playwright Page Object Model from the local map — Tier-1 `generate_pom`.
2
+
3
+ Thin ADAPTER: the deterministic codegen (map elements -> typed POM class with one stable
4
+ locator per interactive element) is the pure `ordel_tools.pom.build_pom`. This module only
5
+ does I/O — load the page's elements from the local ``.ordel/`` store, hand them to the pure
6
+ builder, and (optionally) write the emitted source to ``pages/<class>.ts``. No LLM.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ # Re-export the pure selector helper so callers/tests that import it from here keep working.
14
+ from ordel_tools.pom import _selector, build_pom, merge_pom
15
+
16
+ from ordel_cli.store import OrdelStore, StoreError
17
+
18
+ __all__ = ["_selector", "build_pom", "merge_pom", "generate_pom"]
19
+
20
+
21
+ def generate_pom(store: OrdelStore, page_id: str, write: bool = True) -> dict[str, Any]:
22
+ """Generate — or UPDATE IN PLACE — a POM class for ``page_id`` from the map. On write, if a
23
+ page object already exists it refreshes ONLY Ordel's managed locator block and keeps the
24
+ user's own methods/imports; a hand-written POM with no markers is never overwritten. Returns
25
+ {status, class_name, locators, path, source}; status is `generated` (new file), `updated`
26
+ (managed block refreshed), or `exists_unmanaged` (left untouched). Never raises."""
27
+ try:
28
+ elements = store.load_page_elements(page_id) # T2P-2: corrupt graph -> clean status
29
+ except StoreError as e:
30
+ return {"status": "store_error", "note": str(e)}
31
+ result = build_pom(page_id, elements)
32
+ if result.get("status") != "generated":
33
+ return result
34
+ if write:
35
+ p = store.root / "pages" / f"{result['class_name']}.ts"
36
+ p.parent.mkdir(parents=True, exist_ok=True)
37
+ if p.exists():
38
+ merged, mode = merge_pom(p.read_text(errors="ignore"), result["source"])
39
+ if mode == "unmanaged":
40
+ # a hand-written / foreign POM — do NOT clobber the user's file
41
+ result["status"] = "exists_unmanaged"
42
+ result["note"] = (
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` … "
45
+ "`// ordel:end` markers where locators should refresh, or delete the file to "
46
+ "regenerate from scratch.")
47
+ result["path"] = str(p.relative_to(store.root))
48
+ return result
49
+ p.write_text(merged)
50
+ result["status"] = "updated" # refreshed the managed block, kept the rest
51
+ else:
52
+ p.write_text(result["source"])
53
+ result["path"] = str(p.relative_to(store.root))
54
+ else:
55
+ result["path"] = ""
56
+ return result
@@ -19,6 +19,23 @@ from ordel_cli.store import OrdelStore, StoreError
19
19
  __all__ = ["risk_rank", "plan_tests", "regression_set"]
20
20
 
21
21
 
22
+ def _require_map(store: OrdelStore, force: bool) -> dict[str, Any] | None:
23
+ """Readiness gate: these tools PLAN from the captured map, so an empty map yields a useless
24
+ empty result. Return a `not_ready` dict naming the missing prerequisite unless `force`. Called
25
+ after the store-exists check, so store.pages() is safe."""
26
+ if force:
27
+ return None
28
+ try:
29
+ if not store.pages():
30
+ return {"status": "not_ready", "missing": "map",
31
+ "note": "no pages captured yet — this plans from the map. Map the app first "
32
+ "(explore_app, or browser_open + goto/click + capture_state), then "
33
+ "retry. Pass force=true to run against the empty map anyway."}
34
+ except StoreError:
35
+ return None
36
+ return None
37
+
38
+
22
39
  def _failed_pages(store: OrdelStore) -> dict[str, int]:
23
40
  """page_id -> count of failed runs touching it (best-effort from run payloads)."""
24
41
  fails: dict[str, int] = {}
@@ -42,10 +59,12 @@ def _scored_rows(store: OrdelStore) -> list[dict[str, Any]]:
42
59
  return score_pages(store.load_all_page_elements(), specs, fails)
43
60
 
44
61
 
45
- def risk_rank(store: OrdelStore) -> dict[str, Any]:
62
+ def risk_rank(store: OrdelStore, force: bool = False) -> dict[str, Any]:
46
63
  """Rank mapped pages by risk. Returns {status, rows, ascii}."""
47
64
  if not store.exists:
48
65
  return {"status": "no_map", "note": "no .ordel/ — explore first"}
66
+ if gate := _require_map(store, force):
67
+ return gate
49
68
  try:
50
69
  rows = _scored_rows(store) # T2P-2: corrupt graph -> clean status, not a traceback.
51
70
  except StoreError as e:
@@ -53,10 +72,12 @@ def risk_rank(store: OrdelStore) -> dict[str, Any]:
53
72
  return build_risk_rank(rows)
54
73
 
55
74
 
56
- def plan_tests(store: OrdelStore) -> dict[str, Any]:
75
+ def plan_tests(store: OrdelStore, force: bool = False) -> dict[str, Any]:
57
76
  """A prioritized page-by-page test plan. Returns {status, rows, ascii}."""
58
77
  if not store.exists:
59
78
  return {"status": "no_map", "note": "no .ordel/ — explore first"}
79
+ if gate := _require_map(store, force):
80
+ return gate
60
81
  try:
61
82
  rows = _scored_rows(store) # T2P-2: corrupt graph -> clean status, not a traceback.
62
83
  except StoreError as e:
@@ -77,12 +98,15 @@ def _read_specs_map(root: Path) -> dict[str, str]:
77
98
  return out
78
99
 
79
100
 
80
- def regression_set(store: OrdelStore, changed_pages: list[str] | None = None) -> dict[str, Any]:
101
+ def regression_set(store: OrdelStore, changed_pages: list[str] | None = None,
102
+ force: bool = False) -> dict[str, Any]:
81
103
  """Given changed page_ids, the exact specs to re-run (a spec is selected if it asserts
82
104
  any element on a changed page). Empty/None changed -> full set. Returns
83
105
  {status, specs, changed, ascii}."""
84
106
  if not store.exists:
85
107
  return {"status": "no_map", "note": "no .ordel/ — explore first"}
108
+ if gate := _require_map(store, force):
109
+ return gate
86
110
  spec_map = _read_specs_map(store.root)
87
111
  if not spec_map:
88
112
  return {"status": "no_specs", "note": "no specs in tests/ — nothing to select"}
@@ -109,8 +109,13 @@ async function hiddenState(page) {
109
109
  case 'selectFirst': {
110
110
  // pick the first real (non-empty) option — for AJAX-populated selects whose
111
111
  // values aren't known ahead of time
112
+ // T2-REC-2: `option:not([value=""])` = the first non-placeholder option.
113
+ // The old selector also had a stray `:not([value])` (= has NO value
114
+ // attribute) — the two clauses CONTRADICT, so on any standard <select>
115
+ // (every option carries a value=) it matched ZERO options and
116
+ // getAttribute hung the full 30s before aborting the whole recording.
112
117
  const optSel = s.optionSelector
113
- || `[data-testid="${s.testid}"] option:not([value=""]):not([value])`;
118
+ || `[data-testid="${s.testid}"] option:not([value=""])`;
114
119
  const v = await page.locator(optSel).first().getAttribute('value');
115
120
  await page.selectOption(s.selector || `[data-testid="${s.testid}"]`, v);
116
121
  break;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ordel-cli"
7
- version = "0.2.1"
7
+ version = "0.2.2"
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"
@@ -22,8 +22,8 @@ classifiers = [
22
22
  "Topic :: Software Development :: Testing",
23
23
  ]
24
24
  dependencies = [
25
- "ordel-engine==0.2.1", # pinned: cli + engine version-locked (see version-sync check)
26
- "ordel-tools==0.2.1", # pinned: the shared pure-logic QA tools (data in, spec out)
25
+ "ordel-engine==0.2.2", # pinned: cli + engine version-locked (see version-sync check)
26
+ "ordel-tools==0.2.2", # pinned: the shared pure-logic QA tools (data in, spec out)
27
27
  "mcp>=1.0",
28
28
  "typer>=0.12",
29
29
  ]
@@ -1,38 +0,0 @@
1
- """Generate a Playwright Page Object Model from the local map — Tier-1 `generate_pom`.
2
-
3
- Thin ADAPTER: the deterministic codegen (map elements -> typed POM class with one stable
4
- locator per interactive element) is the pure `ordel_tools.pom.build_pom`. This module only
5
- does I/O — load the page's elements from the local ``.ordel/`` store, hand them to the pure
6
- builder, and (optionally) write the emitted source to ``pages/<class>.ts``. No LLM.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from typing import Any
12
-
13
- # Re-export the pure selector helper so callers/tests that import it from here keep working.
14
- from ordel_tools.pom import _selector, build_pom
15
-
16
- from ordel_cli.store import OrdelStore, StoreError
17
-
18
- __all__ = ["_selector", "build_pom", "generate_pom"]
19
-
20
-
21
- def generate_pom(store: OrdelStore, page_id: str, write: bool = True) -> dict[str, Any]:
22
- """Generate a POM class for ``page_id`` from the map. Returns {status, class_name,
23
- locators, path, source}. Never raises for expected cases (unknown page)."""
24
- try:
25
- elements = store.load_page_elements(page_id) # T2P-2: corrupt graph -> clean status
26
- except StoreError as e:
27
- return {"status": "store_error", "note": str(e)}
28
- result = build_pom(page_id, elements)
29
- if result.get("status") != "generated":
30
- return result
31
- path = ""
32
- if write:
33
- p = store.root / "pages" / f"{result['class_name']}.ts"
34
- p.parent.mkdir(parents=True, exist_ok=True)
35
- p.write_text(result["source"])
36
- path = str(p.relative_to(store.root))
37
- result["path"] = path
38
- return result
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