quicke2e 0.2.0
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/LICENSE +21 -0
- package/README.md +560 -0
- package/bin/quicke2e.mjs +118 -0
- package/codegen/codegen.mjs +240 -0
- package/package.json +58 -0
- package/skill/quicke2e/SKILL.md +87 -0
- package/src/discover-pattern.mjs +8 -0
- package/src/discover.mjs +254 -0
- package/src/loop.mjs +748 -0
- package/src/redact.mjs +89 -0
- package/src/route.mjs +98 -0
- package/src/secret.mjs +94 -0
- package/src/seen-text.js +72 -0
- package/src/snapshot.js +246 -0
package/src/discover.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// discover: crawl an app once and write a MAP -- pages, the edges between them, and each page's
|
|
2
|
+
// forms. No model is involved here; this is plain Playwright plus the same in-page snapshot the
|
|
3
|
+
// loop uses, so the map describes exactly what the loop will later be offered.
|
|
4
|
+
//
|
|
5
|
+
// Two modes (docs/DECISIONS.md #5):
|
|
6
|
+
// full -- follows links, clicks buttons/tabs/menus, opens dropdowns, AND submits forms with
|
|
7
|
+
// spec inputs or deterministic defaults. Default on localhost only.
|
|
8
|
+
// safe -- follows links, opens menus/dialogs/dropdowns to read them, never submits a form and
|
|
9
|
+
// never clicks a control whose name looks final or destructive.
|
|
10
|
+
// A non-local host in full mode needs allowRemote (CLI: --i-own-this-data). Logout is never
|
|
11
|
+
// clicked in either mode: it would end the crawl's own session.
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { scrub } from "./secret.mjs";
|
|
14
|
+
import { buildRedactor, redactSnapshot } from "./redact.mjs";
|
|
15
|
+
import { chromium, SNAPSHOT, settle, act, keyFor, norm } from "./loop.mjs";
|
|
16
|
+
|
|
17
|
+
const DESTRUCTIVE = /\b(delete|remove|destroy|refund|cancel|pay|purchase|buy|checkout|place order|confirm|submit|send|archive|deactivate|unsubscribe|revoke|reset|drop|book|save|create|update)\b/i;
|
|
18
|
+
const LOGOUT = /\b(log ?out|sign ?out)\b/i;
|
|
19
|
+
const FIELD_ROLES = new Set(["textbox", "password", "combobox", "checkbox", "radio", "switch", "spinbutton", "searchbox"]);
|
|
20
|
+
const LOCAL = /^(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/;
|
|
21
|
+
|
|
22
|
+
import { pattern } from "./discover-pattern.mjs";
|
|
23
|
+
export { pattern };
|
|
24
|
+
|
|
25
|
+
// Runs AFTER SNAPSHOT, so every candidate already carries its data-jev-node stamp.
|
|
26
|
+
const PAGEINFO = () => {
|
|
27
|
+
const t = (el) => (el?.innerText || "").replace(/\s+/g, " ").trim();
|
|
28
|
+
const headings = [...document.querySelectorAll("h1,h2")].map(t).filter(Boolean).slice(0, 6);
|
|
29
|
+
const links = [...document.querySelectorAll("a[href][data-jev-node]")].map((a) => ({
|
|
30
|
+
node: a.getAttribute("data-jev-node"), href: a.href, text: t(a).slice(0, 60) }));
|
|
31
|
+
const forms = [...document.querySelectorAll("form")].map((f, i) => {
|
|
32
|
+
const nodes = [...f.querySelectorAll("[data-jev-node]")].map((e) => e.getAttribute("data-jev-node"));
|
|
33
|
+
const sub = f.querySelector("button[type=submit],input[type=submit],button:not([type])");
|
|
34
|
+
return { i, name: f.getAttribute("aria-label") || f.getAttribute("name") || t(f.querySelector("h1,h2,h3,legend")) || "",
|
|
35
|
+
nodes, submit: sub?.getAttribute("data-jev-node") || null,
|
|
36
|
+
required: [...f.querySelectorAll("[required],[aria-required=true]")].map((e) => e.getAttribute("data-jev-node")).filter(Boolean) };
|
|
37
|
+
});
|
|
38
|
+
return { headings, links, forms };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Deterministic crawl values. Not a model: a field with no spec value gets a fixed, obviously
|
|
42
|
+
// synthetic value so the form can be submitted and the page behind it discovered.
|
|
43
|
+
function defaultFor(label, role) {
|
|
44
|
+
const l = (label || "").toLowerCase();
|
|
45
|
+
if (role === "password" || /password|passphrase/.test(l)) return "Quicke2e-123!";
|
|
46
|
+
if (/e-?mail/.test(l)) return "quicke2e@example.com";
|
|
47
|
+
if (/phone|tel/.test(l)) return "+15555550100";
|
|
48
|
+
if (/qty|quantity|number|amount|count|tickets?\b|seats?/.test(l)) return "1";
|
|
49
|
+
if (/url|website/.test(l)) return "https://example.com";
|
|
50
|
+
if (/date/.test(l)) return "2030-01-01";
|
|
51
|
+
if (/code|coupon|promo|discount/.test(l)) return "";
|
|
52
|
+
if (/search|filter/.test(l)) return "";
|
|
53
|
+
return "quicke2e";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function snap(page, redact = []) {
|
|
57
|
+
await settle(page);
|
|
58
|
+
let s;
|
|
59
|
+
try { s = await page.evaluate(`(${SNAPSHOT})()`); }
|
|
60
|
+
catch { await page.waitForTimeout(150); s = await page.evaluate(`(${SNAPSHOT})()`); }
|
|
61
|
+
// declared redaction applies to the map on disk too (audit r9)
|
|
62
|
+
if (redact.length) { const r = await buildRedactor(page, redact); redactSnapshot(s, r); s.redactor = r; }
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function discover({ base, start = ["/"], mode = "full", allowRemote = false, reset,
|
|
67
|
+
storageState, inputs = {}, redact = [], maxPages = 40, perPattern = 2, maxProbes = 20, browser: shared,
|
|
68
|
+
log = () => {} } = {}) {
|
|
69
|
+
base = base.replace(/\/$/, "");
|
|
70
|
+
const host = new URL(base).hostname;
|
|
71
|
+
if (mode === "full" && !LOCAL.test(host) && !allowRemote)
|
|
72
|
+
throw new Error(`full crawl on non-local host ${host} refused: it submits forms and changes data. `
|
|
73
|
+
+ `Use mode "safe", or pass --i-own-this-data if this is a throwaway environment.`);
|
|
74
|
+
if (reset) { log(`reset: ${reset}`); execSync(reset, { stdio: "ignore" }); }
|
|
75
|
+
|
|
76
|
+
const t0 = performance.now();
|
|
77
|
+
const browser = shared || await chromium.launch({ headless: true });
|
|
78
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 900 },
|
|
79
|
+
...(storageState ? { storageState } : {}) });
|
|
80
|
+
const page = await context.newPage();
|
|
81
|
+
const origin = new URL(base).origin;
|
|
82
|
+
const pages = new Map(); // pattern -> page record
|
|
83
|
+
const edges = [];
|
|
84
|
+
const edgeKey = new Set();
|
|
85
|
+
const queue = start.map((s) => base + s);
|
|
86
|
+
const seenUrl = new Set();
|
|
87
|
+
const addEdge = (e) => {
|
|
88
|
+
const k = `${e.fromUrl}|${e.toUrl}|${e.via.op}|${e.via.label}`;
|
|
89
|
+
if (edgeKey.has(k)) return; edgeKey.add(k); edges.push(e);
|
|
90
|
+
};
|
|
91
|
+
const enqueue = (u) => {
|
|
92
|
+
let url; try { url = new URL(u); } catch { return; }
|
|
93
|
+
if (url.origin !== origin) return;
|
|
94
|
+
url.hash = "";
|
|
95
|
+
const s = url.toString();
|
|
96
|
+
if (seenUrl.has(s) || queue.includes(s)) return;
|
|
97
|
+
const p = pattern(s);
|
|
98
|
+
const known = pages.get(p);
|
|
99
|
+
if (known && known.urls.length >= perPattern) return;
|
|
100
|
+
queue.push(s);
|
|
101
|
+
};
|
|
102
|
+
const goto = async (u) => { await page.goto(u, { waitUntil: "domcontentloaded" }); return snap(page, redact); };
|
|
103
|
+
|
|
104
|
+
while (queue.length && pages.size < maxPages) {
|
|
105
|
+
const url = queue.shift();
|
|
106
|
+
if (seenUrl.has(url)) continue;
|
|
107
|
+
seenUrl.add(url);
|
|
108
|
+
const p = pattern(url);
|
|
109
|
+
if (pages.get(p)?.urls.length >= perPattern) continue;
|
|
110
|
+
let s;
|
|
111
|
+
try { s = await goto(url); } catch (e) { log(`skip ${url}: ${e.message.slice(0, 80)}`); continue; }
|
|
112
|
+
const landed = page.url();
|
|
113
|
+
if (landed !== url) { // a redirect (auth wall, trailing slash, default tab)
|
|
114
|
+
addEdge({ from: p, fromUrl: url, to: pattern(landed), toUrl: landed, via: { op: "redirect", role: null, label: null } });
|
|
115
|
+
enqueue(landed); continue;
|
|
116
|
+
}
|
|
117
|
+
const info = await page.evaluate(PAGEINFO);
|
|
118
|
+
if (s.redactor) {
|
|
119
|
+
info.headings = info.headings.map(s.redactor.cut);
|
|
120
|
+
for (const l of info.links) { l.text = s.redactor.cut(l.text); l.href = s.redactor.url(l.href); }
|
|
121
|
+
}
|
|
122
|
+
const byNode = new Map();
|
|
123
|
+
for (const a of s.actions) if (!byNode.has(a.node) || a.kind === "fill") byNode.set(a.node, a);
|
|
124
|
+
const rec = pages.get(p) || { pattern: p, urls: [], title: s.title, headings: info.headings,
|
|
125
|
+
forms: [], reveals: [], counts: {} };
|
|
126
|
+
rec.urls.push(url);
|
|
127
|
+
pages.set(p, rec);
|
|
128
|
+
const first = rec.urls.length === 1; // forms/probes only on the first instance of a pattern
|
|
129
|
+
log(`page ${p} (${url.replace(origin, "")})`);
|
|
130
|
+
|
|
131
|
+
// 1. links: edges for free, read off href, no click needed
|
|
132
|
+
for (const l of info.links) {
|
|
133
|
+
if (LOGOUT.test(l.text)) continue;
|
|
134
|
+
// SAFE mode: a GET link can still mutate (/items/3/delete). Not followed when its text or URL
|
|
135
|
+
// names a destructive verb. Best effort -- the README says: safe is a guard, not a guarantee.
|
|
136
|
+
if (mode !== "full" && (DESTRUCTIVE.test(l.text) || DESTRUCTIVE.test(decodeURIComponent(new URL(l.href, url).pathname).replace(/[-_/]/g, " ")))) continue;
|
|
137
|
+
const a = byNode.get(l.node);
|
|
138
|
+
if (!/^https?:/.test(l.href) || new URL(l.href).origin !== origin) continue;
|
|
139
|
+
addEdge({ from: p, fromUrl: url, to: pattern(l.href), toUrl: l.href.replace(/#.*$/, ""),
|
|
140
|
+
via: { op: "link", role: "link", label: a?.label || l.text } });
|
|
141
|
+
enqueue(l.href);
|
|
142
|
+
}
|
|
143
|
+
if (!first) continue;
|
|
144
|
+
|
|
145
|
+
// 2. forms: fields, options, the submit control
|
|
146
|
+
const inForm = new Set();
|
|
147
|
+
for (const f of info.forms) {
|
|
148
|
+
const fields = [];
|
|
149
|
+
for (const n of f.nodes) {
|
|
150
|
+
inForm.add(n);
|
|
151
|
+
const a = byNode.get(n); if (!a) continue;
|
|
152
|
+
if (n === f.submit) continue;
|
|
153
|
+
if (!FIELD_ROLES.has(a.role)) continue;
|
|
154
|
+
const opts = s.actions.filter((x) => x.node === n && x.kind === "select").map((x) => x.label);
|
|
155
|
+
fields.push({ role: a.role, label: a.label, node: n, required: f.required.includes(n),
|
|
156
|
+
...(opts.length ? { options: opts.slice(0, 20) } : {}) });
|
|
157
|
+
}
|
|
158
|
+
const sub = f.submit && byNode.get(f.submit);
|
|
159
|
+
rec.forms.push({ name: f.name, fields, submit: sub ? { role: sub.role, label: sub.label, node: f.submit } : null });
|
|
160
|
+
}
|
|
161
|
+
rec.counts = { links: info.links.length, controls: byNode.size, forms: info.forms.length };
|
|
162
|
+
|
|
163
|
+
// 3. comboboxes (inside forms too): open once, read the options, close
|
|
164
|
+
for (const f of rec.forms) for (const fld of f.fields) {
|
|
165
|
+
if (fld.role !== "combobox" || fld.options) continue;
|
|
166
|
+
try {
|
|
167
|
+
await act(page, { node: fld.node, role: "combobox", label: fld.label }, (loc, t) => loc.click({ timeout: t }));
|
|
168
|
+
const after = await snap(page, redact);
|
|
169
|
+
const opts = after.actions.filter((x) => x.popup && (x.role === "option" || x.role === "menuitem")).map((x) => x.label);
|
|
170
|
+
if (opts.length) fld.options = [...new Set(opts)].slice(0, 20);
|
|
171
|
+
await page.keyboard.press("Escape");
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 4. probe non-link, non-form clickables: does it navigate, open something, or nothing?
|
|
176
|
+
// SAFE mode is STRUCTURAL, not a word list (audit C1: 7/7 mutating controls fired, the name regex
|
|
177
|
+
// is English-only): it probes only controls that declare they open something -- aria-haspopup,
|
|
178
|
+
// aria-expanded, a tab, a combobox. Never a plain button, a switch, or a submit.
|
|
179
|
+
const opener = (a) => a.haspopup || a.expanded !== undefined || a.role === "tab" || a.role === "combobox";
|
|
180
|
+
const probes = [...byNode.values()].filter((a) => a.kind === "click" && !a.popup && a.role !== "link"
|
|
181
|
+
&& !inForm.has(a.node) && ["button", "tab", "menuitem", "combobox", "switch"].includes(a.role)
|
|
182
|
+
&& !LOGOUT.test(a.label) && (mode === "full" || (opener(a) && a.role !== "switch" && !a.submit)))
|
|
183
|
+
.slice(0, maxProbes);
|
|
184
|
+
for (const a of probes) {
|
|
185
|
+
try {
|
|
186
|
+
if (page.url() !== url) await goto(url);
|
|
187
|
+
const before = new Set((await snap(page, redact)).actions.map((x) => x.label));
|
|
188
|
+
await act(page, a, (loc, t) => loc.click({ timeout: t }));
|
|
189
|
+
await page.waitForURL((u) => u.toString() !== url, { timeout: 800 }).catch(() => {});
|
|
190
|
+
if (page.url() !== url) {
|
|
191
|
+
addEdge({ from: p, fromUrl: url, to: pattern(page.url()), toUrl: page.url(),
|
|
192
|
+
via: { op: "click", role: a.role, label: a.label } });
|
|
193
|
+
enqueue(page.url());
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const after = await snap(page, redact);
|
|
197
|
+
const fresh = [...new Set(after.actions.map((x) => x.label))].filter((l) => !before.has(l));
|
|
198
|
+
if (fresh.length) rec.reveals.push({ via: { role: a.role, label: a.label }, items: fresh.slice(0, 15) });
|
|
199
|
+
await page.keyboard.press("Escape");
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 5. full mode: submit each form once to find the page behind it
|
|
204
|
+
if (mode !== "full") continue;
|
|
205
|
+
for (const f of rec.forms) {
|
|
206
|
+
if (!f.submit || LOGOUT.test(f.submit.label)) continue;
|
|
207
|
+
try {
|
|
208
|
+
await goto(url);
|
|
209
|
+
for (const fld of f.fields) {
|
|
210
|
+
const act1 = { node: fld.node, role: fld.role, label: fld.label };
|
|
211
|
+
if (fld.role === "textbox" || fld.role === "password") {
|
|
212
|
+
const k = keyFor(fld.label, inputs, fld.role);
|
|
213
|
+
const v = k !== null ? inputs[k] : defaultFor(fld.label, fld.role);
|
|
214
|
+
if (v) await act(page, act1, (loc, t) => loc.fill(String(v), { timeout: t }));
|
|
215
|
+
} else if (fld.role === "checkbox" && fld.required) {
|
|
216
|
+
await act(page, act1, (loc, t) => loc.check({ timeout: t }));
|
|
217
|
+
} else if (fld.role === "combobox" && fld.options?.length) {
|
|
218
|
+
const want = fld.options.find((o) => !/^(select|choose|pick)\b/i.test(o)) || fld.options[0];
|
|
219
|
+
const native = s.actions.some((x) => x.node === fld.node && x.kind === "select");
|
|
220
|
+
if (native) await act(page, act1, (loc, t) => loc.selectOption({ label: want }, { timeout: t }));
|
|
221
|
+
else {
|
|
222
|
+
await act(page, act1, (loc, t) => loc.click({ timeout: t }));
|
|
223
|
+
await page.getByRole("option", { name: want, exact: true }).first().click({ timeout: 1500 });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const beforeText = norm(await page.evaluate(() => document.body.innerText));
|
|
228
|
+
await act(page, f.submit, (loc, t) => loc.click({ timeout: t }));
|
|
229
|
+
await page.waitForURL((u) => u.toString() !== url, { timeout: 3000 }).catch(() => {});
|
|
230
|
+
await settle(page);
|
|
231
|
+
if (page.url() !== url) {
|
|
232
|
+
addEdge({ from: p, fromUrl: url, to: pattern(page.url()), toUrl: page.url(),
|
|
233
|
+
via: { op: "submit", role: f.submit.role, label: f.submit.label, form: f.name } });
|
|
234
|
+
f.result = { navigates: pattern(page.url()) };
|
|
235
|
+
enqueue(page.url());
|
|
236
|
+
} else {
|
|
237
|
+
const text = norm(await page.evaluate(() => document.body.innerText));
|
|
238
|
+
f.result = { stays: true, message: text.replace(beforeText, "").slice(0, 160) || null };
|
|
239
|
+
}
|
|
240
|
+
} catch (e) { f.result = { error: String(e.message || e).slice(0, 120) }; }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const r of pages.values()) for (const f of r.forms) {
|
|
244
|
+
for (const fld of f.fields) delete fld.node;
|
|
245
|
+
if (f.submit) delete f.submit.node;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await context.close();
|
|
249
|
+
if (!shared) await browser.close();
|
|
250
|
+
// SECURITY (audit): the map on disk is scrubbed of secret spec values (a GET form's toUrl).
|
|
251
|
+
return scrub({ version: 1, base, mode, createdAt: new Date().toISOString(),
|
|
252
|
+
ms: Math.round(performance.now() - t0), pages: [...pages.values()], edges,
|
|
253
|
+
unvisited: queue.slice(0, 50) }, inputs);
|
|
254
|
+
}
|