decisium 0.0.0-stage → 0.1.2
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 +33 -2
- package/dist/browser.d.ts +98 -0
- package/dist/browser.js +539 -0
- package/dist/cassette.d.ts +58 -0
- package/dist/cassette.js +298 -0
- package/dist/change.d.ts +6 -0
- package/dist/change.js +134 -0
- package/dist/check.d.ts +27 -0
- package/dist/check.js +253 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +176 -0
- package/dist/decision.d.ts +124 -0
- package/dist/decision.js +143 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +46 -0
- package/dist/execute.d.ts +34 -0
- package/dist/execute.js +61 -0
- package/dist/feature.d.ts +37 -0
- package/dist/feature.js +92 -0
- package/dist/formatter.d.ts +5 -0
- package/dist/formatter.js +119 -0
- package/dist/jev.d.ts +36 -0
- package/dist/jev.js +198 -0
- package/dist/lint.d.ts +5 -0
- package/dist/lint.js +109 -0
- package/dist/network.d.ts +9 -0
- package/dist/network.js +35 -0
- package/dist/observation.d.ts +87 -0
- package/dist/observation.js +222 -0
- package/dist/options.d.ts +7 -0
- package/dist/options.js +27 -0
- package/dist/report.d.ts +55 -0
- package/dist/report.js +151 -0
- package/dist/runner.d.ts +36 -0
- package/dist/runner.js +282 -0
- package/dist/step.d.ts +55 -0
- package/dist/step.js +126 -0
- package/dist/steps.d.ts +1 -0
- package/dist/steps.js +205 -0
- package/dist/trace.d.ts +28 -0
- package/dist/trace.js +112 -0
- package/docs/configuration.md +53 -0
- package/docs/cookbook.md +346 -0
- package/package.json +69 -4
- package/skills/decisium/SKILL.md +38 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readStep } from "./step.js";
|
|
2
|
+
import { capabilities, gate, GATE_TARGET_MARGIN, GATE_TARGET_TOP, grouped, NotYet, named, fmt, PAGE } from "./decision.js";
|
|
3
|
+
import { verdict as checkVerdict } from "./check.js";
|
|
4
|
+
import { changes } from "./change.js";
|
|
5
|
+
import { MASK } from "./observation.js";
|
|
6
|
+
/** `ask` on `obs`, re-observing after each change while it throws NotYet. */
|
|
7
|
+
async function settled(rt, obs, ask) {
|
|
8
|
+
const deadline = Date.now() + ASSERT_TIMEOUT_MS;
|
|
9
|
+
for (;;) {
|
|
10
|
+
try {
|
|
11
|
+
return [obs, await ask(obs)];
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (!(err instanceof NotYet))
|
|
15
|
+
throw err;
|
|
16
|
+
const left = deadline - Date.now();
|
|
17
|
+
if (left <= 0 || !(await rt.waitForChange(obs.text, left)))
|
|
18
|
+
throw err.final;
|
|
19
|
+
obs = await rt.observe();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const MAX_TURNS = 8;
|
|
24
|
+
const TOGGLES = new Set(["checkbox", "switch", "menuitemcheckbox"]);
|
|
25
|
+
/** Jev torn between "nothing to do" and a toggle already in the wanted state (task 0043): both mean done. */
|
|
26
|
+
function alreadyToggled(pick, obs, goal) {
|
|
27
|
+
const done = (c) => c === "none" || obs.elements.some(e => e.ref === c && toggled(e, goal));
|
|
28
|
+
if (!Object.entries(pick.probabilities).some(([c, p]) => c !== "none" && p >= 0.1 && done(c)))
|
|
29
|
+
return false;
|
|
30
|
+
const merged = {};
|
|
31
|
+
for (const [c, p] of Object.entries(pick.probabilities))
|
|
32
|
+
merged[done(c) ? "none" : c] = (merged[done(c) ? "none" : c] ?? 0) + p;
|
|
33
|
+
return gate({ choice: "none", probabilities: merged }, GATE_TARGET_TOP, GATE_TARGET_MARGIN)
|
|
34
|
+
&& Object.entries(merged).every(([c, p]) => c === "none" || p < merged.none);
|
|
35
|
+
}
|
|
36
|
+
/** An element like `el` (role, name or text) inside a named element like `to` on `obs`; unnamed targets cannot be told apart. */
|
|
37
|
+
function landed(obs, el, to) {
|
|
38
|
+
if (!to.name)
|
|
39
|
+
return false;
|
|
40
|
+
const id = (e) => `${e.role}|${e.name || e.value}`;
|
|
41
|
+
return obs.elements.some(e => {
|
|
42
|
+
if (id(e) !== id(el))
|
|
43
|
+
return false;
|
|
44
|
+
for (let p = e.parent; p; p = p.parent)
|
|
45
|
+
if (p.role === to.role && p.name === to.name)
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** The element is a toggle in the Step's wanted state, or a checked radio (radios cannot be unchecked). */
|
|
51
|
+
const toggled = (e, goal) => goal !== undefined
|
|
52
|
+
&& (TOGGLES.has(e.role) || e.role === "radio" && goal) && /\[checked\]/.test(e.state) === goal;
|
|
53
|
+
/** The browser Action for each target action (decision.ts ACTIONS); `press <key>` is PRESS. */
|
|
54
|
+
const VERBS = { click: "CLICK", type: "TYPE", select: "SELECT", upload: "UPLOAD", paste: "PASTE",
|
|
55
|
+
hover: "HOVER", "double click": "DBLCLICK", "right click": "RIGHTCLICK", drag: "DRAG", "scroll down": "SCROLL", "scroll up": "SCROLL_UP",
|
|
56
|
+
wait: "WAIT", back: "BACK", forward: "FORWARD", reload: "RELOAD", "close tab": "CLOSE_TAB", "switch tab": "SWITCH_TAB", enter: "ENTER" };
|
|
57
|
+
const ASSERT_TIMEOUT_MS = 5000; // total time an Assertion step may wait for the UI to reach the expected state
|
|
58
|
+
const DONE_THRESHOLD = 0.8;
|
|
59
|
+
/**
|
|
60
|
+
* After an action that changed the page, "nothing left to do" is evidence too: with it, done needs only this much
|
|
61
|
+
* (task 0007). In the corpus every such Turn with done >= 0.5 was a real success (SauceDemo cart 0.58-0.77).
|
|
62
|
+
*/
|
|
63
|
+
const DONE_AFTER_ACTION = 0.5;
|
|
64
|
+
/**
|
|
65
|
+
* Jev classifies the check and picks the element it is about; code decides pass/fail.
|
|
66
|
+
* `judged` opts in to fuzzy checks, which Jev answers directly and are reported as JUDGED_*, never PASSED.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* `earlier`: quoted values from this scenario's earlier Steps (value -> Step), for checks that quote none.
|
|
70
|
+
* `arg`: a doc string is one more quoted value; a one-column table is a list of values that must all be shown, a wider
|
|
71
|
+
* one is matched row by row to the page table under its column headers.
|
|
72
|
+
*/
|
|
73
|
+
export async function runAssertion(step, rt, decider, log = console.log, judged = false, earlier = {}, arg = {}) {
|
|
74
|
+
const { label: said, values, list, exact } = readStep({ text: step, kind: "assertion", ...arg }); // said: what Jev reads, with the table or doc string
|
|
75
|
+
log(`\nAssert: ${said}`);
|
|
76
|
+
const end = finish(log);
|
|
77
|
+
const { table } = arg;
|
|
78
|
+
if (table && !list && table.length < 2)
|
|
79
|
+
return end("UNVERIFIABLE", "the table has only its header row (a check's first row names the columns), so there is no row to compare");
|
|
80
|
+
let recallNote = "";
|
|
81
|
+
let recall;
|
|
82
|
+
if (!values.length && !table && Object.keys(earlier).length) {
|
|
83
|
+
const r = await decider.recall(said, earlier);
|
|
84
|
+
log(` Recall: ${fmt(r)}`);
|
|
85
|
+
if (gate(r) && r.choice !== "none") {
|
|
86
|
+
values.push(r.choice);
|
|
87
|
+
recall = r;
|
|
88
|
+
}
|
|
89
|
+
else
|
|
90
|
+
recallNote = `; Jev could not tell which earlier value it refers to (${named(r)})`;
|
|
91
|
+
}
|
|
92
|
+
// Like Playwright's expect: a check that does not pass is re-evaluated whenever the UI changes, until
|
|
93
|
+
// the UI stops changing within the wait cap. Jev is asked again only after a change.
|
|
94
|
+
const deadline = Date.now() + ASSERT_TIMEOUT_MS;
|
|
95
|
+
let obs = await rt.observe();
|
|
96
|
+
for (;;) {
|
|
97
|
+
let verdict;
|
|
98
|
+
[obs, verdict] = await settled(rt, obs, check);
|
|
99
|
+
const final = ["PASSED", "JUDGED_PASSED", "UNVERIFIABLE"].includes(verdict.outcome);
|
|
100
|
+
const left = deadline - Date.now();
|
|
101
|
+
if (verdict.outcome === "UNVERIFIABLE")
|
|
102
|
+
verdict.reason += recallNote;
|
|
103
|
+
if (final || left <= 0)
|
|
104
|
+
return end(verdict.outcome, verdict.reason, verdict.marginal);
|
|
105
|
+
log(" (not passed yet; waiting for the UI to change)");
|
|
106
|
+
if (!(await rt.waitForChange(obs.text, left)))
|
|
107
|
+
return end(verdict.outcome, verdict.reason);
|
|
108
|
+
obs = await rt.observe();
|
|
109
|
+
}
|
|
110
|
+
async function check(obs) {
|
|
111
|
+
const a = await decider.assess(said, obs, judged);
|
|
112
|
+
const result = checkVerdict(a, obs, { values, table, list, exact, recall });
|
|
113
|
+
for (const line of result.details)
|
|
114
|
+
log(line);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const finish = (log) => (outcome, reason, weak = []) => {
|
|
119
|
+
const marginal = weak.length ? weak.join("; ") : undefined;
|
|
120
|
+
log(` => ${outcome}${reason ? `: ${reason}` : ""}${marginal ? ` (marginal: ${marginal})` : ""}`);
|
|
121
|
+
return { outcome, reason, ...(marginal ? { marginal } : {}) };
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Action or Attempt step. An Attempt is done once its actions are performed, even if the app rejects them.
|
|
125
|
+
* `docString`: one more value to type, read by Jev with the Step.
|
|
126
|
+
*/
|
|
127
|
+
export async function runStep(step, rt, decider, log = console.log, attempt = false, docString) {
|
|
128
|
+
return rt.actionStep(() => act(step, rt, decider, log, attempt, docString), log);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* How to answer the open dialog: Cancel unless Jev is sure the Step means OK (or one of its quoted values, for a
|
|
132
|
+
* prompt). An alert, or a prompt the Step quotes nothing for, has no other answer, so Jev is not asked.
|
|
133
|
+
*/
|
|
134
|
+
async function answerDialog(dialog, said, values, obs, decider, log) {
|
|
135
|
+
const answers = dialog.type === "alert" ? [] : dialog.type === "prompt" ? values : ["accept"];
|
|
136
|
+
if (!answers.length)
|
|
137
|
+
return { verb: "DISMISS" };
|
|
138
|
+
const p = await decider.dialog(said, obs, [...answers, "dismiss"]);
|
|
139
|
+
log(` Dialog answer: ${fmt(p)}`);
|
|
140
|
+
if (!gate(p) || p.choice === "dismiss")
|
|
141
|
+
return { verb: "DISMISS" };
|
|
142
|
+
return dialog.type === "prompt" ? { verb: "ACCEPT", value: p.choice } : { verb: "ACCEPT" };
|
|
143
|
+
}
|
|
144
|
+
async function act(step, rt, decider, log, attempt, docString) {
|
|
145
|
+
const read = readStep({ text: step, kind: "action", docString });
|
|
146
|
+
const said = read.label;
|
|
147
|
+
log(`\n${attempt ? "Attempt" : "Step"}: ${said}`);
|
|
148
|
+
const end = finish(log);
|
|
149
|
+
// Only "press X" or "go back" / "go to /x": nothing to choose, so code does it and the following checks verify the
|
|
150
|
+
// effect (tasks 0039, 0041).
|
|
151
|
+
const only = read.pressOnly ? { verb: "PRESS", value: read.pressOnly } : read.navigation;
|
|
152
|
+
if (only) {
|
|
153
|
+
const described = `${only.verb}${only.value === undefined ? "" : ` ${only.value}`}`;
|
|
154
|
+
log(` Execute: ${described}`);
|
|
155
|
+
try {
|
|
156
|
+
await rt.execute(only);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
return end("ACTION_FAILED", `${described} failed: ${err.message.split("\n")[0]}`);
|
|
160
|
+
}
|
|
161
|
+
return end("DONE");
|
|
162
|
+
}
|
|
163
|
+
const recent = []; // this Step's actions, sent to Jev so it knows what it already did
|
|
164
|
+
let unchanged = 0;
|
|
165
|
+
let obs = await rt.observe();
|
|
166
|
+
for (let turn = 1; turn <= MAX_TURNS; turn++) {
|
|
167
|
+
log(`\n Observation #${turn}:`);
|
|
168
|
+
for (const e of obs.elements)
|
|
169
|
+
log(` [${e.ref}] ${e.line}`);
|
|
170
|
+
// An open dialog blocks the page: this Turn answers it, nothing else.
|
|
171
|
+
if (obs.dialog?.state === "open") {
|
|
172
|
+
const action = await answerDialog(obs.dialog, said, read.values, obs, decider, log);
|
|
173
|
+
const described = `${action.verb} ${obs.dialog.type} "${obs.dialog.message}"${action.value === undefined ? "" : ` <- "${action.value}"`}`;
|
|
174
|
+
log(` Execute: ${described}`);
|
|
175
|
+
try {
|
|
176
|
+
await rt.execute(action);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
return end("ACTION_FAILED", `${described} failed: ${err.message.split("\n")[0]}`);
|
|
180
|
+
}
|
|
181
|
+
recent.push(described);
|
|
182
|
+
obs = await rt.observe();
|
|
183
|
+
unchanged = 0;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
let t;
|
|
187
|
+
[obs, t] = await settled(rt, obs, o => decider.turn(said, o, recent, attempt, read.keys));
|
|
188
|
+
t = { ...t, target: grouped(t.target, obs) }; // equivalent links count as one (task 0065)
|
|
189
|
+
log(` Decision: done=${t.done.toFixed(2)}${t.action ? ` action: ${fmt(t.action)}` : ""} target: ${fmt(t.target)}`);
|
|
190
|
+
if (t.done >= DONE_THRESHOLD)
|
|
191
|
+
return end("DONE");
|
|
192
|
+
if (recent.length && unchanged === 0 && t.target.choice === "none" && t.done >= DONE_AFTER_ACTION)
|
|
193
|
+
return end("DONE");
|
|
194
|
+
if (alreadyToggled(t.target, obs, read.toggle)) {
|
|
195
|
+
log(" Toggle: already done");
|
|
196
|
+
return end("DONE");
|
|
197
|
+
}
|
|
198
|
+
if (!gate(t.target, GATE_TARGET_TOP, GATE_TARGET_MARGIN))
|
|
199
|
+
return end("AMBIGUOUS", `Jev is unsure what to do next: ${named(t.target, obs)}`);
|
|
200
|
+
if (t.target.choice === "none")
|
|
201
|
+
return end("BLOCKED", "nothing on this screen helps do the step");
|
|
202
|
+
const el = t.target.choice === PAGE ? undefined : obs.elements.find(e => e.ref === t.target.choice);
|
|
203
|
+
if (t.target.choice !== PAGE && !el)
|
|
204
|
+
return end("ELEMENT_NOT_FOUND", `Jev chose an element that is no longer on the page (${t.target.choice})`);
|
|
205
|
+
// The likeliest action the target can do; not gated (task 0065: restricted, the right one can be far below 0.5).
|
|
206
|
+
const can = capabilities(el ?? PAGE, obs, read.keys);
|
|
207
|
+
const how = !t.action ? can[0] : Object.entries(t.action.probabilities).filter(([a]) => can.includes(a)).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
208
|
+
// Only a recorded answer can miss (live answers rate every action): it was not offered this Turn.
|
|
209
|
+
if (!how)
|
|
210
|
+
return end("ELEMENT_NOT_FOUND", `Jev chose ${t.action?.choice} on ${el?.line ?? "the page"}, which is not offered now`);
|
|
211
|
+
const key = /^press (.+)$/.exec(how)?.[1];
|
|
212
|
+
let action;
|
|
213
|
+
let described;
|
|
214
|
+
let dropOn;
|
|
215
|
+
if (!el) {
|
|
216
|
+
action = key ? { verb: "PRESS", value: key } : { verb: VERBS[how] };
|
|
217
|
+
described = key ? `PRESS ${key}` : action.verb;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
if (el.verb === "CLICK" && toggled(el, read.toggle)) {
|
|
221
|
+
log(` Toggle: already ${el.line}`);
|
|
222
|
+
return end("DONE");
|
|
223
|
+
}
|
|
224
|
+
action = { verb: VERBS[how], ref: el.ref };
|
|
225
|
+
let onto = "";
|
|
226
|
+
if (action.verb === "DRAG") {
|
|
227
|
+
const d = await decider.drop(said, obs, el);
|
|
228
|
+
log(` Drop: ${fmt(d)}`);
|
|
229
|
+
if (!gate(d, GATE_TARGET_TOP, GATE_TARGET_MARGIN) || d.choice === "none")
|
|
230
|
+
return end("AMBIGUOUS", `Jev is unsure where to drop ${el.line}: ${named(d, obs)}`);
|
|
231
|
+
action.to = d.choice;
|
|
232
|
+
const to = dropOn = [...obs.readable, ...obs.areas].find(e => e.ref === d.choice);
|
|
233
|
+
onto = ` onto ${to.role} "${to.name || to.value.slice(0, 60)}"`;
|
|
234
|
+
}
|
|
235
|
+
if (action.verb === "TYPE" || action.verb === "SELECT" || action.verb === "UPLOAD") {
|
|
236
|
+
// A file to upload is only ever one the Step quotes (task 0044).
|
|
237
|
+
const candidates = el.verb === "SELECT" ? el.options : action.verb === "UPLOAD" ? read.quoted : read.candidates;
|
|
238
|
+
if (!candidates.length)
|
|
239
|
+
return end("AMBIGUOUS", `the step gives no value to enter into ${el.line}; quote it (e.g. enter "Winter Sale")`);
|
|
240
|
+
// A clear Step that quotes nothing allows only "": no question (task 0067: Jev's "" 79% missed the gate).
|
|
241
|
+
const v = candidates.length === 1 && candidates[0] === "" && action.verb === "TYPE" ? { choice: "", probabilities: { "": 1 } }
|
|
242
|
+
: await decider.value(said, obs, el, candidates);
|
|
243
|
+
log(` Value: ${el.secret ? "(secret)" : fmt(v)}`);
|
|
244
|
+
if (!gate(v) || v.choice === "none")
|
|
245
|
+
return end("AMBIGUOUS", `Jev is unsure which value to enter into ${el.line}${el.secret ? "" : `: ${named(v)}`}`);
|
|
246
|
+
action.value = v.choice;
|
|
247
|
+
}
|
|
248
|
+
const shown = action.value === undefined ? "" : ` <- "${el.secret ? MASK : action.value}"`;
|
|
249
|
+
described = `${action.verb} ${el.role} "${el.name || el.value.slice(0, 60)}"${shown}${onto}`;
|
|
250
|
+
}
|
|
251
|
+
// The page moved on its own since the Turn's Observation: diff from now, so that change is not the action's.
|
|
252
|
+
const before = await rt.stale?.() ? await rt.observe() : obs;
|
|
253
|
+
log(` Execute: ${described}`);
|
|
254
|
+
try {
|
|
255
|
+
await rt.execute(action);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
return end("ACTION_FAILED", `${described} failed: ${err.message.split("\n")[0]}`);
|
|
259
|
+
}
|
|
260
|
+
const next = await rt.observe();
|
|
261
|
+
// What the action changed, for Jev's next `done` (task 0066: under-confident after working actions).
|
|
262
|
+
const effect = changes(before, next, action.ref);
|
|
263
|
+
log(` Effect: ${effect}`);
|
|
264
|
+
recent.push(`${described} -> ${effect}`);
|
|
265
|
+
// Drag effect check (task 0065): Jev's done stayed 0.57-0.73 with the item shown in its new list, any wording.
|
|
266
|
+
if (action.verb === "DRAG" && el && dropOn && !landed(obs, el, dropOn) && landed(next, el, dropOn)) {
|
|
267
|
+
log(` Drag: ${el.line} is now in ${dropOn.role} "${dropOn.name}"`);
|
|
268
|
+
return end("DONE");
|
|
269
|
+
}
|
|
270
|
+
const box = action.verb === "CLICK" ? next.elements.find(e => e.ref === action.ref && toggled(e, read.toggle)) : undefined;
|
|
271
|
+
if (box) {
|
|
272
|
+
log(` Toggle: ${box.line}`);
|
|
273
|
+
return end("DONE");
|
|
274
|
+
}
|
|
275
|
+
// By the effect, not the raw text: focus alone ([active]) is no change (task 0067, false DONE on the hover menu).
|
|
276
|
+
unchanged = effect === "no visible change" ? unchanged + 1 : 0;
|
|
277
|
+
if (unchanged >= 2)
|
|
278
|
+
return end("BLOCKED", `the page did not change after two actions (last: ${described})`);
|
|
279
|
+
obs = next;
|
|
280
|
+
}
|
|
281
|
+
return end("STEP_TIMEOUT", `not done after ${MAX_TURNS} actions (${recent.join(", ")})`);
|
|
282
|
+
}
|
package/dist/step.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PickleStepType } from "@cucumber/messages";
|
|
2
|
+
export type StepKind = "action" | "attempt" | "assertion";
|
|
3
|
+
/** A Step's Gherkin argument: a data table (rows of cells) or a doc string. */
|
|
4
|
+
export interface StepArgument {
|
|
5
|
+
table?: string[][];
|
|
6
|
+
docString?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ParsedStep extends StepArgument {
|
|
9
|
+
text: string;
|
|
10
|
+
kind: StepKind;
|
|
11
|
+
line?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface StepReading {
|
|
14
|
+
/** The Step as reports and cassette keys show it: the table compactly, e.g. `[First name=Ada, Last name=Lovelace]`. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** What runs: an Action step's Data table as one Step per row (none for a header alone), else the text. */
|
|
17
|
+
rows: string[];
|
|
18
|
+
/** Double-quoted strings in the text. Single quotes are left alone so apostrophes ("campaign's") don't open a quote. */
|
|
19
|
+
quoted: string[];
|
|
20
|
+
/** Quoted values and the Doc string; for a check also its one-column table: what it compares, or a prompt can answer. */
|
|
21
|
+
values: string[];
|
|
22
|
+
/** Candidate values an Action step can type: quoted strings, numbers with an optional unit word, the Doc string; clear/empty adds "". */
|
|
23
|
+
candidates: string[];
|
|
24
|
+
/** Named keys after "press" or "hit", as Playwright names them, one per verb. Actions only. */
|
|
25
|
+
keys: string[];
|
|
26
|
+
/** What the Step means as a key (a `+` combination, or "the X key") but is not a key name; never pressed. */
|
|
27
|
+
unknownKeys: string[];
|
|
28
|
+
/** The key of a Step that is only "press X" (`I press Escape`, `hit the "Tab" key`). */
|
|
29
|
+
pressOnly?: string;
|
|
30
|
+
/** A Step that is only "go back", "go forward", "reload the page" or "go to <URL or path>" (task 0041). */
|
|
31
|
+
navigation?: Navigation;
|
|
32
|
+
/** A Step that only checks (true) or unchecks (false) one box; "and"/"then" mean more to do. */
|
|
33
|
+
toggle?: boolean;
|
|
34
|
+
/** A check of rows "exactly" or "in this order". */
|
|
35
|
+
exact: boolean;
|
|
36
|
+
/** The table's first row is a header: a check with 2+ columns, an action with 3+. */
|
|
37
|
+
header: boolean;
|
|
38
|
+
/** A check's one-column table: a list of values that must all be shown. */
|
|
39
|
+
list: boolean;
|
|
40
|
+
/** The `"$NAME"` Secret placeholders: quoted, as the Doc string, or as a table cell. */
|
|
41
|
+
placeholders: string[];
|
|
42
|
+
/** An action's table cell that cannot become a quoted value (rows): empty gives none, a `"` splits it in two. */
|
|
43
|
+
badCell?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function readStep(step: ParsedStep): StepReading;
|
|
46
|
+
export declare const stepKind: (step: {
|
|
47
|
+
text: string;
|
|
48
|
+
type?: PickleStepType;
|
|
49
|
+
}) => StepKind;
|
|
50
|
+
/** A quoted `"$NAME"` is a secret placeholder (task 0030): the variable's name, else undefined. */
|
|
51
|
+
export declare const placeholder: (value: string) => string | undefined;
|
|
52
|
+
export interface Navigation {
|
|
53
|
+
verb: "GOTO" | "BACK" | "FORWARD" | "RELOAD" | "CLOSE_TAB" | "SWITCH_TAB";
|
|
54
|
+
value?: string;
|
|
55
|
+
}
|
package/dist/step.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The Step language (task 0052): what a Step's text, Data table and Doc string mean to the runtime, read in one place.
|
|
2
|
+
// The runner, Jev's questions, lint, reports and cassette keys all read a Step through readStep().
|
|
3
|
+
import { PickleStepType } from "@cucumber/messages";
|
|
4
|
+
export function readStep(step) {
|
|
5
|
+
const { text, kind, table, docString } = step;
|
|
6
|
+
const isCheck = kind === "assertion";
|
|
7
|
+
const header = !!table && table[0].length > (isCheck ? 1 : 2);
|
|
8
|
+
const list = isCheck && !!table && !header;
|
|
9
|
+
const quotedText = quoted(text);
|
|
10
|
+
const withDoc = docString === undefined ? quotedText : [...quotedText, docString];
|
|
11
|
+
const numbers = [...text.matchAll(/\b\d+(?:[.,]\d+)?(?:\s+[a-z]+)?/gi)].map(m => m[0]);
|
|
12
|
+
const { keys, unknown } = isCheck ? { keys: [], unknown: [] } : stepKeys(text);
|
|
13
|
+
const clear = !isCheck && /\b(clear|empty)\b/i.test(text.replace(/["“][^"”]*["”]/g, ""));
|
|
14
|
+
return {
|
|
15
|
+
label: label(text, table, docString, header),
|
|
16
|
+
rows: table && !isCheck ? tableSteps(text, table, header) : [text],
|
|
17
|
+
quoted: quotedText,
|
|
18
|
+
values: list ? [...withDoc, ...table.map(r => r[0])] : withDoc,
|
|
19
|
+
candidates: [...new Set([...quotedText, ...numbers, ...clear ? [""] : []]), ...docString === undefined ? [] : [docString]],
|
|
20
|
+
keys,
|
|
21
|
+
unknownKeys: unknown,
|
|
22
|
+
pressOnly: isCheck ? undefined : pressOnly(text),
|
|
23
|
+
navigation: isCheck ? undefined : navigation(text),
|
|
24
|
+
toggle: isCheck ? undefined : toggleGoal(text),
|
|
25
|
+
exact: /\b(exactly|in this order)\b/i.test(text),
|
|
26
|
+
header,
|
|
27
|
+
list,
|
|
28
|
+
placeholders: [...withDoc, ...table?.flat() ?? []].filter(v => placeholder(v)),
|
|
29
|
+
badCell: isCheck ? undefined : table?.flat().find(c => !c.trim() || /["“”]/.test(c)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** "try"/"attempt" right after the keyword marks an Attempt step: done once performed, even if the app rejects it. */
|
|
33
|
+
const ATTEMPT = /^(I\s+)?(try|tries|attempt|attempts)\b/i;
|
|
34
|
+
export const stepKind = (step) => step.type === PickleStepType.OUTCOME ? "assertion" : ATTEMPT.test(step.text) ? "attempt" : "action";
|
|
35
|
+
/** A quoted `"$NAME"` is a secret placeholder (task 0030): the variable's name, else undefined. */
|
|
36
|
+
export const placeholder = (value) => /^\$([A-Za-z_]\w*)$/.exec(value)?.[1];
|
|
37
|
+
const quoted = (text) => [...text.matchAll(/["“]([^"”]+)["”]/g)].map(m => m[1]);
|
|
38
|
+
function label(text, table, docString, header) {
|
|
39
|
+
if (docString !== undefined)
|
|
40
|
+
return `${text} ${JSON.stringify(docString)}`;
|
|
41
|
+
if (!table)
|
|
42
|
+
return text;
|
|
43
|
+
const cells = header ? table.slice(1).map(row => row.map((v, i) => `${table[0][i]}=${v}`).join(", ")).join("; ")
|
|
44
|
+
: table.map(row => row.length === 1 ? row[0] : `${row[0]}=${row[1]}`).join(", ");
|
|
45
|
+
return `${text} [${cells}]`;
|
|
46
|
+
}
|
|
47
|
+
/** An Action step's table as one Step per row, each resolved alone; 2 columns are label/value pairs (Cucumber `rowsHash`). */
|
|
48
|
+
function tableSteps(text, table, header) {
|
|
49
|
+
const base = text.replace(/\s*:$/, "");
|
|
50
|
+
if (header)
|
|
51
|
+
return table.slice(1).map(row => `${base} ${row.map((v, i) => `${table[0][i]} "${v}"`).join(", ")}`);
|
|
52
|
+
return table.map(row => row.length === 1 ? `${base} "${row[0]}"` : `I enter "${row[1]}" as ${row[0]}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Jev's `done` barely tells checked from unchecked (task 0033: 0.45-0.80 on checked boxes, so it kept clicking), so
|
|
56
|
+
* code reads the box after the click instead, like the TYPE/SELECT effect check.
|
|
57
|
+
*/
|
|
58
|
+
function toggleGoal(text) {
|
|
59
|
+
if (/\b(and|then)\b|,/i.test(text))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (/^(I\s+)?(uncheck|untick|deselect|clear|turn off|switch off|disable)\b/i.test(text))
|
|
62
|
+
return false;
|
|
63
|
+
// Task 0043: other wordings mean on unless negated; only applied when the clicked element is a toggle or radio.
|
|
64
|
+
return !/\b(disable|turn off|switch off|decline|reject|opt out|unsubscribe)/i.test(text);
|
|
65
|
+
}
|
|
66
|
+
/** Key names a Step can press (task 0039): Playwright's, lower-cased, plus aliases. */
|
|
67
|
+
const KEYS = new Map([
|
|
68
|
+
..."Escape Tab Enter Backspace Delete Insert Home End PageUp PageDown ArrowUp ArrowDown ArrowLeft ArrowRight Space Control Shift Alt Meta"
|
|
69
|
+
.split(" "), ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`)
|
|
70
|
+
].map(k => [k.toLowerCase(), k]));
|
|
71
|
+
for (const [alias, key] of Object.entries({ esc: "Escape", return: "Enter", del: "Delete", ctrl: "Control", cmd: "Meta", command: "Meta",
|
|
72
|
+
option: "Alt", "page up": "PageUp", "page down": "PageDown", "up arrow": "ArrowUp", "down arrow": "ArrowDown",
|
|
73
|
+
"left arrow": "ArrowLeft", "right arrow": "ArrowRight", "arrow up": "ArrowUp", "arrow down": "ArrowDown",
|
|
74
|
+
"arrow left": "ArrowLeft", "arrow right": "ArrowRight" }))
|
|
75
|
+
KEYS.set(alias, key);
|
|
76
|
+
/** `Ctrl+K`, `down arrow` -> Playwright's `Control+K`, `ArrowDown`; undefined if a part is no key. A letter or digit alone only quoted. */
|
|
77
|
+
function keyName(expr, quoted) {
|
|
78
|
+
const parts = expr.trim().split(/\s*\+\s*/);
|
|
79
|
+
const named = parts.map(p => KEYS.get(p.toLowerCase()) ?? (/^[a-z0-9]$/i.test(p) && (quoted || parts.length > 1) ? p : undefined));
|
|
80
|
+
return named.every(k => k !== undefined) ? named.join("+") : undefined;
|
|
81
|
+
}
|
|
82
|
+
const ELEMENT_NOUN = /^(button|link|tab|icon|item|option|menu|card|row|toggle|checkbox|control)s?$/i;
|
|
83
|
+
const PRESS = /\b(?:press(?:es|ed|ing)?|hit(?:s|ting)?)\s+(?:the\s+)?(?:["“]([^"”]+)["”]|([\w+]+(?:\s*\+\s*\w+)*)(?=((?:\s+\w+){0,2})))/gi; // the words after: lookahead, so a next "press" is still found
|
|
84
|
+
function stepKeys(text) {
|
|
85
|
+
const keys = [], unknown = [];
|
|
86
|
+
// Quoted text (a value to type, a doc string) names no key, unless it is the quoted key itself: press "Tab".
|
|
87
|
+
const said = text.replace(/(\b(?:press(?:es|ed|ing)?|hit(?:s|ting)?)\s+(?:the\s+)?["“][^"”]+["”])|["“][^"”]*["”]/gi, (m, key) => key ?? '""');
|
|
88
|
+
for (const [, quotedKey, first, rest] of said.matchAll(PRESS)) {
|
|
89
|
+
const [next = ""] = rest?.trim().split(/\s+/) ?? [];
|
|
90
|
+
// "press the Delete button" clicks a button named like a key.
|
|
91
|
+
const key = quotedKey !== undefined ? keyName(quotedKey, true)
|
|
92
|
+
: keyName(`${first} ${next}`, false) ?? (ELEMENT_NOUN.test(next) ? undefined : keyName(first, false));
|
|
93
|
+
if (key)
|
|
94
|
+
keys.push(key);
|
|
95
|
+
else if (first && (first.includes("+") || next.toLowerCase() === "key"))
|
|
96
|
+
unknown.push(first);
|
|
97
|
+
}
|
|
98
|
+
return { keys, unknown };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Code navigates without Jev only for these whole-Step forms; "go back to the list" and the like are left to Jev, which
|
|
102
|
+
* is offered back/forward/reload every Turn. GOTO needs something URL-like (a path, query, hash, or full URL), never a
|
|
103
|
+
* page name: "I go to the Settings page" is Jev's.
|
|
104
|
+
*/
|
|
105
|
+
function navigation(text) {
|
|
106
|
+
const t = text.trim().replace(/[.!]$/, "");
|
|
107
|
+
const go = /^(?:I\s+)?(?:go|goes|navigate|navigates|browse|browses)\s+/i.source;
|
|
108
|
+
if (new RegExp(`${go}back$`, "i").test(t))
|
|
109
|
+
return { verb: "BACK" };
|
|
110
|
+
if (new RegExp(`${go}forward$`, "i").test(t))
|
|
111
|
+
return { verb: "FORWARD" };
|
|
112
|
+
if (/^(?:I\s+)?(?:reload|reloads|refresh|refreshes)(?:\s+the)?(?:\s+page)?$/i.test(t))
|
|
113
|
+
return { verb: "RELOAD" };
|
|
114
|
+
// Jev picks "close tab" right (0.97) but then barely takes the Step as done (0.24-0.37): a closed tab leaves no trace.
|
|
115
|
+
if (/^(?:I\s+)?(?:close|closes)\s+(?:the|this)\s+(?:new\s+)?tab$/i.test(t))
|
|
116
|
+
return { verb: "CLOSE_TAB" };
|
|
117
|
+
if (/^(?:I\s+)?(?:switch|switches|go|goes)\s+back\s+to\s+the\s+(?:previous|first|other|original)\s+tab$/i.test(t))
|
|
118
|
+
return { verb: "SWITCH_TAB" };
|
|
119
|
+
const m = new RegExp(`(?:${go}to|^(?:I\\s+)?(?:open|opens|visit|visits))\\s+(?:["“]([^"”]+)["”]|(\\S+))$`, "i").exec(t);
|
|
120
|
+
const url = m?.[1] ?? m?.[2];
|
|
121
|
+
return url && /^(\/|\?|#|\.\.?\/|[a-z][a-z\d+.-]*:\/\/)/i.test(url) ? { verb: "GOTO", value: url } : undefined;
|
|
122
|
+
}
|
|
123
|
+
function pressOnly(text) {
|
|
124
|
+
const m = /^(?:I\s+)?(?:press|hit)(?:es|s)?\s+(?:the\s+)?(?:["“]([^"”]+)["”]|(.+?))(?:\s+key)?[.!]?$/i.exec(text.trim());
|
|
125
|
+
return m ? keyName(m[1] ?? m[2], m[1] !== undefined) : undefined;
|
|
126
|
+
}
|
package/dist/steps.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|