ordel-cli 0.2.0__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.
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/PKG-INFO +3 -3
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/cli.py +56 -22
- ordel_cli-0.2.2/ordel_cli/context.py +99 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/coverage.py +5 -1
- ordel_cli-0.2.2/ordel_cli/ingest.py +95 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/mcp_server.py +57 -24
- ordel_cli-0.2.2/ordel_cli/pom.py +56 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/qa_plan.py +27 -3
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/record.cjs +6 -1
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/pyproject.toml +3 -3
- ordel_cli-0.2.0/ordel_cli/pom.py +0 -38
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/.gitignore +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/LICENSE +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/PRIVACY.md +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/README.md +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/__init__.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/branding.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/capture.cjs +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/capture_extract.cjs +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/doctor.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/explore.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/heal_service.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/mcp_supervisor.cjs +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/py.typed +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/record.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/runner.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/scenarios.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/session.py +0 -0
- {ordel_cli-0.2.0 → ordel_cli-0.2.2}/ordel_cli/session_driver.cjs +0 -0
- {ordel_cli-0.2.0 → 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.
|
|
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.
|
|
18
|
-
Requires-Dist: ordel-tools==0.2.
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
55
|
-
Mapping
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- `
|
|
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
|
|
291
|
-
Deterministic codegen: one stable locator per interactive element,
|
|
292
|
-
robust selector
|
|
293
|
-
|
|
294
|
-
|
|
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.
|
|
305
|
-
|
|
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 +
|
|
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).
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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.
|
|
376
|
-
'something changed' into 'run precisely
|
|
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
|
|
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=""])
|
|
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.
|
|
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.
|
|
26
|
-
"ordel-tools==0.2.
|
|
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
|
]
|
ordel_cli-0.2.0/ordel_cli/pom.py
DELETED
|
@@ -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
|
|
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
|