ordel-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Ordel free CLI — local QA-automation shell over the deterministic engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ordel_cli.heal_service import CircuitOpenError, HealService
6
+ from ordel_cli.store import OrdelStore
7
+
8
+ __all__ = ["OrdelStore", "HealService", "CircuitOpenError"]
ordel_cli/branding.py ADDED
@@ -0,0 +1,26 @@
1
+ """Ordel attribution — the ◉◯ two-circle mark on key MCP responses.
2
+
3
+ When a coding agent relays Ordel's output, Ordel's work can get shadowed by the agent's
4
+ own voice. A small, consistent signature on the KEY tool responses (coverage, scenarios,
5
+ risk/plan/regression, explore, record, pom) keeps the attribution visible without being
6
+ noisy — one line appended to the ascii, and a `signature` field on the payload.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ SIGNATURE = "◉◯ ordel"
14
+ _FOOTER = "\n " + SIGNATURE
15
+
16
+
17
+ def sign(result: dict[str, Any]) -> dict[str, Any]:
18
+ """Attach Ordel's signature to a tool result (idempotent): a `signature` field, and a
19
+ footer line on the `ascii` output when present. Mutates and returns the same dict."""
20
+ if not isinstance(result, dict):
21
+ return result
22
+ result.setdefault("signature", SIGNATURE)
23
+ ascii_out = result.get("ascii")
24
+ if isinstance(ascii_out, str) and SIGNATURE not in ascii_out:
25
+ result["ascii"] = ascii_out + _FOOTER
26
+ return result
ordel_cli/capture.cjs ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Ordel free-CLI page capture (one-shot).
3
+ *
4
+ * Navigates to a URL with the PROJECT's own Playwright (path passed via
5
+ * ORDEL_PW_PATH so require() resolves against the dev's node_modules, exactly
6
+ * like run_test uses the project's `npx playwright`) and extracts signals for
7
+ * the page's meaningful elements via the shared extractor. Emits a JSON document:
8
+ * { "url", "title", "elements": [ { "key", signals... }, ... ] }
9
+ * The Python side turns each element's signals into an ElementFingerprint. No LLM.
10
+ *
11
+ * This is the single-shot path (open→goto→capture→close). The persistent
12
+ * agent-driven path lives in session_driver.cjs and shares the same extractor.
13
+ *
14
+ * Usage: ORDEL_PW_PATH=/abs/@playwright/test node capture.cjs <url>
15
+ */
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const { extract } = require('./capture_extract.cjs');
20
+
21
+ const url = process.argv[2];
22
+ const pwPath = process.env.ORDEL_PW_PATH;
23
+ if (!url || !pwPath) {
24
+ console.error('usage: ORDEL_PW_PATH=<@playwright/test> node capture.cjs <url>');
25
+ process.exit(2);
26
+ }
27
+
28
+ (async () => {
29
+ // absolute so Node treats it as a path, not a node_modules package lookup
30
+ // eslint-disable-next-line
31
+ const { chromium } = require(path.resolve(pwPath));
32
+ const browser = await chromium.launch({ headless: process.env.ORDEL_HEADED !== '1' });
33
+ try {
34
+ const page = await browser.newPage();
35
+ const resp = await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
36
+ const httpStatus = resp ? resp.status() : 0;
37
+ const title = await page.title();
38
+ const elements = await page.evaluate(extract);
39
+ process.stdout.write(JSON.stringify({ url: page.url(), title, elements, httpStatus }));
40
+ } finally {
41
+ await browser.close();
42
+ }
43
+ })().catch((e) => {
44
+ console.error(String((e && e.stack) || e));
45
+ process.exit(1);
46
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared in-browser DOM extractor for Ordel capture.
3
+ *
4
+ * Used by BOTH the one-shot `capture.cjs` and the persistent `session_driver.cjs`, so
5
+ * the fingerprint signals are identical whichever path captured them. `extract` is
6
+ * self-contained: it runs inside the page via `page.evaluate(extract)` and references
7
+ * only browser globals (document, getComputedStyle, CSS), never Node scope.
8
+ */
9
+ 'use strict';
10
+
11
+ function extract() {
12
+ const canon = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
13
+ const IMPLICIT_ROLE = {
14
+ A: 'link', BUTTON: 'button', INPUT: 'textbox', SELECT: 'combobox',
15
+ TEXTAREA: 'textbox', H1: 'heading', H2: 'heading', H3: 'heading',
16
+ H4: 'heading', H5: 'heading', H6: 'heading', IMG: 'img', LABEL: 'label',
17
+ };
18
+ const roleOf = (el) => {
19
+ const explicit = el.getAttribute('role');
20
+ if (explicit) return explicit;
21
+ if (el.tagName === 'INPUT') {
22
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
23
+ if (t === 'checkbox') return 'checkbox';
24
+ if (t === 'radio') return 'radio';
25
+ if (t === 'button' || t === 'submit') return 'button';
26
+ return 'textbox';
27
+ }
28
+ return IMPLICIT_ROLE[el.tagName] || '';
29
+ };
30
+ const directText = (el) => {
31
+ let t = '';
32
+ for (const n of el.childNodes) if (n.nodeType === 3) t += n.textContent;
33
+ return t;
34
+ };
35
+ const accessibleName = (el) => {
36
+ const aria = el.getAttribute('aria-label');
37
+ if (aria) return aria;
38
+ const lb = el.getAttribute('aria-labelledby');
39
+ if (lb) {
40
+ const parts = lb.split(/\s+/).map((id) => {
41
+ const r = document.getElementById(id);
42
+ return r ? r.textContent : '';
43
+ });
44
+ const j = parts.join(' ').trim();
45
+ if (j) return j;
46
+ }
47
+ if (el.id) {
48
+ const lab = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
49
+ if (lab && lab.textContent.trim()) return lab.textContent;
50
+ }
51
+ const wrap = el.closest('label');
52
+ if (wrap && wrap.textContent.trim()) return wrap.textContent;
53
+ const txt = (el.textContent || '').trim();
54
+ if (txt) return txt;
55
+ return el.getAttribute('placeholder') || el.getAttribute('alt')
56
+ || el.getAttribute('title') || '';
57
+ };
58
+ const visible = (el) => {
59
+ const r = el.getBoundingClientRect();
60
+ if (r.width === 0 && r.height === 0) return false;
61
+ const s = getComputedStyle(el);
62
+ return s.visibility !== 'hidden' && s.display !== 'none';
63
+ };
64
+
65
+ const INTERACTIVE = 'a,button,input,select,textarea,[role],[data-testid],label,iframe,h1,h2,h3,h4,h5,h6';
66
+ const FORM_CONTROL = { INPUT: 1, SELECT: 1, TEXTAREA: 1 };
67
+ const seen = new Set();
68
+ const picked = [];
69
+ // 1) interactive + landmark elements. Form controls are captured even when hidden —
70
+ // hidden inputs carry FORM STATE (continuation/csrf/state tokens, ids).
71
+ for (const el of document.querySelectorAll(INTERACTIVE)) {
72
+ if (!FORM_CONTROL[el.tagName] && !visible(el)) continue;
73
+ seen.add(el);
74
+ picked.push(el);
75
+ }
76
+ // 2) value-like leaves: short direct text, no element children carrying text.
77
+ for (const el of document.querySelectorAll('span,div,dd,td,p,strong,b,em,small')) {
78
+ if (seen.has(el)) continue;
79
+ const dt = directText(el).trim();
80
+ if (!dt || dt.length > 60) continue;
81
+ if (!visible(el)) continue;
82
+ picked.push(el);
83
+ }
84
+
85
+ const usedKeys = Object.create(null);
86
+ const out = [];
87
+ picked.forEach((el, idx) => {
88
+ const role = roleOf(el);
89
+ const name = accessibleName(el).trim();
90
+ const testid = el.getAttribute('data-testid') || '';
91
+ const r = el.getBoundingClientRect();
92
+ const classTokens = (el.getAttribute('class') || '').split(/\s+/).filter(Boolean);
93
+ const slug = (s) => s.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 40);
94
+ let key = testid || (name ? `${role || el.tagName.toLowerCase()}:${slug(name)}`
95
+ : `${el.tagName.toLowerCase()}:${idx}`);
96
+ if (usedKeys[key]) key = `${key}#${usedKeys[key]++}`; else usedKeys[key] = 1;
97
+ out.push({
98
+ key,
99
+ tag_canon: canon(el.tagName),
100
+ role_canon: canon(role),
101
+ accessibleName_canon: canon(name),
102
+ testid_canon: canon(testid),
103
+ nameAttr_canon: canon(el.getAttribute('name') || ''),
104
+ id_canon: canon(el.id || ''),
105
+ id: el.id || '',
106
+ text: (el.textContent || '').trim().slice(0, 200),
107
+ classTokens,
108
+ href: el.getAttribute('href') || '',
109
+ type: el.getAttribute('type') || '',
110
+ placeholder: el.getAttribute('placeholder') || '',
111
+ title: el.getAttribute('title') || '',
112
+ alt: el.getAttribute('alt') || '',
113
+ boundingBox: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
114
+ });
115
+ });
116
+ return out;
117
+ }
118
+
119
+ module.exports = { extract };