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.
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // quicke2e CLI.
3
+ // quicke2e discover <baseUrl> [--start /,/admin] [--safe] [--i-own-this-data] [--reset "<cmd>"]
4
+ // [--inputs inputs.json] [--storage state.json] [-o quicke2e.map.json]
5
+ // [--redact ".css-selector" --redact "/regex/i" ...] [--headed|--headless]
6
+ // quicke2e run <spec.mjs> [--base url] [--engine jev|local|vercel] [--map quicke2e.map.json]
7
+ // [--runs N] [--emit dir] [--trace dir] [--video dir] [--headed|--headless] [--allow-weak] [--only name]
8
+ // quicke2e check <spec.mjs> [--base url]
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { chromium, runOnce, SNAPSHOT, settle, checkGoal } from "../src/loop.mjs";
13
+ import { discover } from "../src/discover.mjs";
14
+ import { parseRedactArg } from "../src/redact.mjs";
15
+
16
+ const argv = process.argv.slice(2);
17
+ const cmd = argv.shift();
18
+ const opt = (name, dflt) => { const i = argv.indexOf(name); return i < 0 ? dflt : argv[i + 1]; };
19
+ const flag = (name) => argv.includes(name);
20
+ const FLAGS = new Set(["--safe", "--i-own-this-data", "--headed", "--headless", "--allow-weak", "--json"]);
21
+ const positional = argv.filter((a, i) => !a.startsWith("-") && !(i > 0 && argv[i - 1].startsWith("-") && !FLAGS.has(argv[i - 1])));
22
+ const out = (s = "") => process.stdout.write(s + "\n");
23
+ // Headed when a person runs it (interactive terminal, a display available); headless in CI, with no
24
+ // display, or when the output is piped. --headed / --headless force either.
25
+ const headless = flag("--headless") ? true : flag("--headed") ? false
26
+ : Boolean(process.env.CI) || !process.stdout.isTTY
27
+ || (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY);
28
+
29
+ async function loadFlows(file) {
30
+ const m = await import(pathToFileURL(path.resolve(file)).href);
31
+ const v = m.default ?? m.flows ?? m.FLOWS;
32
+ const flows = Array.isArray(v) ? v : [v];
33
+ const only = opt("--only");
34
+ return only ? flows.filter((f) => f.name === only) : flows;
35
+ }
36
+
37
+ // A spec whose assertion already holds on the start page proves nothing (README finding #4).
38
+ async function weak(flow, base, browser) {
39
+ const ctx = await browser.newContext({ ...(flow.storageState ? { storageState: flow.storageState } : {}) });
40
+ if (flow.expectSeen?.length) await ctx.addInitScript("window.__jevSeen = [];");
41
+ const page = await ctx.newPage();
42
+ try {
43
+ await page.goto(base.replace(/\/$/, "") + (flow.start || "/"), { waitUntil: "domcontentloaded" });
44
+ await settle(page);
45
+ const snap = await page.evaluate(`(${SNAPSHOT})()`);
46
+ return await checkGoal(page, flow, snap);
47
+ } finally { await ctx.close(); }
48
+ }
49
+
50
+ const usage = () => { out(fs.readFileSync(new URL(import.meta.url), "utf8").split("\n").slice(1, 7).map((l) => l.replace(/^\/\/ ?/, "")).join("\n")); process.exit(2); };
51
+
52
+ if (cmd === "discover") {
53
+ const base = positional[0]; if (!base) usage();
54
+ const inputs = opt("--inputs") ? JSON.parse(fs.readFileSync(opt("--inputs"), "utf8")) : {};
55
+ const dbrowser = await chromium.launch({ headless });
56
+ const map = await discover({ base, browser: dbrowser, start: (opt("--start", "/")).split(","), mode: flag("--safe") ? "safe" : "full",
57
+ allowRemote: flag("--i-own-this-data"), reset: opt("--reset"), storageState: opt("--storage"), inputs,
58
+ maxPages: Number(opt("--max-pages", 40)), log: (m) => out(" " + m),
59
+ redact: argv.flatMap((a, i) => (a === "--redact" ? [argv[i + 1]] : [])).map(parseRedactArg) });
60
+ await dbrowser.close();
61
+ const file = opt("-o", "quicke2e.map.json");
62
+ fs.writeFileSync(file, JSON.stringify(map, null, 2));
63
+ out(`\n${map.pages.length} pages, ${map.edges.length} edges, ${map.pages.reduce((n, p) => n + p.forms.length, 0)} forms in ${(map.ms / 1000).toFixed(1)}s -> ${file}`);
64
+ process.exit(0);
65
+ }
66
+
67
+ if (cmd === "run" || cmd === "check") {
68
+ const file = positional[0]; if (!file) usage();
69
+ const flows = await loadFlows(file);
70
+ const base = opt("--base", process.env.APP_BASE || "http://localhost:3000");
71
+ const engine = opt("--engine", "jev");
72
+ const runs = Number(opt("--runs", 1));
73
+ const map = opt("--map") ? JSON.parse(fs.readFileSync(opt("--map"), "utf8")) : null;
74
+ const browser = await chromium.launch({ headless });
75
+ let failed = 0;
76
+ const all = [];
77
+ const { weakSecretKeys } = await import("../src/secret.mjs");
78
+ for (const flow of flows) {
79
+ const fbase = flow.base || base;
80
+ const weakKeys = weakSecretKeys(flow.inputs);
81
+ if (weakKeys.length) out(`note: ${flow.name}: ${weakKeys.join(", ")} ${weakKeys.length > 1 ? "are" : "is a"} weak secret${weakKeys.length > 1 ? "s" : ""}`
82
+ + ` (short or a common word). It is kept out of the goal, but the page may echo it to the engine.`);
83
+ // An app that is not running is the most common first failure: say so in one line, not a stack trace.
84
+ let isWeak;
85
+ try { isWeak = await weak(flow, fbase, browser); }
86
+ catch (e) {
87
+ out(`UNREACHABLE ${flow.name}: ${fbase.replace(/\/$/, "")}${flow.start || "/"} (${String(e.message || e).split("\n")[0].replace(/^page\.goto: /, "")}). Is the app running?`);
88
+ failed++; continue;
89
+ }
90
+ if (isWeak) {
91
+ out(`WEAK_ASSERTION ${flow.name}: the assertion is already true on ${flow.start || "/"} before any work.`);
92
+ if (cmd === "check" || !flag("--allow-weak")) { failed++; continue; }
93
+ } else if (cmd === "check") { out(`ok ${flow.name}`); continue; }
94
+ for (let k = 0; k < runs; k++) {
95
+ const trace = opt("--trace") ? path.join(opt("--trace"), `${flow.name}-${Date.now()}.json`) : undefined;
96
+ if (trace) fs.mkdirSync(opt("--trace"), { recursive: true });
97
+ const rec = await runOnce({ flow, engine, browser, base: fbase, trace, ...(map ? { map } : {}),
98
+ ...(opt("--video") ? { video: opt("--video") } : {}) });
99
+ all.push(rec);
100
+ if (!rec.passed) failed++;
101
+ out(`${rec.passed ? "PASS" : "FAIL"} ${flow.name.padEnd(28)} ${String(rec.steps.length).padStart(2)} steps `
102
+ + `${(rec.wallMs / 1000).toFixed(1).padStart(5)}s $${rec.cost.toFixed(5)} ${rec.outcome}`
103
+ + (rec.route?.pattern ? ` via map -> ${rec.route.pattern}` : "") + (rec.error ? ` ${rec.error}` : ""));
104
+ if (rec.passed && opt("--emit")) {
105
+ const { generate } = await import("../codegen/codegen.mjs");
106
+ fs.mkdirSync(opt("--emit"), { recursive: true });
107
+ const f = path.join(opt("--emit"), `${flow.name}.spec.ts`);
108
+ try { fs.writeFileSync(f, generate(rec, flow).code); out(` emitted ${f}`); }
109
+ catch (e) { out(` not emitted: ${e.message}`); }
110
+ }
111
+ }
112
+ }
113
+ await browser.close();
114
+ if (flag("--json")) out(JSON.stringify(all));
115
+ process.exit(failed ? 1 : 0);
116
+ }
117
+
118
+ usage();
@@ -0,0 +1,240 @@
1
+ import { isSecretKey, scrubText } from "../src/secret.mjs";
2
+ // codegen: a PASSED run record -> a deterministic Playwright spec.
3
+ //
4
+ // Why this exists: the decision model runs on MLX, which is Apple Silicon only, so a Linux CI
5
+ // runner cannot load it. Exploring once on a Mac and replaying a plain Playwright spec in CI is
6
+ // the only free path to a merge gate. (README, "A CI merge gate -- not yet".)
7
+ //
8
+ // Three rules, all of them load-bearing:
9
+ // 1. USER-FACING LOCATORS ONLY. getByRole / getByLabel / getByText. Never CSS, never the
10
+ // data-jev-node stamp -- snapshot.js:105 assigns it per snapshot, so it does not exist at
11
+ // replay time and a trace's `target` ordinal is meaningless across runs (snapshot.js:58,64).
12
+ // 2. TEXT COMES FROM THE SPEC. The trace records which inputs KEY matched (loop.mjs specKey),
13
+ // never the value. The spec file reads process.env / INPUTS, so no credential is written
14
+ // into a trace on disk.
15
+ // 3. THE ASSERTION IS THE TEST. expectUrl/expect/expectState map 1:1 onto real expect() calls;
16
+ // nothing is invented and nothing is softened.
17
+
18
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
19
+ const q = (s) => JSON.stringify(String(s));
20
+ // The snapshot's own role vocabulary -> a real ARIA role Playwright's role engine understands.
21
+ // Identical table to act() in loop.mjs:226-228, on purpose: the spec must resolve the same way
22
+ // the exploratory run did, or it is testing something else.
23
+ const ARIA = { textbox: "textbox", password: "textbox", button: "button", link: "link",
24
+ option: "option", combobox: "combobox", tab: "tab", checkbox: "checkbox", switch: "switch",
25
+ radio: "radio", menuitem: "menuitem" };
26
+
27
+ // ---------------------------------------------------------------- step pruning
28
+ // An exploratory trace wanders. Three classes of junk, and only ONE of them is safe to drop
29
+ // on static grounds:
30
+ //
31
+ // DROP (mechanical, always safe):
32
+ // - WAIT : no target, no label. Playwright auto-waits on every locator action,
33
+ // so a WAIT is an artefact of the loop's snapshot cadence, not a step.
34
+ // - stale:true : act() threw on all four strategies, so NOTHING happened to the page.
35
+ // loop.mjs:317,322 pushes the step and `continue`s without acting.
36
+ // - noSpecValue / doneRejected / retried : the same shape -- recorded, not performed.
37
+ // - DONE / BLOCKED : meta choices, never an interaction.
38
+ //
39
+ // KEEP UNLESS REPLAY SAYS OTHERWISE (the dangerous class):
40
+ // - a repeated CLICK on the same {op,label} at the same url. It LOOKS redundant. Yesterday's
41
+ // finding is that such a click was twice the stepping stone the model needed -- e.g. the
42
+ // first click opens a portal and the second lands in it, or a debounced control needs the
43
+ // second event. Static analysis cannot tell those apart from a double-fire.
44
+ // So: prune by REPLAY, not by rule. `prune()` below removes one candidate at a time and
45
+ // re-runs the generated spec; a step whose removal still passes was not load-bearing.
46
+ export function classify(steps) {
47
+ const out = [];
48
+ for (const s of steps) {
49
+ let drop = null;
50
+ if (s.op === "WAIT") drop = "WAIT: playwright auto-waits";
51
+ else if (s.op === "DONE" || s.op === "BLOCKED") drop = `${s.op}: meta choice, no interaction`;
52
+ else if (s.stale) drop = "stale: act() resolved nothing, the page never changed";
53
+ else if (s.noSpecValue) drop = "noSpecValue: run aborted here, nothing typed";
54
+ else if (!s.label) drop = "no label: nothing to build a locator from";
55
+ else if (s.op === "TYPE_TEXT" && !s.inputKey) drop = "TYPE_TEXT with no inputKey: re-run the trace on a loop.mjs that records it";
56
+ out.push({ step: s, drop });
57
+ }
58
+ // A CLICK whose {op,label,url} repeats an EARLIER kept step is only a *candidate* for pruning.
59
+ const seen = new Set();
60
+ for (const o of out) {
61
+ if (o.drop) continue;
62
+ const k = `${o.step.op}|${o.step.label}|${o.step.url}`;
63
+ if (seen.has(k)) o.candidate = "repeat of an earlier identical step -- prune only if replay still passes";
64
+ seen.add(k);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ // ---------------------------------------------------------------- locators
70
+ //
71
+ // `via` records which rung of act() resolved (loop.mjs:229-241), and it is the ONLY free evidence
72
+ // about the emitted locator. Measured across every saved run in `runs/` (2,751 resolved steps):
73
+ //
74
+ // node 2738 (99.5%) role 12 (0.44%) role~ 1 (0.04%) text 0
75
+ //
76
+ // So `via` is decisive where it exists and SILENT on 99.5% of steps: the stamped node wins first,
77
+ // so the role rungs are never exercised and nothing is learned about them. Two consequences:
78
+ // - honour it where it exists. via "role~" means getByRole(..., exact:true) PROVABLY failed at
79
+ // action time, so emitting exact:true there emits a locator known to match nothing.
80
+ // - it cannot be the mechanism. For the 99.5% the emitted locator is unverified, which is why
81
+ // verifyLocators() below re-walks the flow and counts matches per step.
82
+ export const STRATEGY = { exact: "exact", loose: "loose", text: "text" };
83
+ function strategyFor(s) {
84
+ if (s.locatorStrategy) return s.locatorStrategy; // measured by verifyLocators(), wins
85
+ if (s.via === "role~") return STRATEGY.loose; // exact provably failed at action time
86
+ if (s.via === "text") return STRATEGY.text; // both role rungs provably failed
87
+ return STRATEGY.exact; // via "node"/"role": assume, then verify
88
+ }
89
+ export function locator(s, { page = "page", inputs, weakKeys = [] } = {}) {
90
+ const role = ARIA[s.role];
91
+ const name = s.label;
92
+ // SECURITY (audit): a label that echoes a secret spec value is matched by the text before it.
93
+ // A label may already carry a placeholder (a weak-secret echo scrubbed at the source), or carry a
94
+ // strong secret that is scrubbed here: either way, match by the text before it.
95
+ const scrubbed = scrubText(name, inputs, { weakKeys });
96
+ if (role && (scrubbed !== String(name ?? "") || /<[\w ]+>/.test(String(name ?? "")))) {
97
+ const prefix = scrubbed.split(/<[^>]+>/)[0].trim();
98
+ // Never emit an empty name (`/^/` matches every control -- audit FP1). Fail loudly instead.
99
+ if (!prefix) throw new Error(`cannot emit a stable locator for a ${role}: its accessible name is entirely a secret or redacted value`);
100
+ return `${page}.getByRole(${q(role)}, { name: new RegExp(${q("^" + escapeRe(prefix))}) })`;
101
+ }
102
+ const st = strategyFor(s);
103
+ // getByLabel is idiomatic for a form field and matches how snapshot.js:41 found the name
104
+ // (label[for=id]). But accName() also falls back to placeholder, aria-label and the name
105
+ // attribute (snapshot.js:43-51), which getByLabel cannot see -- measured on login.html,
106
+ // getByLabel("Email") returns 2 matches and violates strict mode. getByRole is what act()
107
+ // used, so it is the primary.
108
+ // FIX (v1, TicketBay D4): a price in the accessible name ("Pay €121.54") breaks the replay on
109
+ // any price change. Match the stable prefix instead.
110
+ const priced = /^(.*?\S)\s*(?:[€$£]\s?\d[\d.,]*|\d[\d.,]*\s?[€$£]).*$/.exec(name || "");
111
+ if (role && priced) return `${page}.getByRole(${q(role)}, { name: new RegExp(${q("^" + escapeRe(priced[1]))}) })`;
112
+ if (role && st === STRATEGY.exact) return `${page}.getByRole(${q(role)}, { name: ${q(name)}, exact: true })`;
113
+ // .first() is deliberate here and ONLY here: a loose name is a substring match, so >1 candidate
114
+ // is expected. Measured on the rename pair, .first() can bind the WRONG control (login.html's
115
+ // "Email" loosely matches both the login field and the signup form's "Work email"), so this
116
+ // rung is emitted only when a tighter one was measured to fail.
117
+ if (role && st === STRATEGY.loose) return `${page}.getByRole(${q(role)}, { name: ${q(name)} }).first()`;
118
+ return `${page}.getByText(${q(name)}, { exact: true }).filter({ visible: true }).first()`;
119
+ }
120
+
121
+ function actionLine(s, inputs, weakKeys) {
122
+ const loc = locator(s, { inputs, weakKeys });
123
+ if (s.op === "SELECT") return ` await page.getByRole("combobox", { name: ${q(s.control)}, exact: true }).selectOption({ label: ${q(s.option)} });`;
124
+ // audit: `INPUTS.["discount code"]` is a SyntaxError -- bracket access has no dot.
125
+ if (s.op === "TYPE_TEXT") return ` await ${loc}.fill(${IDENT.test(s.inputKey) ? `INPUTS.${s.inputKey}` : `INPUTS[${q(s.inputKey)}]`});`;
126
+ return ` await ${loc}.click();`;
127
+ }
128
+
129
+ // ---------------------------------------------------------------- assertions
130
+ // Every branch mirrors exactly what the loop checks, so a green spec means the same thing a
131
+ // DONE_VERIFIED meant.
132
+ function assertions(flow, waitMs) {
133
+ const out = [];
134
+ // MEASURED (2026-09-22): the first generated create-campaign spec went 20/21 serially and 4/6
135
+ // under parallel workers, always failing on the terminal toHaveURL. Playwright's default expect
136
+ // timeout is 5,000 ms; the exploratory run measured 3,251 ms between the last click and
137
+ // DONE_VERIFIED on an idle app, so a loaded app overruns the default. The exploratory run is the
138
+ // only honest source for this number -- it is the one thing that watched the app do the work.
139
+ const t = waitMs ? `, { timeout: ${waitMs} }` : "";
140
+ if (flow.expectUrl) // loop.mjs:206 new RegExp(...).test(url)
141
+ out.push(` await expect(page).toHaveURL(new RegExp(${q(flow.expectUrl)})${t});`);
142
+ for (const w of flow.expectSeen || []) // transient text (a toast): poll for it
143
+ out.push(` await expect(page.getByText(${q(w)}).first()).toBeVisible(${t ? `{ timeout: ${waitMs} }` : ""});`);
144
+ for (const w of flow.expect || []) // loop.mjs:208-212 innerText substring,
145
+ out.push(` await expect(page.locator("body")).toContainText(${q(w)}${t});`); // both sides normalised
146
+ for (const w of flow.expectState || []) { // loop.mjs:192-202 stateOk()
147
+ const role = ARIA[w.role] || w.role;
148
+ const loc = `page.getByRole(${q(role)}, { name: ${q(w.name)}, exact: true })`;
149
+ if (w.value != null) {
150
+ // snapshot.js:56-72 selectionOf(): a native <select> reports its selected option's label
151
+ // (readable as the element's value); a <button role=combobox> reports its rendered text.
152
+ // `tag` on the recorded control says which. Default to text, because that is the Radix
153
+ // case and the one the substring assertion got wrong (README 2026-09-22).
154
+ out.push(w.tag === "select" || w.tag === "input"
155
+ ? ` await expect(${loc}).toHaveValue(new RegExp(${q(escapeRe(w.value))}));`
156
+ : ` await expect(${loc}).toContainText(${q(w.value)});`);
157
+ }
158
+ if (w.checked != null) out.push(` await expect(${loc})${w.checked ? "" : ".not"}.toBeChecked();`);
159
+ if (w.selected != null) out.push(` await expect(${loc}).toHaveAttribute("aria-selected", ${q(String(w.selected))});`);
160
+ if (w.expanded != null) out.push(` await expect(${loc}).toHaveAttribute("aria-expanded", ${q(String(w.expanded))});`);
161
+ }
162
+ if (!out.length) out.push(` // NO ASSERTION IN THE FLOW SPEC -- this spec proves nothing. README finding #4.`);
163
+ return out;
164
+ }
165
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+
167
+ // ---------------------------------------------------------------- the writer
168
+ export function generate(rec, flow, { title = flow.name, dropCandidates = [] } = {}) {
169
+ if (!rec.passed) throw new Error(`refusing to generate from a run that did not pass (outcome=${rec.outcome})`);
170
+ const classified = classify(rec.steps);
171
+ const kept = [], dropped = [];
172
+ for (const [i, c] of classified.entries()) {
173
+ if (c.drop) { dropped.push({ n: c.step.n, op: c.step.op, label: c.step.label, why: c.drop }); continue; }
174
+ if (dropCandidates.includes(c.step.n)) { dropped.push({ n: c.step.n, op: c.step.op, label: c.step.label, why: "pruned by replay: removing it still passed" }); continue; }
175
+ kept.push(c);
176
+ }
177
+
178
+ const lines = [];
179
+ lines.push(`// GENERATED from a passed exploratory run -- do not hand-edit; regenerate.`);
180
+ lines.push(`// flow: ${flow.name} engine: ${rec.engine} outcome: ${rec.outcome} ${rec.steps.length} steps in ${rec.wallMs} ms`);
181
+ lines.push(`// Every locator below is an ACCESSIBLE NAME. A rename in the app breaks this spec --`);
182
+ lines.push(`// that is the intended signal, not a defect. See replay-or-heal.mjs.`);
183
+ lines.push(`import { test, expect } from "@playwright/test";`);
184
+ lines.push(``);
185
+ lines.push(`const BASE = process.env.APP_BASE ?? ${q(rec.base)};`);
186
+ if (Object.keys(flow.inputs || {}).some(isSecretKey))
187
+ lines.push(`const missing = (v) => { throw new Error(\`set \${v}: secret spec values are read from the environment only\`); };`);
188
+ lines.push(`const INPUTS = {`);
189
+ for (const [k, v] of Object.entries(flow.inputs || {}))
190
+ // SECURITY (audit): a secret-looking key gets NO literal default -- the spec reads it from the
191
+ // environment only, so a credential is never written into a generated file.
192
+ lines.push(isSecretKey(k)
193
+ ? ` ${IDENT.test(k) ? k : q(k)}: process.env.${envName(flow.name, k)} ?? missing(${q(envName(flow.name, k))}),`
194
+ : ` ${IDENT.test(k) ? k : q(k)}: process.env.${envName(flow.name, k)} ?? ${q(v)},`);
195
+ lines.push(`};`);
196
+ lines.push(``);
197
+ lines.push(`test(${q(title)}, async ({ page }) => {`);
198
+ lines.push(` await page.goto(BASE + ${q(flow.start || "/")});`);
199
+ // MAP (v1): the run was routed to its target page before the step loop. Replay the same walk.
200
+ for (const h of rec.route?.hops || []) {
201
+ if (h.goto) lines.push(` await page.goto(BASE + ${q(new URL(h.goto).pathname + new URL(h.goto).search)});`);
202
+ else if (h.ok) lines.push(` await page.getByRole(${q(h.role)}, { name: ${q(h.label)}, exact: true }).first().click();`);
203
+ }
204
+
205
+ let lastUrl = null;
206
+ for (const c of kept) {
207
+ const s = c.step;
208
+ // A url change between two kept steps is a navigation the exploratory run observed. Asserting
209
+ // it turns a silent wrong-page click into a named failure at the step that caused it.
210
+ if (lastUrl !== null && s.url !== lastUrl)
211
+ lines.push(` await expect(page).toHaveURL(new RegExp(${q(escapeRe(new URL(s.url).pathname))}));`);
212
+ lastUrl = s.url;
213
+ if (c.candidate) lines.push(` // kept: ${c.candidate}`);
214
+ lines.push(actionLine(s, flow.inputs, rec.weakEchoed || []));
215
+ }
216
+ lines.push(``);
217
+ lines.push(` // ---- the assertion. This, not the steps, is the test.`);
218
+ // Size the terminal wait from what the exploratory run MEASURED between its last action and
219
+ // DONE_VERIFIED, x3 for headroom, floored at Playwright's own default so we never shorten it.
220
+ const lastActed = [...rec.steps].reverse().find((s) => s.wallAt != null);
221
+ const leg = lastActed ? Math.max(0, (rec.tLoopEnd ?? rec.wallMs) - lastActed.wallAt) : 0;
222
+ for (const a of assertions(flow, Math.max(5000, Math.round(leg * 3 / 500) * 500))) lines.push(a);
223
+ lines.push(`});`);
224
+ lines.push(``);
225
+ // The sidecar: everything needed to RE-EXPLORE this flow when the spec breaks. The spec is
226
+ // derived; this is the source. Keep them next to each other in the repo.
227
+ // NB `defaultBase`, NOT `base`. loop.mjs:245 resolves `flow.base || base || $APP_BASE`, so a
228
+ // `base` key on the flow object OUTRANKS the explicit argument -- which silently sent the heal
229
+ // re-exploration back to the page the spec was generated from. Measured: the heal loop reported
230
+ // re-explore ok=true and then emitted a byte-identical spec that failed again.
231
+ const sidecar = { name: flow.name, start: flow.start, defaultBase: rec.base, maxSteps: flow.maxSteps,
232
+ inputs: flow.inputs, goal: flow.goal, done: flow.done,
233
+ expectUrl: flow.expectUrl, expect: flow.expect, expectState: flow.expectState,
234
+ generatedFrom: { engine: rec.engine, outcome: rec.outcome, wallMs: rec.wallMs, steps: rec.steps.length },
235
+ locators: kept.map((c) => ({ n: c.step.n, op: c.step.op, role: c.step.role, name: c.step.label })) };
236
+ return { code: lines.join("\n"), sidecar, kept: kept.map((c) => c.step.n), dropped, classified,
237
+ measuredLegMs: leg };
238
+ }
239
+ const envName = (flow, k) =>
240
+ (flow + "_" + k).toUpperCase().replace(/[^A-Z0-9]+/g, "_");
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "quicke2e",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Plain-English end-to-end browser tests: a small decision model picks each click, your spec supplies every typed value, code decides pass/fail, and a passing run exports a Playwright spec.",
6
+ "keywords": [
7
+ "e2e",
8
+ "end-to-end",
9
+ "testing",
10
+ "playwright",
11
+ "browser-testing",
12
+ "plain-english",
13
+ "ai-testing",
14
+ "decision-model",
15
+ "jev",
16
+ "exploratory-testing"
17
+ ],
18
+ "homepage": "https://github.com/dmoka/quicke2e#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/dmoka/quicke2e/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/dmoka/quicke2e.git"
25
+ },
26
+ "license": "MIT",
27
+ "main": "src/loop.mjs",
28
+ "bin": {
29
+ "quicke2e": "bin/quicke2e.mjs"
30
+ },
31
+ "exports": {
32
+ ".": "./src/loop.mjs",
33
+ "./discover": "./src/discover.mjs",
34
+ "./route": "./src/route.mjs",
35
+ "./codegen": "./codegen/codegen.mjs"
36
+ },
37
+ "files": [
38
+ "bin",
39
+ "src",
40
+ "codegen/codegen.mjs",
41
+ "skill",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "scripts": {
49
+ "test": "node --test test/*.test.mjs",
50
+ "fixtures": "node fixtures/run.mjs --checkout ."
51
+ },
52
+ "dependencies": {
53
+ "playwright": "^1.50.0"
54
+ },
55
+ "devDependencies": {
56
+ "@playwright/test": "^1.63.0"
57
+ }
58
+ }
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: quicke2e
3
+ description: Invent and run exploratory browser test cases for a web app with quicke2e. Maps the app, reads its source for business rules, writes goal-based specs with deterministic assertions, runs them with the Jev decision model, and reports findings. Use when the user says "quicke2e", "explore my app", "find UI bugs", "write browser test cases", or wants adversarial end-to-end checks without writing selectors.
4
+ ---
5
+
6
+ # quicke2e — invent the cases, let Jev drive, let code judge
7
+
8
+ Division of labour. Do not blur it:
9
+ - **You (the big model)** invent the cases and write the spec files. Once, offline.
10
+ - **Jev** drives the browser for each spec. Cheap, fast, many times. It only ever picks one of the options the tool offers.
11
+ - **Code** decides pass or fail. Never you, never Jev.
12
+
13
+ ## 1. Map the app
14
+
15
+ Ask for the base URL if you do not have it. Then:
16
+
17
+ ```bash
18
+ npx quicke2e discover http://localhost:3000 --start / -o quicke2e.map.json
19
+ ```
20
+
21
+ - Full crawl is the default on localhost: it submits forms. Use a throwaway database (`--reset "<cmd>"` to restore seed data).
22
+ - Any other host: use `--safe`, unless the user confirms the data is disposable (`--i-own-this-data`).
23
+ - Read the map: pages, edges, forms, fields, options, and what each form submit led to.
24
+
25
+ ## 2. Read the rules in the source
26
+
27
+ The map shows WHERE things are. The source shows WHAT MUST BE TRUE. Read, in this order:
28
+ domain/business logic (pricing, limits, windows, permissions, state machines), validation schemas,
29
+ API/route handlers, seed data (real names, codes, dates you can use as inputs), README/PRD.
30
+ List every rule as one line: `rule — file:line`.
31
+
32
+ ## 3. Invent the cases
33
+
34
+ For each form and each rule, write cases in three kinds:
35
+ 1. **Happy path** — the main job of the page works.
36
+ 2. **Boundary** — the edge of a rule: last valid day, max quantity, code at its limit.
37
+ 3. **Refusal** — the rule must say no: expired window, sold out, wrong code, a started event, a user without the role.
38
+
39
+ Prefer the cases a scripted suite does not have. Check the existing tests first and say which rules they skip.
40
+
41
+ ## 4. Write the spec
42
+
43
+ One file, `quicke2e.spec.mjs`, exporting an array:
44
+
45
+ ```js
46
+ export default [
47
+ {
48
+ name: "refund-refused-after-start",
49
+ start: "/orders",
50
+ maxSteps: 14,
51
+ inputs: { email: "buyer@example.com" }, // every typed value comes from here
52
+ goal: "Open the order for the concert that already started and request a refund",
53
+ done: "The refund request was refused",
54
+ expectUrl: "/orders/\\d+$", // THE ASSERTION
55
+ expect: ["Refunds close when the event starts"], // THE ASSERTION
56
+ },
57
+ ];
58
+ ```
59
+
60
+ Rules for the assertion — the assertion IS the test:
61
+ - Assert only what is true AFTER the work: a URL that cannot exist before, text that did not exist before, or a control state via `expectState: [{ role, name, value | checked | selected }]`.
62
+ - `expect` is text a sighted user sees. It ignores form controls (a value the test typed or chose is not a result) and hidden text. To check a chosen option or a field's value, use `expectState`. For a toast that disappears, use `expectSeen`.
63
+ - Never assert a label, a button text, or a word that is on the page anyway.
64
+ - For a refusal, assert the refusal text AND that the success state is absent (use a URL that only the refusal page has, or text only the refusal shows).
65
+ - Field keys in `inputs` must match the field label ("email" matches "Email"). Labels that share a substring ("Email" and "Billing email") get the same value: give such fields distinct keys or avoid the case.
66
+
67
+ If the page shows sensitive data the engine must not see (recovery codes, saved cards, personal
68
+ data), declare it in the spec: `redact: [".backup-code", "#saved-cards", /recovery code \S+/i]`
69
+ (CSS selectors and text patterns). Nothing is guessed automatically. Secret spec inputs (password,
70
+ api key, card number, token, …) are always kept out.
71
+
72
+ ## 5. Check, then run
73
+
74
+ ```bash
75
+ npx quicke2e check quicke2e.spec.mjs --base http://localhost:3000 # rejects WEAK_ASSERTION specs
76
+ npx quicke2e run quicke2e.spec.mjs --base http://localhost:3000 --map quicke2e.map.json --runs 3
77
+ ```
78
+
79
+ A `WEAK_ASSERTION` means the assertion already holds on the start page: rewrite the assertion, never add `--allow-weak` to make it go away.
80
+
81
+ ## 6. Report
82
+
83
+ For each failing spec: is it an APP bug (the rule in the source is violated) or a SPEC/TOOL problem (the run never reached the place)? Read the trace (`--trace runs/`) before you decide. The outcome tells you: `DONE_VERIFIED` = pass; `MAX_STEPS`/`MODEL_BLOCKED` = it got lost; a wrong final page with the success text visible = the app broke a rule.
84
+
85
+ - **Never edit a spec to make it pass.** A red refusal case is the finding.
86
+ - For each app bug: the rule (file:line), the spec name, what happened. Suggest the lowest-level test that would catch it.
87
+ - For a flow that must stay green, emit a Playwright spec from a passing run: `--emit e2e/generated/`.
@@ -0,0 +1,8 @@
1
+ // /events/42/checkout -> /events/:id/checkout. Numeric, uuid-ish and long mixed slugs are ids.
2
+ export function pattern(u) {
3
+ const url = new URL(u);
4
+ const segs = url.pathname.split("/").map((s) =>
5
+ /^\d+$/.test(s) || /^[0-9a-f]{8}-[0-9a-f-]{20,}$/i.test(s) || /^[0-9a-f]{12,}$/i.test(s)
6
+ || (s.length >= 16 && /\d/.test(s) && /[a-z]/i.test(s)) ? ":id" : s);
7
+ return segs.join("/") || "/";
8
+ }