aetherius 0.2.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.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
@@ -0,0 +1,262 @@
1
+ """The recorder's floating "mod menu": an in-page overlay to pick data and pose meta-actions.
2
+
3
+ Injected like the debug overlay (``add_init_script``, re-installed on every navigation), but isolated
4
+ in a Shadow DOM so the page's CSS cannot mangle it and vice versa. It hosts a toolbar (Pick data /
5
+ Pick table / Wait for / Make input / Finish); a hover highlight; and small panels to choose the
6
+ extraction format. Element descriptors and selectors come from the shared ``window.__aeSel``
7
+ (:mod:`._selector_js`); the ranking policy stays pure Python (:mod:`.selector_synth`).
8
+
9
+ All emit paths funnel through ``window.__aeOverlay.{emitExtract,emitRecords,emitWaitFor,emitParam}``,
10
+ which the UI calls and integration tests drive directly (so the payload contract is testable without
11
+ replaying the whole click-through). While any mode is active, ``window.__aeRecorderPicking`` tells the
12
+ action-capture script to stand down.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # Self-invoking, guarded. Requires window.__aeSel (inject SELECTOR_JS first). Emits through the same
18
+ # __aetherius_capture binding as action capture, with pick/records/wait_for/parameterize/finish kinds.
19
+ OVERLAY_JS = r"""
20
+ (() => {
21
+ if (window.__aeOverlay) return;
22
+ const OVERLAY_ID = '__ae-recorder-overlay';
23
+ const ACCENT = '#c9a94a';
24
+
25
+ const send = (p) => { try { window.__aetherius_capture(JSON.stringify(p)); } catch (e) {} };
26
+ const S = () => window.__aeSel;
27
+
28
+ const STATUS = {
29
+ idle: 'Idle — pick a tool.',
30
+ pick: 'Pick data: click an element to extract.',
31
+ table: 'Pick table: click the repeating item, then each field, then Done.',
32
+ waitfor: 'Wait for: click the element to wait for.',
33
+ makeinput: 'Make input: click a field you filled to parameterize it.',
34
+ };
35
+
36
+ let host, root, statusEl, panelEl, highlight, doneBtn;
37
+ let mode = 'idle';
38
+ let container = null; // repeating-record container element (table mode)
39
+ let fields = []; // [{name, el, as, attr}]
40
+
41
+ const guessType = (el) => {
42
+ const tag = el.tagName.toLowerCase();
43
+ if (tag === 'img') return { as: 'attr', attr: 'src' };
44
+ if (tag === 'a') return { as: 'attr', attr: 'href' };
45
+ const t = (el.textContent || '').trim();
46
+ if (t && t.length < 24 && /\d/.test(t) && /^[^A-Za-z]*-?\d/.test(t)) return { as: 'number', attr: '' };
47
+ return { as: 'text', attr: '' };
48
+ };
49
+
50
+ const guessName = (el) => {
51
+ const raw = el.getAttribute('data-testid') || el.id || el.getAttribute('name') ||
52
+ (S().visibleText(el) || '') || el.tagName.toLowerCase();
53
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'field';
54
+ };
55
+
56
+ // ── Emit API (called by the UI and by integration tests) ──────────────────
57
+ window.__aeOverlay = {
58
+ emitExtract(el, cfg) {
59
+ // `group` (matches all siblings) is used when as is list/count; single values use the
60
+ // unique selector Python synthesizes from the descriptor.
61
+ send({ kind: 'extract', descriptor: S().descriptorFor(el), name: cfg.name, as: cfg.as,
62
+ attr: cfg.attr || null, item: cfg.item || null, group: S().groupSelector(el) });
63
+ },
64
+ emitRecords(cont, fieldDefs, name) {
65
+ send({ kind: 'extract_records', descriptor: S().descriptorFor(cont), name,
66
+ each: S().groupSelector(cont),
67
+ fields: fieldDefs.map((f) => ({
68
+ name: f.name, selector: S().relativeSelector(cont, f.el),
69
+ as: f.as || 'text', attr: f.attr || null })) });
70
+ },
71
+ emitWaitFor(el) {
72
+ // group selector fallback: waiting is a presence check, so a class that matches the whole
73
+ // group beats a brittle positional path when the element has no unique hook.
74
+ send({ kind: 'wait_for', descriptor: S().descriptorFor(el), group: S().groupSelector(el) });
75
+ },
76
+ emitParam(el, name) { send({ kind: 'parameterize', descriptor: S().descriptorFor(el), name }); },
77
+ setMode: (m) => setMode(m),
78
+ };
79
+
80
+ // ── UI ─────────────────────────────────────────────────────────────────────
81
+ const el = (tag, css, text) => {
82
+ const n = document.createElement(tag);
83
+ if (css) n.style.cssText = css;
84
+ if (text != null) n.textContent = text;
85
+ return n;
86
+ };
87
+
88
+ const button = (label, onclick) => {
89
+ const b = el('button',
90
+ 'margin:0 4px 0 0;padding:4px 8px;font:12px system-ui;cursor:pointer;border-radius:5px;' +
91
+ 'border:1px solid ' + ACCENT + '55;background:#1c1830;color:#e7e2f3;', label);
92
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
93
+ return b;
94
+ };
95
+
96
+ const clearPanel = () => { if (panelEl) panelEl.innerHTML = ''; };
97
+
98
+ const setMode = (m) => {
99
+ mode = m;
100
+ window.__aeRecorderPicking = (m !== 'idle');
101
+ if (statusEl) statusEl.textContent = STATUS[m] || '';
102
+ if (highlight) highlight.style.display = (m === 'idle') ? 'none' : 'block';
103
+ if (doneBtn) doneBtn.style.display = (m === 'table') ? 'inline-block' : 'none';
104
+ if (m === 'idle') { clearPanel(); container = null; fields = []; }
105
+ };
106
+
107
+ const fieldRow = (labelText, node) => {
108
+ const row = el('div', 'display:flex;align-items:center;gap:6px;margin:4px 0;');
109
+ row.appendChild(el('label', 'font:12px system-ui;color:#b9b2d0;min-width:64px;', labelText));
110
+ row.appendChild(node);
111
+ return row;
112
+ };
113
+
114
+ const textInput = (value) => {
115
+ const i = el('input', 'flex:1;padding:3px 6px;font:12px system-ui;border-radius:4px;' +
116
+ 'border:1px solid #4a4368;background:#0f0b1e;color:#e7e2f3;');
117
+ i.value = value || '';
118
+ return i;
119
+ };
120
+
121
+ const typeSelect = (value) => {
122
+ const s = el('select', 'padding:3px;font:12px system-ui;border-radius:4px;' +
123
+ 'border:1px solid #4a4368;background:#0f0b1e;color:#e7e2f3;');
124
+ for (const t of ['text', 'number', 'html', 'attr', 'count']) {
125
+ const o = el('option', '', t); o.value = t; if (t === value) o.selected = true; s.appendChild(o);
126
+ }
127
+ return s;
128
+ };
129
+
130
+ // Format panel for a single element (Pick data).
131
+ const openFormPanel = (target) => {
132
+ clearPanel();
133
+ const g = guessType(target);
134
+ const name = textInput(guessName(target));
135
+ const type = typeSelect(g.as);
136
+ const attr = textInput(g.attr);
137
+ const attrRow = fieldRow('attr', attr);
138
+ const allBox = el('input'); allBox.type = 'checkbox';
139
+ const sync = () => { attrRow.style.display = (type.value === 'attr') ? 'flex' : 'none'; };
140
+ type.addEventListener('change', sync);
141
+
142
+ panelEl.appendChild(fieldRow('name', name));
143
+ panelEl.appendChild(fieldRow('type', type));
144
+ panelEl.appendChild(attrRow);
145
+ panelEl.appendChild(fieldRow('all matching', allBox));
146
+ const actions = el('div', 'margin-top:6px;');
147
+ actions.appendChild(button('Add', () => {
148
+ const asList = allBox.checked && type.value !== 'count';
149
+ const as = asList ? 'list' : type.value;
150
+ window.__aeOverlay.emitExtract(target, { name: name.value, as,
151
+ attr: type.value === 'attr' ? attr.value : '', item: asList ? type.value : null });
152
+ setMode('idle');
153
+ }));
154
+ actions.appendChild(button('Cancel', () => setMode('idle')));
155
+ panelEl.appendChild(actions);
156
+ sync();
157
+ };
158
+
159
+ // Field panel for a record field (Pick table, after the container is chosen).
160
+ const openFieldPanel = (target) => {
161
+ clearPanel();
162
+ const g = guessType(target);
163
+ const name = textInput(guessName(target));
164
+ const type = typeSelect(g.as === 'count' ? 'text' : g.as);
165
+ panelEl.appendChild(el('div', 'font:12px system-ui;color:' + ACCENT + ';', 'Record field'));
166
+ panelEl.appendChild(fieldRow('name', name));
167
+ panelEl.appendChild(fieldRow('type', type));
168
+ const actions = el('div', 'margin-top:6px;');
169
+ actions.appendChild(button('Add field', () => {
170
+ fields.push({ name: name.value, el: target, as: type.value, attr: g.attr });
171
+ statusEl.textContent = fields.length + ' field(s) — click more, then Done.';
172
+ clearPanel();
173
+ }));
174
+ panelEl.appendChild(actions);
175
+ };
176
+
177
+ const openInputPanel = (target) => {
178
+ clearPanel();
179
+ const name = textInput(guessName(target));
180
+ panelEl.appendChild(fieldRow('input name', name));
181
+ const actions = el('div', 'margin-top:6px;');
182
+ actions.appendChild(button('Make input', () => {
183
+ window.__aeOverlay.emitParam(target, name.value); setMode('idle');
184
+ }));
185
+ actions.appendChild(button('Cancel', () => setMode('idle')));
186
+ panelEl.appendChild(actions);
187
+ };
188
+
189
+ const targetOf = (e) => {
190
+ const path = e.composedPath ? e.composedPath() : [e.target];
191
+ if (path.some((n) => n && n.id === OVERLAY_ID)) return null; // never target the overlay
192
+ return e.target;
193
+ };
194
+
195
+ const onMove = (e) => {
196
+ if (mode === 'idle' || !highlight) return;
197
+ const t = targetOf(e);
198
+ if (!t || !t.getBoundingClientRect) { highlight.style.display = 'none'; return; }
199
+ const r = t.getBoundingClientRect();
200
+ highlight.style.display = 'block';
201
+ highlight.style.left = r.left + 'px'; highlight.style.top = r.top + 'px';
202
+ highlight.style.width = r.width + 'px'; highlight.style.height = r.height + 'px';
203
+ };
204
+
205
+ const onClick = (e) => {
206
+ if (mode === 'idle') return;
207
+ const t = targetOf(e);
208
+ if (!t) return; // clicks on the overlay's own controls fall through to their handlers
209
+ e.preventDefault(); e.stopPropagation();
210
+ if (mode === 'pick') { openFormPanel(t); }
211
+ else if (mode === 'waitfor') { window.__aeOverlay.emitWaitFor(t); setMode('idle'); }
212
+ else if (mode === 'makeinput') { openInputPanel(t); }
213
+ else if (mode === 'table') {
214
+ if (!container) { container = t; statusEl.textContent = 'Container set — click each field.'; }
215
+ else { openFieldPanel(t); }
216
+ }
217
+ };
218
+
219
+ const install = () => {
220
+ const rootEl = document.documentElement;
221
+ if (!rootEl || document.getElementById(OVERLAY_ID)) return;
222
+ host = el('div'); host.id = OVERLAY_ID;
223
+ host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;';
224
+ rootEl.appendChild(host);
225
+ root = host.attachShadow({ mode: 'open' });
226
+
227
+ highlight = el('div', 'position:fixed;z-index:2147483646;pointer-events:none;display:none;' +
228
+ 'border:2px solid ' + ACCENT + ';background:' + ACCENT + '22;box-sizing:border-box;');
229
+ root.appendChild(highlight);
230
+
231
+ const bar = el('div', 'position:fixed;top:10px;right:10px;width:270px;padding:8px 10px;' +
232
+ 'font:12px system-ui;background:#171327ee;color:#e7e2f3;border:1px solid ' + ACCENT + '66;' +
233
+ 'border-radius:8px;box-shadow:0 6px 24px #000a;');
234
+ const title = el('div', 'font-weight:600;color:' + ACCENT + ';margin-bottom:6px;', 'Aetherius recorder');
235
+ const tools = el('div', 'margin-bottom:6px;');
236
+ tools.appendChild(button('Pick data', () => setMode('pick')));
237
+ tools.appendChild(button('Pick table', () => setMode('table')));
238
+ tools.appendChild(button('Wait for', () => setMode('waitfor')));
239
+ tools.appendChild(button('Make input', () => setMode('makeinput')));
240
+ doneBtn = button('Done', () => {
241
+ if (container && fields.length) window.__aeOverlay.emitRecords(container, fields, 'records');
242
+ setMode('idle');
243
+ });
244
+ doneBtn.style.display = 'none';
245
+ tools.appendChild(doneBtn);
246
+ tools.appendChild(button('Finish', () => { setMode('idle'); send({ kind: 'finish' }); }));
247
+ statusEl = el('div', 'color:#b9b2d0;min-height:16px;', STATUS.idle);
248
+ panelEl = el('div', 'margin-top:6px;');
249
+ bar.appendChild(title); bar.appendChild(tools); bar.appendChild(statusEl); bar.appendChild(panelEl);
250
+ root.appendChild(bar);
251
+
252
+ document.addEventListener('mousemove', onMove, true);
253
+ document.addEventListener('click', onClick, true);
254
+ };
255
+
256
+ if (document.readyState === 'loading') {
257
+ document.addEventListener('DOMContentLoaded', install);
258
+ } else {
259
+ install();
260
+ }
261
+ })();
262
+ """
@@ -0,0 +1,49 @@
1
+ """Shared Playwright plumbing for the recorders: lazy import and the synchronous pump loop.
2
+
3
+ Both recorders drive a visible browser and collect callbacks that the sync Playwright API only
4
+ dispatches while a Playwright call is in flight. They share the same lazy import (so ``import
5
+ aetherius`` stays light and a missing extra is a typed error) and the same idle pump, kept here once.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from typing import Any
12
+
13
+ from ..core.errors import DependencyError
14
+
15
+ # How often the pump wakes to let the sync Playwright API dispatch pending binding/event callbacks.
16
+ _PUMP_INTERVAL_MS = 200
17
+
18
+
19
+ def import_playwright() -> Any:
20
+ """Import the sync Playwright entry point, or raise a typed, actionable error."""
21
+ try:
22
+ from playwright.sync_api import sync_playwright
23
+ except ImportError as exc: # extra [browser] not installed
24
+ raise DependencyError(
25
+ "The recorder requires Playwright. Install it with:\n"
26
+ ' pip install "aetherius[browser]" && playwright install chromium',
27
+ extra="browser",
28
+ ) from exc
29
+ return sync_playwright
30
+
31
+
32
+ def pump(context: Any, disconnected: threading.Event, *stop_events: threading.Event | None) -> None:
33
+ """Keep the sync API alive so callbacks fire, until the window closes or a stop event is set.
34
+
35
+ Several stop events are accepted (e.g. the caller's Stop and the overlay's Finish); any one ends
36
+ the loop. ``None`` entries are ignored so callers can pass optional events directly.
37
+ """
38
+ while not disconnected.is_set() and not any(_is_set(e) for e in stop_events):
39
+ pages = context.pages
40
+ if not pages:
41
+ break
42
+ try:
43
+ pages[0].wait_for_timeout(_PUMP_INTERVAL_MS)
44
+ except Exception: # the page or context went away: the demonstration is over
45
+ break
46
+
47
+
48
+ def _is_set(event: threading.Event | None) -> bool:
49
+ return event is not None and event.is_set()
@@ -0,0 +1,148 @@
1
+ """Shared in-page selector primitives, installed on ``window.__aeSel``.
2
+
3
+ The only place that can call ``CSS.escape`` and measure a selector's uniqueness against the live DOM.
4
+ Both the action-capture script (:mod:`._capture_js`) and the pick overlay (:mod:`._overlay_js`) build
5
+ their element descriptors from here, so the candidate/uniqueness logic exists once. The ranking policy
6
+ that turns candidates into a Blueprint selector stays pure Python (:mod:`.selector_synth`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # Self-invoking, guarded. Attaches the primitives to window so later scripts can reuse them.
12
+ SELECTOR_JS = r"""
13
+ (() => {
14
+ if (window.__aeSel) return;
15
+
16
+ const CLICKABLE = 'a, button, [role="button"], input[type="submit"], input[type="button"], ' +
17
+ 'input[type="reset"], input[type="checkbox"], input[type="radio"]';
18
+
19
+ const isUnique = (sel, root) => {
20
+ try { return (root || document).querySelectorAll(sel).length === 1; } catch (e) { return false; }
21
+ };
22
+
23
+ // Escape a value for use inside an [attr="..."] string: only backslash and double-quote.
24
+ // (CSS.escape is for identifiers like #id/.class and would over-escape `/`, spaces, etc.)
25
+ const attrValue = (v) => String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+
27
+ const testIdAttr = (el) => {
28
+ for (const name of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
29
+ if (el.hasAttribute(name)) return [name, el.getAttribute(name)];
30
+ }
31
+ return null;
32
+ };
33
+
34
+ const cssPath = (el) => {
35
+ const parts = [];
36
+ let node = el;
37
+ while (node && node.nodeType === 1 && node !== document.body) {
38
+ let part = node.tagName.toLowerCase();
39
+ if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
40
+ const parent = node.parentElement;
41
+ if (parent) {
42
+ const sameTag = [...parent.children].filter((c) => c.tagName === node.tagName);
43
+ if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
44
+ }
45
+ parts.unshift(part);
46
+ node = node.parentElement;
47
+ }
48
+ return parts.join(' > ');
49
+ };
50
+
51
+ const visibleText = (el) => {
52
+ const t = (el.textContent || '').trim().replace(/\s+/g, ' ');
53
+ return t.length > 0 && t.length <= 64 ? t : null;
54
+ };
55
+
56
+ const candidates = (el) => {
57
+ const tag = el.tagName.toLowerCase();
58
+ const out = [];
59
+ const push = (strategy, selector, selector_type) =>
60
+ out.push({ strategy, selector, selector_type, unique: isUnique(selector) });
61
+
62
+ const testId = testIdAttr(el);
63
+ if (testId) push('testid', '[' + testId[0] + '="' + attrValue(testId[1]) + '"]', 'css');
64
+ if (el.id) push('id', '#' + CSS.escape(el.id), 'css');
65
+ const href = tag === 'a' ? el.getAttribute('href') : null; // the natural, stable hook for a link
66
+ if (href) push('href', 'a[href="' + attrValue(href) + '"]', 'css');
67
+ const name = el.getAttribute('name');
68
+ if (name) push('name', tag + '[name="' + attrValue(name) + '"]', 'css');
69
+ const aria = el.getAttribute('aria-label');
70
+ if (aria) push('aria', '[aria-label="' + attrValue(aria) + '"]', 'css');
71
+ const role = el.getAttribute('role');
72
+ if (role) push('role', tag + '[role="' + attrValue(role) + '"]', 'css');
73
+ for (const cls of el.classList) { // a single class that is unique is a decent, readable hook
74
+ const s = '.' + CSS.escape(cls);
75
+ if (isUnique(s)) { out.push({ strategy: 'class', selector: s, selector_type: 'css', unique: true }); break; }
76
+ }
77
+
78
+ if (el.matches(CLICKABLE) || role === 'button') {
79
+ const text = visibleText(el);
80
+ if (text) {
81
+ // Uniqueness must match get_by_text at run time: substring, case-insensitive, whole DOM.
82
+ // Count the *deepest* elements containing the text (no child also contains it), which
83
+ // approximates get_by_text's leaf matching. A brittle "License"-style text stays non-unique.
84
+ const norm = (s) => (s || '').trim().replace(/\s+/g, ' ').toLowerCase();
85
+ const needle = norm(text);
86
+ const hits = [...document.querySelectorAll('*')].filter((e) =>
87
+ norm(e.textContent).includes(needle) &&
88
+ ![...e.children].some((c) => norm(c.textContent).includes(needle)));
89
+ out.push({ strategy: 'text', selector: text, selector_type: 'text', unique: hits.length === 1 });
90
+ }
91
+ }
92
+ return out;
93
+ };
94
+
95
+ const descriptorFor = (el) => ({
96
+ tag: el.tagName.toLowerCase(),
97
+ css_path: cssPath(el),
98
+ text: visibleText(el),
99
+ name: el.getAttribute('name'),
100
+ field_type: (el.getAttribute('type') || '').toLowerCase() || null,
101
+ autocomplete: el.getAttribute('autocomplete'),
102
+ candidates: candidates(el),
103
+ });
104
+
105
+ // A selector matching `el` AND its repeating siblings (for lists/records: the shared class that
106
+ // yields the most matches, else the tag). Distinct from candidates(), which seek a *unique* hook.
107
+ const groupSelector = (el) => {
108
+ let best = null, bestCount = 1;
109
+ for (const cls of el.classList) {
110
+ const s = '.' + CSS.escape(cls);
111
+ const n = isUnique(s) ? 1 : (() => { try { return document.querySelectorAll(s).length; } catch (e) { return 0; } })();
112
+ if (n >= 2 && n > bestCount) { best = s; bestCount = n; }
113
+ }
114
+ if (best) return best;
115
+ const tag = el.tagName.toLowerCase();
116
+ return tag;
117
+ };
118
+
119
+ // A selector for `el` scoped *within* `container` (for records: `.title` inside `.product`).
120
+ const relativeSelector = (container, el) => {
121
+ const testId = testIdAttr(el);
122
+ if (testId) {
123
+ const s = '[' + testId[0] + '="' + attrValue(testId[1]) + '"]';
124
+ if (isUnique(s, container)) return s;
125
+ }
126
+ for (const cls of el.classList) {
127
+ const s = '.' + CSS.escape(cls);
128
+ if (isUnique(s, container)) return s;
129
+ }
130
+ const tag = el.tagName.toLowerCase();
131
+ if (isUnique(tag, container)) return tag;
132
+ // Fallback: nth-of-type path from just under the container down to el.
133
+ const parts = [];
134
+ let node = el;
135
+ while (node && node !== container && node.parentElement) {
136
+ let part = node.tagName.toLowerCase();
137
+ const sameTag = [...node.parentElement.children].filter((c) => c.tagName === node.tagName);
138
+ if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
139
+ parts.unshift(part);
140
+ node = node.parentElement;
141
+ }
142
+ return parts.join(' > ') || tag;
143
+ };
144
+
145
+ window.__aeSel = { CLICKABLE, isUnique, cssPath, visibleText, candidates, descriptorFor,
146
+ groupSelector, relativeSelector };
147
+ })();
148
+ """
@@ -0,0 +1,214 @@
1
+ """Pure transformation of captured events into Blueprint steps, inputs, secrets and outputs.
2
+
3
+ No browser here: this is the testable heart of the recorder. Actions become navigate/click/fill/
4
+ select/press steps; overlay picks become a single coalesced ``extract`` step (single values, lists or
5
+ record tables) plus the Blueprint ``outputs`` map; ``wait_for`` and ``parameterize`` (turn a filled
6
+ value into an ``input``) round out the "beyond clicks" meta-actions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from ._names import ident as _ident
14
+ from ._names import unique as _unique
15
+ from .capture import RecordedEvent
16
+ from .selector_synth import ElementDescriptor, SelectorChoice, synthesize
17
+
18
+ _CREDENTIAL_TOKENS = ("username", "user", "login", "email", "mail", "pass", "identifiant", "pseudo")
19
+ _CREDENTIAL_AUTOCOMPLETE = {"username", "email", "current-password", "new-password"}
20
+ # A navigation right after one of these was triggered by it, not typed by hand.
21
+ _NAV_TRIGGERS = frozenset({"click", "press"})
22
+ _ELEMENT_KINDS = frozenset({"click", "fill", "select", "press"})
23
+ _EXTRACT_KINDS = frozenset({"extract", "extract_records"})
24
+
25
+
26
+ def _selector_fields(choice: SelectorChoice) -> dict[str, str]:
27
+ """Selector fields for a step: ``selector_type`` omitted when it is the ``css`` default."""
28
+ fields = {"selector": choice.selector}
29
+ if choice.selector_type != "css":
30
+ fields["selector_type"] = choice.selector_type
31
+ return fields
32
+
33
+
34
+ def _is_credential(descriptor: ElementDescriptor | None) -> bool:
35
+ """True when a text field looks like a credential worth turning into a secret."""
36
+ if descriptor is None:
37
+ return False
38
+ if (descriptor.field_type or "") == "password":
39
+ return True
40
+ if (descriptor.autocomplete or "").lower() in _CREDENTIAL_AUTOCOMPLETE:
41
+ return True
42
+ haystack = f"{descriptor.name or ''} {descriptor.autocomplete or ''}".lower()
43
+ return any(token in haystack for token in _CREDENTIAL_TOKENS)
44
+
45
+
46
+ def _choice(descriptor: ElementDescriptor | None) -> SelectorChoice:
47
+ return synthesize(descriptor) if descriptor is not None else SelectorChoice("")
48
+
49
+
50
+ class _Recording:
51
+ """Accumulates the Blueprint pieces as captured events are handled, in order."""
52
+
53
+ def __init__(self, *, credentials_as_secrets: bool) -> None:
54
+ self.credentials_as_secrets = credentials_as_secrets
55
+ self.steps: list[dict[str, Any]] = []
56
+ self.secrets: list[str] = []
57
+ self.inputs: dict[str, Any] = {}
58
+ self.outputs: dict[str, Any] = {}
59
+ self._secret_by_selector: dict[str, str] = {}
60
+ self._extract: dict[str, Any] | None = None # current open extract step
61
+ self._extract_id: str = ""
62
+ self._extract_seq = 0
63
+ self._output_names: set[str] = set()
64
+ self._prev_kind: str | None = None
65
+
66
+ def handle(self, event: RecordedEvent) -> None:
67
+ kind = event.kind
68
+ if kind in _EXTRACT_KINDS:
69
+ self._add_extraction(event)
70
+ else:
71
+ self._extract = None # any non-extraction closes the coalesced extract step
72
+ if kind == "navigate":
73
+ self._add_navigate(event)
74
+ elif kind == "wait_for":
75
+ self._add_wait_for(event)
76
+ elif kind == "parameterize":
77
+ self._parameterize(event)
78
+ elif kind in _ELEMENT_KINDS:
79
+ self._add_element(event)
80
+ self._prev_kind = kind
81
+
82
+ # ── actions ──────────────────────────────────────────────────────────────
83
+ def _add_navigate(self, event: RecordedEvent) -> None:
84
+ if self._prev_kind not in _NAV_TRIGGERS and event.url:
85
+ self.steps.append({"action": "navigate", "url": event.url})
86
+
87
+ def _add_wait_for(self, event: RecordedEvent) -> None:
88
+ choice = _choice(event.descriptor)
89
+ config = event.config or {}
90
+ # Presence check: a group class beats a positional path when there is no unique hook.
91
+ if choice.strategy == "css_path" and config.get("group"):
92
+ fields: dict[str, str] = {"selector": str(config["group"])}
93
+ else:
94
+ fields = _selector_fields(choice)
95
+ self.steps.append({"action": "wait_for", **fields})
96
+
97
+ def _add_element(self, event: RecordedEvent) -> None:
98
+ choice = _choice(event.descriptor)
99
+ selector_fields = _selector_fields(choice)
100
+ if event.kind == "click":
101
+ if event.url: # a link click: navigate to its (stable) URL rather than replay the click
102
+ self.steps.append({"action": "navigate", "url": event.url})
103
+ else:
104
+ self.steps.append({"action": "click", **selector_fields})
105
+ elif event.kind == "press":
106
+ self.steps.append({"action": "press", "key": event.key or "Enter", **selector_fields})
107
+ elif event.kind == "select":
108
+ self.steps.append({"action": "select", **selector_fields, "value": event.value or ""})
109
+ elif event.kind == "fill":
110
+ self._add_fill(event, choice, selector_fields)
111
+
112
+ def _add_fill(
113
+ self, event: RecordedEvent, choice: SelectorChoice, selector_fields: dict[str, str]
114
+ ) -> None:
115
+ secret = event.redacted or (
116
+ self.credentials_as_secrets and _is_credential(event.descriptor)
117
+ )
118
+ if secret:
119
+ value = f"{{{{ secrets.{self._secret_for(event, choice)} }}}}"
120
+ else:
121
+ value = event.value or ""
122
+ step = {"action": "fill", **selector_fields, "value": value}
123
+ last = self.steps[-1] if self.steps else None
124
+ if (
125
+ last is not None
126
+ and last.get("action") == "fill"
127
+ and last.get("selector") == choice.selector
128
+ ):
129
+ self.steps[-1] = step # coalesce successive edits of the same field
130
+ else:
131
+ self.steps.append(step)
132
+
133
+ def _secret_for(self, event: RecordedEvent, choice: SelectorChoice) -> str:
134
+ name = self._secret_by_selector.get(choice.selector)
135
+ if name is None:
136
+ base = _ident(event.descriptor.name if event.descriptor else None, fallback="secret")
137
+ name = _unique(base, set(self.secrets))
138
+ self._secret_by_selector[choice.selector] = name
139
+ self.secrets.append(name)
140
+ return name
141
+
142
+ def _parameterize(self, event: RecordedEvent) -> None:
143
+ """Rewrite the most recent literal fill at this selector into an ``{{ inputs.x }}``."""
144
+ selector = _choice(event.descriptor).selector
145
+ for step in reversed(self.steps):
146
+ if step.get("action") == "fill" and step.get("selector") == selector:
147
+ if str(step.get("value", "")).startswith("{{ secrets."):
148
+ return # a secret is not an input; leave it alone
149
+ name = _unique(
150
+ _ident((event.config or {}).get("name"), fallback="value"), set(self.inputs)
151
+ )
152
+ step["value"] = f"{{{{ inputs.{name} }}}}"
153
+ self.inputs[name] = {"type": "string", "required": True}
154
+ return
155
+
156
+ # ── extraction ───────────────────────────────────────────────────────────
157
+ def _add_extraction(self, event: RecordedEvent) -> None:
158
+ if self._extract is None:
159
+ self._extract_seq += 1
160
+ self._extract_id = "data" if self._extract_seq == 1 else f"data_{self._extract_seq}"
161
+ self._extract = {"action": "extract", "id": self._extract_id, "outputs": {}}
162
+ self.steps.append(self._extract)
163
+ config = event.config or {}
164
+ name = _unique(_ident(config.get("name"), fallback="value"), self._output_names)
165
+ self._output_names.add(name)
166
+ self._extract["outputs"][name] = self._spec(event, config)
167
+ self.outputs[name] = f"{{{{ steps.{self._extract_id}.{name} }}}}"
168
+
169
+ def _spec(self, event: RecordedEvent, config: dict[str, Any]) -> dict[str, Any]:
170
+ if event.kind == "extract_records":
171
+ return self._records_spec(event, config)
172
+ as_ = str(config.get("as", "text"))
173
+ # list/count read *all* matches, so use the sibling-group selector; single values use the
174
+ # unique selector our policy synthesizes from the descriptor.
175
+ if as_ in ("list", "count") and config.get("group"):
176
+ spec: dict[str, Any] = {"selector": str(config["group"])}
177
+ else:
178
+ spec = dict(_selector_fields(_choice(event.descriptor)))
179
+ spec["as"] = as_
180
+ if as_ == "list":
181
+ item = str(config.get("item") or "text")
182
+ if item != "text":
183
+ spec["item"] = item
184
+ if item == "attr" and config.get("attr"):
185
+ spec["attr"] = str(config["attr"])
186
+ elif as_ == "attr" and config.get("attr"):
187
+ spec["attr"] = str(config["attr"])
188
+ return spec
189
+
190
+ def _records_spec(self, event: RecordedEvent, config: dict[str, Any]) -> dict[str, Any]:
191
+ fields: dict[str, Any] = {}
192
+ taken: set[str] = set()
193
+ for raw in config.get("fields", []):
194
+ fname = _unique(_ident(raw.get("name"), fallback="field"), taken)
195
+ taken.add(fname)
196
+ field_spec = {
197
+ "selector": str(raw.get("selector", "")),
198
+ "as": str(raw.get("as", "text")),
199
+ }
200
+ if raw.get("as") == "attr" and raw.get("attr"):
201
+ field_spec["attr"] = str(raw["attr"])
202
+ fields[fname] = field_spec
203
+ each = str(config.get("each") or _choice(event.descriptor).selector)
204
+ return {"each": each, "fields": fields}
205
+
206
+
207
+ def events_to_steps(
208
+ events: list[RecordedEvent], *, credentials_as_secrets: bool = True
209
+ ) -> tuple[list[dict[str, Any]], list[str], dict[str, Any], dict[str, Any]]:
210
+ """Transform captured events into ``(steps, secrets, inputs, outputs)``."""
211
+ recording = _Recording(credentials_as_secrets=credentials_as_secrets)
212
+ for event in events:
213
+ recording.handle(event)
214
+ return recording.steps, recording.secrets, recording.inputs, recording.outputs