@capagents/forgeqe 2.2.1 → 2.2.3
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.
- package/HOW_TO_USE.md +235 -51
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/forgeqe.js +1145 -951
- package/dist/template.html +36 -36
- package/docs/ARCHITECTURE.md +6 -2
- package/docs/CONTAINERS.md +80 -4
- package/docs/user-guide.html +1509 -143
- package/forgeqe.example.yaml +3 -1
- package/llms.example.json +2 -4
- package/package.json +5 -2
- package/prompts/README.md +1 -0
- package/prompts/brain.md +3 -1
- package/prompts/test-plan.md +61 -0
- package/py/forgeqe_engine/browser.py +106 -1
- package/py/forgeqe_engine/cli.py +57 -0
- package/py/forgeqe_engine/copilot_gateway.py +369 -0
- package/py/forgeqe_engine/dynamic.py +149 -0
- package/py/forgeqe_engine/recordsession.py +908 -0
- package/py/forgeqe_engine/service.py +29 -0
- package/py/forgeqe_engine/webrecord.py +589 -0
- package/py/forgeqe_engine/webrecord_page.py +530 -0
- package/reporters/playwright.mjs +83 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What the web recorder puts in the page: RECORDER_JS, run in every page in an isolated world (its own globals, the
|
|
3
|
+
page's DOM), so the page's own scripts can neither see nor call the binding it reports through.
|
|
4
|
+
|
|
5
|
+
- It reports only trusted events (a real person's input, not a script's dispatchEvent). A password field's text is
|
|
6
|
+
never read.
|
|
7
|
+
- The Recorder panel is docked in the page itself, in the same browser window: a small panel in a corner (drag it
|
|
8
|
+
elsewhere, or make it a pill), in the top layer above the page's dialogs, in a closed shadow root. Its own clicks and
|
|
9
|
+
keys are handled here and stopped before the page's listeners, and are never recorded.
|
|
10
|
+
- Everything the panel shows is set as text (textContent), never as HTML. It loads nothing from the network.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
# The leader key, then one letter: what each letter does, as the key bar and the panel's hint show it.
|
|
18
|
+
COMMAND_KEYS = (
|
|
19
|
+
("a", "assert", "check an element"),
|
|
20
|
+
("v", "variable", "keep a value"),
|
|
21
|
+
("w", "wait", "wait for an element"),
|
|
22
|
+
("d", "data", "last typed value is test data"),
|
|
23
|
+
("n", "note", "add a note"),
|
|
24
|
+
("p", "pause", "pause / resume"),
|
|
25
|
+
("z", "undo", "undo last step"),
|
|
26
|
+
("s", "stop", "stop recording"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
RECORDER_JS = r"""(() => {
|
|
30
|
+
if (window.top !== window) return;
|
|
31
|
+
if (globalThis.__fqeRec) return;
|
|
32
|
+
const CFG = __CONFIG__;
|
|
33
|
+
const send = (ev) => { try { globalThis[CFG.binding](JSON.stringify(ev)); } catch (e) {} };
|
|
34
|
+
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
// Elements reported to ForgeQE, by number, so it can find each on the page through the DevTools protocol.
|
|
36
|
+
const els = [];
|
|
37
|
+
let dropped = 0;
|
|
38
|
+
const ref = (el) => { els.push(el); if (els.length > 400) { els.splice(0, 100); dropped += 100; } return dropped + els.length - 1; };
|
|
39
|
+
const elAt = (i) => els[i - dropped] || null;
|
|
40
|
+
|
|
41
|
+
// ---- the element a user acts on, and its facts --------------------------------------------------------------
|
|
42
|
+
const ACT_TAGS = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'OPTION', 'LABEL']);
|
|
43
|
+
const ACT_ROLES = new Set(['button', 'link', 'checkbox', 'radio', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'combobox', 'textbox', 'searchbox', 'treeitem', 'slider', 'spinbutton']);
|
|
44
|
+
const actionable = (e) => {
|
|
45
|
+
if (!e || e.nodeType !== 1) return false;
|
|
46
|
+
if (e.tagName === 'A') return e.hasAttribute('href') || ACT_ROLES.has(e.getAttribute('role'));
|
|
47
|
+
return ACT_TAGS.has(e.tagName) || ACT_ROLES.has(e.getAttribute('role')) || e.isContentEditable && e === editableRoot(e);
|
|
48
|
+
};
|
|
49
|
+
const editableRoot = (e) => { let r = e; while (r.parentElement && r.parentElement.isContentEditable) r = r.parentElement; return r; };
|
|
50
|
+
const control = (node) => {
|
|
51
|
+
let e = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
52
|
+
for (let i = 0, c = e; c && i <= 5; i++, c = c.parentElement) if (actionable(c)) return c;
|
|
53
|
+
return e;
|
|
54
|
+
};
|
|
55
|
+
const TEXT_TYPES = new Set(['', 'text', 'search', 'email', 'tel', 'url', 'number', 'password', 'date', 'time', 'datetime-local', 'month', 'week']);
|
|
56
|
+
const isField = (e) => !!e && ((e.tagName === 'INPUT' && TEXT_TYPES.has((e.getAttribute('type') || '').toLowerCase())) || e.tagName === 'TEXTAREA' || e.isContentEditable);
|
|
57
|
+
const isPassword = (e) => !!e && e.tagName === 'INPUT' && (e.getAttribute('type') || '').toLowerCase() === 'password';
|
|
58
|
+
const autocomplete = (e) => !!e && (e.getAttribute('role') === 'combobox' || e.hasAttribute('aria-autocomplete') || e.hasAttribute('aria-expanded') || e.hasAttribute('list'));
|
|
59
|
+
const implicitRole = (e) => {
|
|
60
|
+
const t = e.tagName, type = (e.getAttribute('type') || '').toLowerCase();
|
|
61
|
+
if (t === 'A') return e.hasAttribute('href') ? 'link' : '';
|
|
62
|
+
if (t === 'BUTTON' || t === 'SUMMARY') return 'button';
|
|
63
|
+
if (t === 'SELECT') return e.multiple || e.size > 1 ? 'listbox' : 'combobox';
|
|
64
|
+
if (t === 'TEXTAREA') return 'textbox';
|
|
65
|
+
if (t === 'OPTION') return 'option';
|
|
66
|
+
if (t === 'IMG') return 'img';
|
|
67
|
+
if (/^H[1-6]$/.test(t)) return 'heading';
|
|
68
|
+
if (t === 'INPUT') {
|
|
69
|
+
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button';
|
|
70
|
+
if (type === 'checkbox' || type === 'radio') return type;
|
|
71
|
+
if (type === 'range') return 'slider';
|
|
72
|
+
if (type === 'number') return 'spinbutton';
|
|
73
|
+
if (type === 'search') return 'searchbox';
|
|
74
|
+
if (['', 'text', 'email', 'tel', 'url'].includes(type)) return e.hasAttribute('list') ? 'combobox' : 'textbox';
|
|
75
|
+
}
|
|
76
|
+
return '';
|
|
77
|
+
};
|
|
78
|
+
const labelText = (e) => {
|
|
79
|
+
const by = e.getAttribute('aria-labelledby');
|
|
80
|
+
if (by) { const t = by.split(/\s+/).map((id) => { const x = document.getElementById(id); return x ? x.textContent : ''; }).join(' '); if (norm(t)) return norm(t); }
|
|
81
|
+
if (e.getAttribute('aria-label')) return norm(e.getAttribute('aria-label'));
|
|
82
|
+
if (e.labels && e.labels.length) return norm([...e.labels].map((l) => l.textContent).join(' '));
|
|
83
|
+
return '';
|
|
84
|
+
};
|
|
85
|
+
const accName = (e) => {
|
|
86
|
+
const l = labelText(e);
|
|
87
|
+
if (l) return l;
|
|
88
|
+
const t = e.tagName;
|
|
89
|
+
if (t === 'INPUT') {
|
|
90
|
+
const type = (e.getAttribute('type') || '').toLowerCase();
|
|
91
|
+
if (['button', 'submit', 'reset'].includes(type)) return norm(e.value || (type === 'submit' ? 'Submit' : ''));
|
|
92
|
+
if (type === 'image') return norm(e.getAttribute('alt'));
|
|
93
|
+
return norm(e.getAttribute('placeholder') || e.getAttribute('title'));
|
|
94
|
+
}
|
|
95
|
+
if (t === 'TEXTAREA' || t === 'SELECT') return norm(e.getAttribute('title'));
|
|
96
|
+
if (t === 'IMG') return norm(e.getAttribute('alt'));
|
|
97
|
+
const role = e.getAttribute('role') || implicitRole(e);
|
|
98
|
+
if (['button', 'link', 'heading', 'tab', 'menuitem', 'option', 'checkbox', 'radio', 'switch', 'treeitem'].includes(role)) return norm(e.innerText || e.textContent).slice(0, 120) || norm(e.getAttribute('title'));
|
|
99
|
+
return norm(e.getAttribute('title'));
|
|
100
|
+
};
|
|
101
|
+
const xpath = (e) => { const parts = []; for (; e && e.nodeType === 1; e = e.parentElement) { let i = 1; for (let s = e.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === e.tagName) i++; parts.unshift(e.tagName.toLowerCase() + '[' + i + ']'); } return parts.join('/'); };
|
|
102
|
+
const facts = (e) => {
|
|
103
|
+
const attrs = {};
|
|
104
|
+
for (const k of CFG.kept) { const v = e.getAttribute(k); if (v) attrs[k] = v; }
|
|
105
|
+
const name = accName(e);
|
|
106
|
+
const classes = [...e.classList].filter((c) => /^[A-Za-z_-][\w-]*$/.test(c)).slice(0, 4);
|
|
107
|
+
return { tag: e.tagName.toLowerCase(), attributes: attrs, classes, xpath: xpath(e), role: e.getAttribute('role') || implicitRole(e), name: name.slice(0, 120), text: name ? '' : norm(e.innerText || e.textContent).slice(0, 120) };
|
|
108
|
+
};
|
|
109
|
+
// How many elements each kind of locator for `e` matches right now, counted at the event: ForgeQE checks locators
|
|
110
|
+
// through the DevTools protocol, but a click that opens another page can take the element away before it looks.
|
|
111
|
+
const q = (v) => '"' + String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
112
|
+
const visible = (x) => x.getClientRects().length > 0;
|
|
113
|
+
const count = (sel) => { try { const all = [...document.querySelectorAll(sel)]; const v = all.filter(visible); return (v.length ? v : all).length; } catch (err) { return -1; } };
|
|
114
|
+
const ROLE_SEL = 'a, button, input, select, textarea, summary, option, img, h1, h2, h3, h4, h5, h6, [role]';
|
|
115
|
+
const matches = (e, f) => {
|
|
116
|
+
const out = { attr: {} };
|
|
117
|
+
for (const [k, v] of Object.entries(f.attributes)) out.attr[k] = count('[' + k + '=' + q(v) + ']');
|
|
118
|
+
if (f.attributes.name) out.name = count(f.tag + '[name=' + q(f.attributes.name) + ']');
|
|
119
|
+
if (f.attributes.href) out.href = count('a[href=' + q(f.attributes.href) + ']');
|
|
120
|
+
if (f.classes.length) out.css = count(f.tag + f.classes.slice(0, 2).map((c) => '.' + c).join(''));
|
|
121
|
+
const t = f.text || f.name;
|
|
122
|
+
if (t) out.text = [...document.querySelectorAll('body *')].filter((x) => visible(x) && norm(x.textContent) === t && ![...x.children].some((c) => norm(c.textContent) === t)).length;
|
|
123
|
+
if (f.role && f.name) {
|
|
124
|
+
const same = [...document.querySelectorAll(ROLE_SEL)].filter((x) => visible(x) && (x.getAttribute('role') || implicitRole(x)) === f.role);
|
|
125
|
+
out.role = same.filter((x) => accName(x) === f.name).length;
|
|
126
|
+
// The names of the others with this role, for a locator by part of the name, counted after the page is gone.
|
|
127
|
+
if (/\d/.test(f.name)) out.role_names = same.slice(0, 400).map((x) => accName(x).slice(0, 160));
|
|
128
|
+
}
|
|
129
|
+
if (f.name) out.label = [...document.querySelectorAll('input, select, textarea, [aria-label]')].filter((x) => labelText(x) === f.name).length;
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
const shown = (e) => {
|
|
133
|
+
if (!e) return '';
|
|
134
|
+
if (e.tagName === 'INPUT' || e.tagName === 'TEXTAREA') return isPassword(e) ? '' : norm(e.value);
|
|
135
|
+
if (e.tagName === 'SELECT') return norm([...e.selectedOptions].map((o) => o.text).join(', '));
|
|
136
|
+
return norm(String(e.innerText || e.textContent || '').split('\n').map(norm).filter(Boolean)[0] || '');
|
|
137
|
+
};
|
|
138
|
+
const describeEl = (e) => { const f = facts(e); return { id: ref(e), el: f, matches: matches(e, f), url: location.href }; };
|
|
139
|
+
|
|
140
|
+
// ---- the Recorder panel, docked in the page, with the pick highlight and the key bar --------------------------------
|
|
141
|
+
// In the top layer (a popover), above the page's own dialogs, as brand.py's overlay is; a closed shadow root, so the
|
|
142
|
+
// page's scripts cannot reach in; left out of what the browser agent reads. Every event in it is handled here, in the
|
|
143
|
+
// isolated world, and stopped before the page's own listeners: the page never sees the Recorder's clicks and keys, so
|
|
144
|
+
// a focus trap or a key shortcut of the page cannot take them.
|
|
145
|
+
const host = document.createElement('forgeqe-recorder');
|
|
146
|
+
host.setAttribute('data-browser-use-exclude', 'true');
|
|
147
|
+
host.setAttribute('popover', 'manual');
|
|
148
|
+
host.style.cssText = 'all:initial !important;position:fixed !important;inset:0 !important;width:100vw !important;height:100vh !important;max-width:none !important;max-height:none !important;margin:0 !important;padding:0 !important;border:0 !important;background:transparent !important;overflow:visible !important;pointer-events:none !important;z-index:2147483647 !important;';
|
|
149
|
+
const root = host.attachShadow({ mode: 'closed' });
|
|
150
|
+
root.innerHTML = '<style>' +
|
|
151
|
+
// ForgeQE's report: its colours, font, 28px buttons with 7px corners, the brand focus ring.
|
|
152
|
+
':host{--text:#1f2a44;--muted:#5f6b80;--surface:#fff;--surface-2:#f7f9fb;--line:#e3e8ee;--brand:#0074bf;--brand-line:#c7e1f3;--brand-soft:#e9f3fa;--pass:#1b883c;--fail:#c62828;--warn:#9a4a07;' +
|
|
153
|
+
'--font:"Ubuntu","Segoe UI",-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif;--mono:"Ubuntu Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
|
|
154
|
+
'*{box-sizing:border-box}' +
|
|
155
|
+
'.panel{position:fixed;right:16px;bottom:16px;width:340px;max-width:calc(100vw - 32px);max-height:min(70vh,560px);display:flex;flex-direction:column;background:var(--surface);color:var(--text);font:13.5px/1.45 var(--font);border:1px solid var(--line);border-radius:10px;box-shadow:0 10px 30px rgba(15,27,61,.22);pointer-events:auto;overflow:hidden}' +
|
|
156
|
+
'.panel[hidden],.pill[hidden]{display:none}' +
|
|
157
|
+
'.head{display:flex;align-items:center;gap:8px;padding:8px 10px 8px 12px;border-bottom:1px solid var(--line);background:var(--surface-2);cursor:move;user-select:none}' +
|
|
158
|
+
'.state{display:inline-flex;align-items:center;gap:6px;font-weight:600;font-size:14px}' +
|
|
159
|
+
'.state i,.pill i{width:9px;height:9px;border-radius:50%;background:var(--fail);flex:none}' +
|
|
160
|
+
'.paused .state i,.pill.paused i{background:var(--warn)}' +
|
|
161
|
+
'.count{flex:1;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
|
|
162
|
+
'button{font:inherit;font-size:13px;height:28px;padding:0 10px;border:1px solid var(--line);border-radius:7px;background:var(--surface);color:var(--text);cursor:pointer}' +
|
|
163
|
+
'button:hover{border-color:var(--brand-line);color:var(--brand)}' +
|
|
164
|
+
'button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--brand);outline-offset:2px}' +
|
|
165
|
+
'button.primary{background:var(--brand);border-color:var(--brand);color:#fff}' +
|
|
166
|
+
'button.primary:hover{color:#fff;filter:brightness(.92)}' +
|
|
167
|
+
'button.stop{color:var(--fail)}' +
|
|
168
|
+
'button.icon{width:28px;padding:0;font-size:16px;line-height:1}' +
|
|
169
|
+
'.tools{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid var(--line)}' +
|
|
170
|
+
'ol{list-style:none;margin:0;padding:4px 0;overflow:auto;flex:1;min-height:60px}' +
|
|
171
|
+
'li{display:grid;grid-template-columns:24px 1fr 24px;gap:6px;padding:4px 8px 4px 12px;align-items:start}' +
|
|
172
|
+
'li:hover{background:var(--brand-soft)}' +
|
|
173
|
+
'li .n{color:var(--muted);text-align:right;font-variant-numeric:tabular-nums;font-size:12px;padding-top:2px}' +
|
|
174
|
+
'li .t{word-break:break-word}' +
|
|
175
|
+
'li.check .t{color:var(--pass)}' +
|
|
176
|
+
'li .sub{display:block;color:var(--muted);font-size:12px}' +
|
|
177
|
+
'li .x{width:24px;height:24px;padding:0;border:none;background:none;color:var(--muted)}' +
|
|
178
|
+
'li select{font:inherit;font-size:12px;height:24px;margin-top:4px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--text)}' +
|
|
179
|
+
'li label{display:flex;align-items:center;gap:6px;margin-top:4px;color:var(--muted);font-size:12px}' +
|
|
180
|
+
// The test case being recorded: its steps as a checklist, the current one marked.
|
|
181
|
+
'.casebox{border-bottom:1px solid var(--line);padding:6px 0 8px;max-height:34%;overflow:auto}' +
|
|
182
|
+
'.casebox[hidden]{display:none}' +
|
|
183
|
+
'.casehead{margin:0;padding:0 12px 4px;font-size:12px;font-weight:600;color:var(--muted)}' +
|
|
184
|
+
'.case{padding:0;min-height:0;flex:none}' +
|
|
185
|
+
'.case li{cursor:pointer;grid-template-columns:24px 1fr auto}' +
|
|
186
|
+
'.case li.current{background:var(--brand-soft);box-shadow:inset 3px 0 0 var(--brand)}' +
|
|
187
|
+
'.case .ok{color:var(--pass);font-size:12px;white-space:nowrap}' +
|
|
188
|
+
'.case .gap{color:var(--warn);font-size:12px;white-space:nowrap}' +
|
|
189
|
+
'.casebox button{margin:4px 12px 0}' +
|
|
190
|
+
'.log{margin:0;padding:6px 12px;border-top:1px solid var(--line);color:var(--muted);font-size:12px;min-height:28px}' +
|
|
191
|
+
'.log.warn{color:var(--warn)}' +
|
|
192
|
+
'.chat{display:flex;gap:6px;padding:8px 12px;border-top:1px solid var(--line)}' +
|
|
193
|
+
'textarea{flex:1;font:inherit;font-size:13px;resize:none;height:44px;border:1px solid var(--line);border-radius:7px;padding:5px 8px;background:var(--surface);color:var(--text)}' +
|
|
194
|
+
'.hint{margin:0;padding:0 12px 8px;color:var(--muted);font-size:12px}' +
|
|
195
|
+
'kbd{font:11px var(--mono);border:1px solid var(--line);border-radius:4px;padding:0 4px;background:var(--surface-2)}' +
|
|
196
|
+
'.pill{position:fixed;right:16px;bottom:16px;display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 12px;border-radius:16px;border:1px solid var(--line);background:var(--surface);color:var(--text);font:13px var(--font);box-shadow:0 6px 18px rgba(15,27,61,.2);pointer-events:auto}' +
|
|
197
|
+
// The pick highlight and the key bar: brand.py's overlay colours and font.
|
|
198
|
+
'.box{position:fixed;border:2px solid #3ec4ff;background:rgba(62,196,255,.12);border-radius:3px;display:none;pointer-events:none}' +
|
|
199
|
+
'.tip{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);max-width:min(640px,calc(100vw - 32px));background:rgba(15,27,61,.96);color:#f4f8ff;font:500 13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;padding:8px 12px;border-radius:8px;box-shadow:0 -8px 24px rgba(0,0,0,.28);display:none;pointer-events:none}' +
|
|
200
|
+
'.tip b{color:#3ec4ff;font-weight:600}' +
|
|
201
|
+
'</style>' +
|
|
202
|
+
'<section class="panel" role="region" aria-label="ForgeQE Recorder">' +
|
|
203
|
+
'<div class="head" data-drag><span class="state" aria-live="polite"><i></i><span class="st">Recording</span></span><span class="count"></span>' +
|
|
204
|
+
'<button class="icon" data-ui="collapse" aria-label="Make the Recorder small" title="Make small">–</button></div>' +
|
|
205
|
+
'<div class="tools" role="group" aria-label="Add to the recording">' +
|
|
206
|
+
'<button data-cmd="assert" title="Pick an element in the page to check">Check</button>' +
|
|
207
|
+
'<button data-cmd="variable" title="Pick an element whose text later steps use">Variable</button>' +
|
|
208
|
+
'<button data-cmd="wait" title="Pick an element the test waits for">Wait for</button>' +
|
|
209
|
+
'<button data-cmd="data" title="The last value you typed becomes test data">Test data</button>' +
|
|
210
|
+
'<button data-cmd="undo">Undo</button><button data-cmd="pause" class="pause">Pause</button><button data-cmd="stop" class="stop">Stop</button></div>' +
|
|
211
|
+
'<div class="casebox" hidden><p class="casehead"></p><ol class="case" aria-label="Test case steps"></ol><button data-cmd="case_next">Next case step</button></div>' +
|
|
212
|
+
'<ol class="steps" aria-label="Recorded steps"></ol>' +
|
|
213
|
+
'<p class="log" aria-live="polite"></p>' +
|
|
214
|
+
'<div class="chat"><textarea data-ui="text" aria-label="Describe a check, or start with note: for a note" placeholder="Describe a check: the total shows "$40.00""></textarea><button class="primary" data-ui="send">Send</button></div>' +
|
|
215
|
+
'<p class="hint"></p></section>' +
|
|
216
|
+
'<button class="pill" data-ui="expand" hidden aria-label="Open the Recorder"><i></i><span></span></button>' +
|
|
217
|
+
'<div class="box"></div><div class="tip"></div>';
|
|
218
|
+
const $ = (s) => root.querySelector(s);
|
|
219
|
+
const panel = $('.panel'), pill = $('.pill'), box = $('.box'), tip = $('.tip'), text = $('textarea');
|
|
220
|
+
const raise = () => { try { if (host.matches(':popover-open')) host.hidePopover(); host.showPopover(); } catch (e) {} };
|
|
221
|
+
// Inside <html>, never in place of it: the script runs before the page's own document element exists.
|
|
222
|
+
const mount = () => { if (!host.isConnected && document.documentElement) { document.documentElement.appendChild(host); raise(); } };
|
|
223
|
+
// A dialog the page opens later goes into the top layer above the Recorder: put the Recorder back on top.
|
|
224
|
+
new MutationObserver(() => { if (document.querySelector('dialog[open], [popover]:popover-open:not(forgeqe-recorder)')) raise(); }).observe(document, { subtree: true, attributes: true, attributeFilter: ['open'] });
|
|
225
|
+
let tipTimer = 0;
|
|
226
|
+
const say = (parts, ms = 2600) => { mount(); tip.textContent = ''; for (const [b, t] of parts) { const s = document.createElement(b ? 'b' : 'span'); s.textContent = t; tip.appendChild(s); } tip.style.display = 'block'; clearTimeout(tipTimer); if (ms) tipTimer = setTimeout(() => { tip.style.display = 'none'; }, ms); };
|
|
227
|
+
const ours = (ev) => ev.composedPath().includes(host);
|
|
228
|
+
const el = (tag, cls, t) => { const e = document.createElement(tag); if (cls) e.className = cls; if (t !== undefined) e.textContent = t; return e; };
|
|
229
|
+
|
|
230
|
+
let paused = false;
|
|
231
|
+
let ui = { collapsed: false, x: null, y: null };
|
|
232
|
+
const place = () => {
|
|
233
|
+
panel.hidden = ui.collapsed; pill.hidden = !ui.collapsed;
|
|
234
|
+
for (const p of [panel, pill]) {
|
|
235
|
+
if (ui.x === null || ui.x === undefined) { p.style.left = p.style.top = p.style.right = p.style.bottom = ''; continue; }
|
|
236
|
+
const w = p.offsetWidth || 340, h = p.offsetHeight || 32;
|
|
237
|
+
p.style.left = Math.max(0, Math.min(innerWidth - w, ui.x)) + 'px';
|
|
238
|
+
p.style.top = Math.max(0, Math.min(innerHeight - h, ui.y)) + 'px';
|
|
239
|
+
p.style.right = p.style.bottom = 'auto';
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const KINDS = ['text', 'visible', 'hidden', 'value'];
|
|
243
|
+
const render = (s) => {
|
|
244
|
+
mount();
|
|
245
|
+
paused = s.state === 'paused';
|
|
246
|
+
panel.classList.toggle('paused', paused); pill.classList.toggle('paused', paused);
|
|
247
|
+
$('.st').textContent = paused ? 'Paused' : s.state === 'stopped' ? 'Stopped' : 'Recording';
|
|
248
|
+
$('.count').textContent = s.summary || '';
|
|
249
|
+
$('.pause').textContent = paused ? 'Resume' : 'Pause';
|
|
250
|
+
pill.querySelector('span').textContent = (paused ? 'Paused' : 'Recording') + (s.summary ? ' · ' + s.summary : '');
|
|
251
|
+
const hint = $('.hint');
|
|
252
|
+
hint.textContent = '';
|
|
253
|
+
hint.append('In the page: ', el('kbd', '', s.leader || ''), ' then ' + CFG.commands.map(([k, , label]) => k.toUpperCase() + ' ' + label).join(', '));
|
|
254
|
+
const box2 = $('.casebox');
|
|
255
|
+
box2.hidden = !s.case;
|
|
256
|
+
if (s.case) {
|
|
257
|
+
$('.casehead').textContent = 'Test case ' + (s.case.id || '') + ': pick the step you are on';
|
|
258
|
+
const cl = $('.case');
|
|
259
|
+
cl.textContent = '';
|
|
260
|
+
for (const c of s.case.steps || []) {
|
|
261
|
+
const li = el('li', c.current ? 'current' : '');
|
|
262
|
+
li.dataset.case = String(c.n);
|
|
263
|
+
li.setAttribute('aria-current', c.current ? 'step' : 'false');
|
|
264
|
+
li.append(el('span', 'n', String(c.n)));
|
|
265
|
+
const t = el('span', 't', c.action);
|
|
266
|
+
if (c.expected) t.append(el('span', 'sub', 'expects: ' + c.expected));
|
|
267
|
+
li.append(t, c.expected ? el('span', c.checked ? 'ok' : 'gap', c.checked ? '\u2713 checked' : 'no check yet') : el('span'));
|
|
268
|
+
cl.append(li);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const list = $('.steps');
|
|
272
|
+
const atEnd = list.scrollTop + list.clientHeight >= list.scrollHeight - 8;
|
|
273
|
+
list.textContent = '';
|
|
274
|
+
for (const row of s.steps || []) {
|
|
275
|
+
const li = el('li', row.op === 'expect' ? 'check' : '');
|
|
276
|
+
li.append(el('span', 'n', String(row.n)));
|
|
277
|
+
const t = el('span', 't', row.text);
|
|
278
|
+
if (row.inferred) t.append(el('span', 'sub', 'added: the menu opens on hover'));
|
|
279
|
+
if (row.note) t.append(el('span', 'sub', 'note: ' + row.note));
|
|
280
|
+
if (row.case_step && s.case) t.append(el('span', 'sub', 'case step ' + row.case_step));
|
|
281
|
+
if (row.op === 'expect' && KINDS.includes(row.kind)) {
|
|
282
|
+
const sel = el('select');
|
|
283
|
+
sel.setAttribute('aria-label', 'Kind of check for step ' + row.n);
|
|
284
|
+
sel.dataset.kind = String(row.n);
|
|
285
|
+
for (const k of KINDS) { const o = el('option', '', k); o.value = k; o.selected = k === row.kind; sel.append(o); }
|
|
286
|
+
const lab = el('label');
|
|
287
|
+
const dyn = el('input'); dyn.type = 'checkbox'; dyn.checked = !!row.dynamic; dyn.dataset.dyn = String(row.n);
|
|
288
|
+
lab.append(dyn, 'live data: check the pattern, not this value');
|
|
289
|
+
t.append(sel, lab);
|
|
290
|
+
}
|
|
291
|
+
if (row.generated) {
|
|
292
|
+
const lab = el('label');
|
|
293
|
+
const dyn = el('input'); dyn.type = 'checkbox'; dyn.checked = true; dyn.dataset.dyn = String(row.n);
|
|
294
|
+
lab.append(dyn, row.generated === 'date' ? 'a date from today on each run (untick: as typed)' : 'new on each run (untick: as typed)');
|
|
295
|
+
t.append(lab);
|
|
296
|
+
}
|
|
297
|
+
li.append(t);
|
|
298
|
+
if (row.n > 1) { const x = el('button', 'x', '✕'); x.dataset.del = String(row.n); x.title = 'Remove step ' + row.n; x.setAttribute('aria-label', 'Remove step ' + row.n); li.append(x); } else li.append(el('span'));
|
|
299
|
+
list.append(li);
|
|
300
|
+
}
|
|
301
|
+
if (atEnd) list.scrollTop = list.scrollHeight;
|
|
302
|
+
if (s.ui) ui = { ...ui, ...s.ui };
|
|
303
|
+
place();
|
|
304
|
+
};
|
|
305
|
+
const log = (t, level) => { const p = $('.log'); p.textContent = t; p.classList.toggle('warn', level === 'warn'); };
|
|
306
|
+
const sendChat = () => { const t = text.value.trim(); if (t) send({ kind: 'command', cmd: 'chat', text: t }); text.value = ''; };
|
|
307
|
+
|
|
308
|
+
let drag = null;
|
|
309
|
+
const PANEL_EVENTS = ['click', 'dblclick', 'auxclick', 'contextmenu', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'pointermove', 'mousemove', 'mouseover', 'mouseout', 'keydown', 'keyup', 'keypress', 'beforeinput', 'input', 'change', 'focusin', 'focusout', 'focus', 'blur', 'paste', 'copy', 'cut', 'wheel', 'dragstart', 'submit'];
|
|
310
|
+
const onPanel = (ev) => {
|
|
311
|
+
if (drag && (ev.type === 'pointermove' || ev.type === 'pointerup')) {
|
|
312
|
+
ev.stopImmediatePropagation();
|
|
313
|
+
if (ev.type === 'pointermove') { ui.x = drag.x + ev.clientX - drag.cx; ui.y = drag.y + ev.clientY - drag.cy; place(); }
|
|
314
|
+
else { drag = null; send({ kind: 'panel_ui', ui }); }
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (!ours(ev)) return;
|
|
318
|
+
ev.stopImmediatePropagation();
|
|
319
|
+
if (!ev.isTrusted) return;
|
|
320
|
+
// A listener outside a closed shadow root sees only its host: the control is the one under the pointer, or, for
|
|
321
|
+
// keys and changes, the one with focus.
|
|
322
|
+
const start = 'clientX' in ev ? root.elementFromPoint(ev.clientX, ev.clientY) : root.activeElement;
|
|
323
|
+
const hit = (attr) => { for (let x = start; x && x !== root; x = x.parentElement || x.parentNode) if (x.nodeType === 1 && x.hasAttribute(attr)) return x; return null; };
|
|
324
|
+
if (ev.type === 'pointerdown' && hit('data-drag') && !hit('data-ui')) {
|
|
325
|
+
const r = (ui.collapsed ? pill : panel).getBoundingClientRect();
|
|
326
|
+
drag = { x: r.left, y: r.top, cx: ev.clientX, cy: ev.clientY };
|
|
327
|
+
ev.preventDefault();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// The key commands work with the focus in the panel too.
|
|
331
|
+
if (ev.type === 'keydown' && leaderKey(ev)) return;
|
|
332
|
+
if (ev.type === 'keydown' && root.activeElement === text && ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); sendChat(); return; }
|
|
333
|
+
if (ev.type === 'change') {
|
|
334
|
+
const k = hit('data-kind'), d = hit('data-dyn');
|
|
335
|
+
if (k) send({ kind: 'command', cmd: 'set_kind', n: Number(k.dataset.kind), kind: k.value });
|
|
336
|
+
if (d) send({ kind: 'command', cmd: 'set_dynamic', n: Number(d.dataset.dyn), dynamic: d.checked });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (ev.type !== 'click') return;
|
|
340
|
+
const cmd = hit('data-cmd'), u = hit('data-ui'), del = hit('data-del'), cs = hit('data-case');
|
|
341
|
+
if (cs) { send({ kind: 'command', cmd: 'case_step', n: Number(cs.dataset.case) }); return; }
|
|
342
|
+
// A button of the panel gives the focus back to the page, so typing and key commands go on there.
|
|
343
|
+
if ((cmd || del) && root.activeElement) root.activeElement.blur();
|
|
344
|
+
if (cmd) { if (dirty) flush(); picks(cmd.dataset.cmd); send({ kind: 'command', cmd: cmd.dataset.cmd }); }
|
|
345
|
+
else if (del) send({ kind: 'command', cmd: 'delete', n: Number(del.dataset.del) });
|
|
346
|
+
else if (u && u.dataset.ui === 'send') sendChat();
|
|
347
|
+
else if (u && (u.dataset.ui === 'collapse' || u.dataset.ui === 'expand')) { ui.collapsed = u.dataset.ui === 'collapse'; place(); send({ kind: 'panel_ui', ui }); }
|
|
348
|
+
};
|
|
349
|
+
// Added first, so the panel's events stop here before any other listener, ForgeQE's own recording ones included.
|
|
350
|
+
for (const type of PANEL_EVENTS) window.addEventListener(type, onPanel, true);
|
|
351
|
+
|
|
352
|
+
// ---- picking an element for a check, a variable, or a wait ---------------------------------------------------
|
|
353
|
+
let picking = null;
|
|
354
|
+
const pickTarget = (ev) => { host.style.setProperty('display', 'none', 'important'); const e = document.elementFromPoint(ev.clientX, ev.clientY); host.style.removeProperty('display'); return e; };
|
|
355
|
+
const highlight = (e) => { if (!e) { box.style.display = 'none'; return; } const r = e.getBoundingClientRect(); Object.assign(box.style, { display: 'block', left: r.left - 2 + 'px', top: r.top - 2 + 'px', width: r.width + 4 + 'px', height: r.height + 4 + 'px' }); };
|
|
356
|
+
// A control, or else the smallest element under the pointer (the text a check is about).
|
|
357
|
+
const pickable = (node) => { const c = control(node); return actionable(c) ? c : node && node.nodeType === 1 ? node : c; };
|
|
358
|
+
const startPick = (purpose) => { picking = { purpose }; mount(); say([[1, 'Pick an element'], [0, purpose === 'assert' ? ' to check' : purpose === 'variable' ? ' whose text to keep' : ' to wait for'], [0, ' · Esc cancels']], 0); };
|
|
359
|
+
const endPick = () => { picking = null; highlight(null); tip.style.display = 'none'; };
|
|
360
|
+
// Check, Variable, and Wait for start picking here at once: the next click is the pick, however soon it comes.
|
|
361
|
+
const picks = (cmd) => { if (['assert', 'variable', 'wait'].includes(cmd)) startPick(cmd); };
|
|
362
|
+
const swallow = (ev) => { if (!picking || !ev.isTrusted || ours(ev)) return false; ev.preventDefault(); ev.stopImmediatePropagation(); return true; };
|
|
363
|
+
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'dblclick', 'contextmenu']) window.addEventListener(type, swallow, true);
|
|
364
|
+
window.addEventListener('mousemove', (ev) => { if (picking) highlight(pickable(pickTarget(ev))); }, true);
|
|
365
|
+
|
|
366
|
+
// ---- hover menus: the trigger the pointer went over before a menu item showed ---------------------------------
|
|
367
|
+
const TRIGGER = 'a, button, [role=button], [role=menuitem], [aria-haspopup], [aria-expanded], summary';
|
|
368
|
+
const MENU = '[role=menu], [role=menubar] [role=menu], [role=listbox], ul ul, .dropdown-menu, .submenu, .sub-menu, [class*="dropdown"], [class*="submenu"], [class*="flyout"], [class*="mega"]';
|
|
369
|
+
const revealed = [];
|
|
370
|
+
let hovered = null;
|
|
371
|
+
window.addEventListener('mouseover', (ev) => {
|
|
372
|
+
if (!ev.isTrusted || picking || ours(ev)) return;
|
|
373
|
+
const t = ev.target.closest && ev.target.closest(TRIGGER);
|
|
374
|
+
if (t && (!hovered || t !== hovered.el)) hovered = { el: t, at: Date.now() };
|
|
375
|
+
}, true);
|
|
376
|
+
new MutationObserver((list) => {
|
|
377
|
+
if (!hovered || Date.now() - hovered.at > 1500) return;
|
|
378
|
+
for (const m of list) {
|
|
379
|
+
const n = m.type === 'childList' ? [...m.addedNodes].find((x) => x.nodeType === 1) : m.target;
|
|
380
|
+
if (n && n.nodeType === 1 && !n.contains(hovered.el) && n !== host) revealed.push({ by: hovered.el, root: n, at: Date.now() });
|
|
381
|
+
}
|
|
382
|
+
if (revealed.length > 50) revealed.splice(0, revealed.length - 50);
|
|
383
|
+
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden', 'aria-hidden', 'aria-expanded', 'open'] });
|
|
384
|
+
const hoverFor = (c) => {
|
|
385
|
+
for (let i = revealed.length - 1; i >= 0; i--) {
|
|
386
|
+
const r = revealed[i];
|
|
387
|
+
if (r.root.contains(c) && r.by !== c && !r.by.contains(c) && r.by.isConnected && Date.now() - r.at < 60000) return r.by;
|
|
388
|
+
}
|
|
389
|
+
for (let e = c.parentElement, i = 0; e && e !== document.body && i < 8; e = e.parentElement, i++) {
|
|
390
|
+
if (!e.matches(MENU)) continue;
|
|
391
|
+
const owner = e.parentElement;
|
|
392
|
+
if (!owner || !owner.matches(':hover')) continue;
|
|
393
|
+
const trig = [...owner.children].find((x) => x !== e && !x.contains(c) && (x.matches(TRIGGER) || (x.querySelector && x.querySelector('a, button'))));
|
|
394
|
+
if (trig) return trig.matches(TRIGGER) ? trig : trig.querySelector('a, button');
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// ---- typing: one fill with the field's final text when the tester moves on -------------------------------------
|
|
400
|
+
let dirty = null;
|
|
401
|
+
const flush = () => {
|
|
402
|
+
const e = dirty;
|
|
403
|
+
dirty = null;
|
|
404
|
+
if (!e) return;
|
|
405
|
+
const pw = isPassword(e);
|
|
406
|
+
send({ kind: 'fill', ...describeEl(e), password: pw, value: pw ? null : (e.isContentEditable ? norm(e.innerText) : e.value) });
|
|
407
|
+
};
|
|
408
|
+
window.addEventListener('input', (ev) => {
|
|
409
|
+
if (!ev.isTrusted || picking || ours(ev) || paused) return;
|
|
410
|
+
const e = ev.target.isContentEditable ? editableRoot(ev.target) : ev.target;
|
|
411
|
+
if (!isField(e)) return;
|
|
412
|
+
if (dirty && dirty !== e) flush();
|
|
413
|
+
dirty = e;
|
|
414
|
+
}, true);
|
|
415
|
+
window.addEventListener('focusout', (ev) => { if (dirty && ev.target === dirty) flush(); }, true);
|
|
416
|
+
window.addEventListener('pagehide', flush, true);
|
|
417
|
+
window.addEventListener('submit', (ev) => { if (ev.isTrusted !== false) flush(); }, true);
|
|
418
|
+
|
|
419
|
+
window.addEventListener('change', (ev) => {
|
|
420
|
+
if (!ev.isTrusted || picking || ours(ev) || paused) return;
|
|
421
|
+
const e = ev.target;
|
|
422
|
+
if (e.tagName === 'SELECT') { flush(); send({ kind: 'select', ...describeEl(e), text: shown(e) }); return; }
|
|
423
|
+
// The files' paths are read by ForgeQE through the DevTools protocol: the page itself only sees their names.
|
|
424
|
+
if (e.tagName === 'INPUT' && (e.getAttribute('type') || '').toLowerCase() === 'file') { send({ kind: 'upload', ...describeEl(e), count: e.files ? e.files.length : 0 }); return; }
|
|
425
|
+
if (dirty === e) flush();
|
|
426
|
+
}, true);
|
|
427
|
+
|
|
428
|
+
let lastLabel = null;
|
|
429
|
+
window.addEventListener('click', (ev) => {
|
|
430
|
+
if (!ev.isTrusted || ours(ev)) return;
|
|
431
|
+
if (picking) {
|
|
432
|
+
ev.preventDefault(); ev.stopImmediatePropagation();
|
|
433
|
+
const e = pickable(pickTarget(ev));
|
|
434
|
+
const purpose = picking.purpose;
|
|
435
|
+
endPick();
|
|
436
|
+
if (!e) return;
|
|
437
|
+
send({ kind: 'pick', purpose, ...describeEl(e), text: shown(e), password: isPassword(e), checked: 'checked' in e ? !!e.checked : null, field: e.tagName === 'INPUT' || e.tagName === 'TEXTAREA' || e.tagName === 'SELECT' });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (paused) return;
|
|
441
|
+
const c = control(ev.target);
|
|
442
|
+
if (!c || c.tagName === 'OPTION' || c.tagName === 'SELECT') return;
|
|
443
|
+
// A click on a label also clicks its control: one step.
|
|
444
|
+
if (lastLabel && lastLabel.control === c && Date.now() - lastLabel.at < 100) return;
|
|
445
|
+
if (c.tagName === 'LABEL') lastLabel = { control: c.control, at: Date.now() };
|
|
446
|
+
if (dirty && dirty !== c) flush();
|
|
447
|
+
const h = hoverFor(c);
|
|
448
|
+
send({ kind: 'click', ...describeEl(c), field: isField(c), ...(h ? { hover: describeEl(h) } : {}) });
|
|
449
|
+
}, true);
|
|
450
|
+
window.addEventListener('dblclick', (ev) => {
|
|
451
|
+
if (!ev.isTrusted || picking || ours(ev) || paused) return;
|
|
452
|
+
const c = control(ev.target);
|
|
453
|
+
if (c && !isField(c)) send({ kind: 'dblclick', ...describeEl(c) });
|
|
454
|
+
}, true);
|
|
455
|
+
window.addEventListener('dragstart', (ev) => { if (ev.isTrusted && !paused && !picking && !ours(ev)) send({ kind: 'unsupported', action: 'drag', reason: 'drag and drop is not recorded yet; add it in the test' }); }, true);
|
|
456
|
+
|
|
457
|
+
// ---- keys: the leader key and its letter, then Enter, Escape, and chords ----------------------------------------
|
|
458
|
+
let leader = 0;
|
|
459
|
+
const L = CFG.leader;
|
|
460
|
+
const isLeader = (ev) => ev.code === L.code && !!ev.ctrlKey === !!L.ctrl && !!ev.shiftKey === !!L.shift && !!ev.altKey === !!L.alt && !!ev.metaKey === !!L.meta;
|
|
461
|
+
// The leader key, or the letter after it: handled (and kept from the page) wherever the focus is.
|
|
462
|
+
const leaderKey = (ev) => {
|
|
463
|
+
if (isLeader(ev)) {
|
|
464
|
+
ev.preventDefault(); ev.stopImmediatePropagation();
|
|
465
|
+
leader = Date.now();
|
|
466
|
+
const parts = [];
|
|
467
|
+
for (const [k, , label] of CFG.commands) parts.push([1, ' ' + k.toUpperCase() + ' '], [0, label + ' ·']);
|
|
468
|
+
say(parts, 3000);
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
if (leader && Date.now() - leader < 3000 && !['Shift', 'Control', 'Alt', 'Meta'].includes(ev.key)) {
|
|
472
|
+
leader = 0;
|
|
473
|
+
ev.preventDefault(); ev.stopImmediatePropagation();
|
|
474
|
+
tip.style.display = 'none';
|
|
475
|
+
const hit = CFG.commands.find(([k]) => k === ev.key.toLowerCase());
|
|
476
|
+
if (hit) { if (dirty) flush(); picks(hit[1]); send({ kind: 'command', cmd: hit[1] }); }
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
};
|
|
481
|
+
window.addEventListener('keydown', (ev) => {
|
|
482
|
+
if (!ev.isTrusted || ours(ev)) return;
|
|
483
|
+
if (leaderKey(ev)) return;
|
|
484
|
+
if (picking) {
|
|
485
|
+
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopImmediatePropagation(); endPick(); send({ kind: 'pick_cancel' }); }
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (paused || ['Shift', 'Control', 'Alt', 'Meta'].includes(ev.key)) return;
|
|
489
|
+
const e = ev.target && ev.target.nodeType === 1 ? (ev.target.isContentEditable ? editableRoot(ev.target) : ev.target) : null;
|
|
490
|
+
const inField = isField(e);
|
|
491
|
+
const mods = [ev.ctrlKey && 'Control', ev.altKey && 'Alt', ev.metaKey && 'Meta', ev.shiftKey && ev.key.length > 1 && 'Shift'].filter(Boolean);
|
|
492
|
+
const key = ev.key === ' ' ? 'Space' : ev.key.length === 1 ? ev.key.toLowerCase() : ev.key;
|
|
493
|
+
if (inField && !mods.length && ['Tab'].includes(key)) return;
|
|
494
|
+
const chord = [...mods, key].join('+');
|
|
495
|
+
if (inField && !mods.length && !['Enter', 'Escape', 'ArrowDown', 'ArrowUp'].includes(key)) return;
|
|
496
|
+
if (inField && mods.length && ['Control', 'Meta'].some((m) => mods.includes(m)) && 'acvxzy'.includes(key)) return;
|
|
497
|
+
if (!inField && !mods.length && !['Enter', 'Escape'].includes(key)) return;
|
|
498
|
+
// Enter on a focused button or link clicks it, and the click is the step.
|
|
499
|
+
if (!inField && !mods.length && key === 'Enter' && actionable(e)) return;
|
|
500
|
+
if (dirty) flush();
|
|
501
|
+
send({ kind: 'press', keys: chord, in_field: inField, autocomplete: autocomplete(e) });
|
|
502
|
+
}, true);
|
|
503
|
+
|
|
504
|
+
globalThis.__fqeRec = {
|
|
505
|
+
el: elAt,
|
|
506
|
+
pick: (purpose) => startPick(purpose),
|
|
507
|
+
cancelPick: () => endPick(),
|
|
508
|
+
flush,
|
|
509
|
+
render,
|
|
510
|
+
log,
|
|
511
|
+
say: (t, ms) => say([[0, String(t)]], ms === undefined ? 2600 : ms),
|
|
512
|
+
focusChat: () => { ui.collapsed = false; place(); text.focus(); },
|
|
513
|
+
// How many visible elements a pattern locator finds: by role and an accessible name matching `source`, or by text.
|
|
514
|
+
countPattern: (role, source) => {
|
|
515
|
+
let rx;
|
|
516
|
+
try { rx = new RegExp(source); } catch (e) { return -1; }
|
|
517
|
+
if (role) return [...document.querySelectorAll(ROLE_SEL)].filter((x) => visible(x) && (x.getAttribute('role') || implicitRole(x)) === role && rx.test(accName(x))).length;
|
|
518
|
+
return [...document.querySelectorAll('body *')].filter((x) => visible(x) && !host.contains(x) && rx.test(norm(x.textContent)) && ![...x.children].some((c) => rx.test(norm(c.textContent)))).length;
|
|
519
|
+
},
|
|
520
|
+
// Where a control of the panel is on screen (tests click it as a person would).
|
|
521
|
+
box: (sel) => { const e = root.querySelector(sel); if (!e) return null; const r = e.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; },
|
|
522
|
+
};
|
|
523
|
+
if (document.documentElement) mount(); else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
524
|
+
send({ kind: 'ready', url: location.href });
|
|
525
|
+
})()"""
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def recorder_js(binding: str, kept: Any, *, leader: Dict[str, Any]) -> str:
|
|
529
|
+
config = {"binding": binding, "kept": list(kept), "leader": leader, "commands": [list(c) for c in COMMAND_KEYS]}
|
|
530
|
+
return RECORDER_JS.replace("__CONFIG__", json.dumps(config))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ForgeQE reporter for Playwright. After each run (npx playwright test, the VS Code test runner, CI) it writes the
|
|
2
|
+
// results, with each failure's error, screenshot and trace, to <outputDir>/forgeqe-results.json and runs
|
|
3
|
+
// `forgeqe report` on it, which writes the HTML report. `forgeqe report setup` copies this file into the repository;
|
|
4
|
+
// it has no dependencies. FORGEQE_REPORT=0 turns it off; FORGEQE_BIN names the forgeqe command when it is not on PATH.
|
|
5
|
+
import { spawnSync } from "node:child_process"
|
|
6
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
|
|
9
|
+
const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g
|
|
10
|
+
const clean = (s) => String(s || "").replace(ANSI, "")
|
|
11
|
+
|
|
12
|
+
// cmd.exe joins the arguments of a shell: true spawn unquoted: each is quoted (as winQuote in ForgeQE's src/update.ts).
|
|
13
|
+
const winQuote = (a) => (/[\s&|<>^()%!"]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a)
|
|
14
|
+
|
|
15
|
+
export default class ForgeQEReporter {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.tests = new Map()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
printsToStdio() {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onBegin(config) {
|
|
25
|
+
this.config = config
|
|
26
|
+
this.started = new Date()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onTestEnd(test, result) {
|
|
30
|
+
const seen = this.tests.get(test.id) || { test, results: [] }
|
|
31
|
+
seen.results.push(result)
|
|
32
|
+
this.tests.set(test.id, seen)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onEnd(result) {
|
|
36
|
+
if (process.env.FORGEQE_REPORT === "0" || !this.tests.size) return
|
|
37
|
+
const multi = this.config.projects.length > 1
|
|
38
|
+
const tests = [...this.tests.values()].map(({ test, results }) => entry(test, results, multi))
|
|
39
|
+
const outputDir = this.config.projects[0]?.outputDir || path.join(process.cwd(), "test-results")
|
|
40
|
+
const file = path.join(outputDir, "forgeqe-results.json")
|
|
41
|
+
mkdirSync(outputDir, { recursive: true })
|
|
42
|
+
const doc = { format: "forgeqe-results", version: 1, framework: "Playwright", startedAt: this.started.toISOString(), ms: Math.round(result.duration), command: `playwright ${process.argv.slice(2).join(" ")}`.trim(), tests }
|
|
43
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`)
|
|
44
|
+
const cwd = this.config.configFile ? path.dirname(this.config.configFile) : process.cwd()
|
|
45
|
+
report(file, cwd)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function entry(test, results, multi) {
|
|
50
|
+
const outcome = test.outcome()
|
|
51
|
+
const last = results[results.length - 1]
|
|
52
|
+
const failing = results.find((r) => r.status !== "passed" && r.status !== "skipped")
|
|
53
|
+
const [, project, ...rest] = test.titlePath()
|
|
54
|
+
const name = `${multi && project ? `[${project}] ` : ""}${rest.join(" › ")}`
|
|
55
|
+
const status = outcome === "skipped" ? "skipped" : outcome === "unexpected" ? "failed" : "passed"
|
|
56
|
+
const out = { name, status, seconds: Math.round((last?.duration || 0) / 10) / 100, file: path.relative(process.cwd(), test.location.file) }
|
|
57
|
+
if (outcome === "flaky") out.flaky = true
|
|
58
|
+
if (failing && status !== "skipped") {
|
|
59
|
+
const errors = failing.errors?.length ? failing.errors : failing.error ? [failing.error] : []
|
|
60
|
+
const message = clean(errors[0]?.message).split("\n").find((l) => l.trim())
|
|
61
|
+
if (message) out.message = message.trim().slice(0, 300)
|
|
62
|
+
const details = errors.map((e) => clean(e.stack || e.message)).join("\n\n").trim()
|
|
63
|
+
if (details) out.details = details
|
|
64
|
+
out.attachments = failing.attachments.filter((a) => a.path).map((a) => ({ name: a.name, path: a.path, contentType: a.contentType }))
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function report(file, cwd) {
|
|
70
|
+
const bin = process.env.FORGEQE_BIN || "forgeqe"
|
|
71
|
+
const windows = process.platform === "win32"
|
|
72
|
+
const args = ["report", file]
|
|
73
|
+
const run = spawnSync(windows ? winQuote(bin) : bin, windows ? args.map(winQuote) : args, { cwd, encoding: "utf8", shell: windows, timeout: 180_000 })
|
|
74
|
+
// forgeqe report exits 1 when a test failed: that is a report, not an error. Without a Report line it did not run
|
|
75
|
+
// (on Windows a missing command is cmd.exe's exit 1, not ENOENT).
|
|
76
|
+
const line = run.status !== null && run.status <= 1 ? clean(run.stdout).split("\n").find((l) => l.startsWith("Report")) : undefined
|
|
77
|
+
if (line) {
|
|
78
|
+
console.log(`\nForgeQE ${line.replace(/^Report\s+/, "report: ")}`)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
const why = run.error?.code === "ENOENT" ? "forgeqe is not on PATH" : clean(run.stderr).trim().split("\n").pop() || "it did not finish"
|
|
82
|
+
console.log(`\nForgeQE: no report (${why}). Build it with: forgeqe report ${path.relative(cwd, file)}`)
|
|
83
|
+
}
|