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/dist/check.js ADDED
@@ -0,0 +1,253 @@
1
+ import { AREA_MISSING, AREA_PAGE, gate, GATE_MARGIN, GATE_TOP, plausible, named, fmt } from "./decision.js";
2
+ import { assertableText } from "./observation.js";
3
+ // A space before closing punctuation is dropped: snapshots join text nodes with one ("cart !").
4
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").replace(/ ([!?.,;:)])/g, "$1").trim();
5
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ /**
7
+ * Whole-word, case/space-insensitive containment: "1 week" must not match "11 weeks". Only a neighbour of the same kind
8
+ * (letter next to letter, digit next to digit) joins words: snapshots drop line breaks ("1815<br>London" -> "1815London").
9
+ */
10
+ export function containsValue(actual, expected) {
11
+ const e = norm(expected);
12
+ const kind = (c = "") => /\p{L}/u.test(c) ? "\\p{L}" : /\p{N}/u.test(c) ? "\\p{N}" : undefined;
13
+ const [before, after] = [kind(e[0]), kind(e.at(-1))];
14
+ return new RegExp(`${before ? `(?<!${before})` : ""}${escape(e)}${after ? `(?!${after})` : ""}`, "u").test(norm(actual));
15
+ }
16
+ const STATE_FLAG = {
17
+ disabled: ["disabled", true], enabled: ["disabled", false],
18
+ checked: ["checked", true], unchecked: ["checked", false],
19
+ expanded: ["expanded", true], collapsed: ["expanded", false],
20
+ selected: ["selected", true], unselected: ["selected", false],
21
+ pressed: ["pressed", true], unpressed: ["pressed", false],
22
+ };
23
+ // Roles that can carry each ARIA state (WAI-ARIA 1.2). `disabled` applies to any control, so it has no entry.
24
+ const STATE_ROLES = {
25
+ checked: ["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio", "option", "treeitem"],
26
+ selected: ["option", "tab", "row", "gridcell", "columnheader", "rowheader", "treeitem"],
27
+ expanded: ["button", "combobox", "link", "menuitem", "tab", "treeitem", "row", "gridcell", "rowheader", "checkbox", "listbox", "application"],
28
+ pressed: ["button"],
29
+ };
30
+ const JUDGED_THRESHOLD = 0.8;
31
+ /**
32
+ * A pass resting on an answer less than this above its gate is marginal (task 0027). On the corpus (29 passes) it
33
+ * flags 6: the answers that agree carry 0.69-0.89 together.
34
+ */
35
+ const MARGINAL_BAND = 0.1;
36
+ /** Readings that agree (`agreeing`) carry the pass together; under gate + band they are a marginal answer. */
37
+ const weakMass = (what, pick, agreeing, obs) => agreeing.reduce((s, k) => s + (pick.probabilities[k] ?? 0), 0) < GATE_TOP + MARGINAL_BAND ? [`${what}: ${named(pick, obs)}`] : [];
38
+ /** A gated answer (state, recall) that cleared its gate by less than the band. */
39
+ const weakGate = (what, pick) => gate(pick, GATE_TOP + MARGINAL_BAND, GATE_MARGIN + MARGINAL_BAND) ? [] : [`${what}: ${named(pick)}`];
40
+ /** All applicable readings must agree; only a pass carries their combined confidence warnings. */
41
+ function agreement(readings, disagreement, marginal) {
42
+ const first = readings[0];
43
+ if (!first)
44
+ return { outcome: "UNVERIFIABLE", reason: "no plausible reading of the check" };
45
+ if (readings.some(r => r.outcome !== first.outcome))
46
+ return { outcome: "AMBIGUOUS", reason: disagreement };
47
+ if (first.outcome !== "PASSED")
48
+ return first;
49
+ return { ...first, marginal: [...new Set([...marginal, ...readings.flatMap(r => r.marginal ?? [])])] };
50
+ }
51
+ const areaDisagreement = (a, obs) => `Jev is unsure where the check says to look, and the candidates give different answers: ${named(a.area, obs)}`;
52
+ /** Pass/fail for one kind of check, decided by code from the elements Jev identified. */
53
+ function evaluate(kind, a, obs, values, log) {
54
+ if (kind === "fuzzy") {
55
+ if (a.judged === undefined)
56
+ return { outcome: "UNVERIFIABLE", reason: "this is a subjective check that code cannot compare; rerun with --judged to let Jev judge it" };
57
+ log(` Judged: p=${a.judged.toFixed(2)}`);
58
+ if (a.judged >= JUDGED_THRESHOLD)
59
+ return { outcome: "JUDGED_PASSED", reason: `p=${a.judged.toFixed(2)}` };
60
+ if (a.judged <= 1 - JUDGED_THRESHOLD)
61
+ return { outcome: "JUDGED_FAILED", reason: `p=${a.judged.toFixed(2)}` };
62
+ return { outcome: "AMBIGUOUS", reason: `Jev is unsure whether the page satisfies the check (${Math.round(a.judged * 100)}% yes)` };
63
+ }
64
+ if (kind === "presence" || kind === "absence") {
65
+ if (!values.length)
66
+ return { outcome: "UNVERIFIABLE", reason: "the check quotes no text to look for (e.g. \"Winter Sale\" should appear)" };
67
+ // Jev names the area the check is scoped to; code looks for the quoted text inside it. As with kinds, every
68
+ // plausible area is checked and they must agree. A text that is nowhere on the page is decided by code alone.
69
+ // Presence needs every quoted text; absence fails on any one of them, so "any shown" is what it must look for.
70
+ const has = (text) => kind === "absence"
71
+ ? values.some(v => containsValue(text, v))
72
+ : values.every(v => containsValue(text, v));
73
+ const text = values.map(v => `"${v}"`).join(kind === "absence" ? " or " : " and ");
74
+ const reading = (found, where) => found === (kind === "presence")
75
+ ? { outcome: "PASSED" }
76
+ : { outcome: "ASSERTION_FAILED", reason: `${text} ${found ? "is shown" : "is not shown"} in ${where}${found ? ", expected it to be absent" : ""}` };
77
+ // Text nowhere on the page rests on no area answer.
78
+ if (!has(obs.pageText))
79
+ return reading(false, "the page");
80
+ log(` Area: ${fmt(a.area)}`);
81
+ const areas = plausible(a.area);
82
+ const readings = [];
83
+ for (const ref of areas) {
84
+ if (ref === AREA_PAGE)
85
+ readings.push(reading(true, "the page"));
86
+ else if (ref === AREA_MISSING)
87
+ readings.push(reading(false, "the area the check names (that area is not on this screen)"));
88
+ else {
89
+ const el = obs.areas.find(e => e.ref === ref);
90
+ if (!el)
91
+ return { outcome: "ELEMENT_NOT_FOUND", reason: `Jev chose an area that is no longer on the page (${ref})` };
92
+ readings.push(reading(has(el.text), el.line));
93
+ }
94
+ }
95
+ // The old area comparison reported the last agreeing area.
96
+ return agreement(readings.reverse(), areaDisagreement(a, obs), weakMass("area", a.area, areas, obs));
97
+ }
98
+ // State and value checks read one element. Jev may be unsure which, e.g. a repository name shown in the breadcrumb
99
+ // and the title: as with kinds and areas, every plausible element is checked and they must agree.
100
+ if (kind === "state") {
101
+ log(` Subject: ${fmt(a.subject)} State: ${fmt(a.state)}`);
102
+ if (!gate(a.state))
103
+ return { outcome: "AMBIGUOUS", reason: `Jev is unsure which state the check means: ${named(a.state)}` };
104
+ }
105
+ else {
106
+ if (values.length !== 1)
107
+ return { outcome: "UNVERIFIABLE", reason: "a value check needs exactly one quoted expected value (e.g. the status should be \"Draft\")" };
108
+ log(` Subject: ${fmt(a.subject)}`);
109
+ }
110
+ // A reading that cannot apply (a link's text is its label, a link cannot be checked) carries no evidence.
111
+ const subjects = plausible(a.subject);
112
+ const all = subjects.map(ref => (kind === "state" ? stateOf : valueOf)(ref, a, obs, values, log));
113
+ const readings = all.filter(r => !r.inapplicable);
114
+ // No subject applies: unchanged from a single reading. A value check on a label stays a final UNVERIFIABLE, so a
115
+ // plausible presence reading of the same check cannot pass on the label's text.
116
+ if (!readings.length)
117
+ return kind === "value" ? { outcome: all[0].outcome, reason: all[0].reason } : all[0];
118
+ const marginal = weakMass("subject", a.subject, subjects.filter((_, i) => !all[i].inapplicable), obs);
119
+ const what = kind === "state" ? "control" : "element";
120
+ return agreement(readings, `Jev is unsure which ${what} the check is about, and they give different answers: ${named(a.subject, obs)}`, kind === "state" ? [...marginal, ...weakGate("state", a.state)] : marginal);
121
+ }
122
+ const TABLES = new Set(["table", "grid", "treegrid"]);
123
+ const cellText = (c) => (c.name || c.text).trim();
124
+ const within = (el, area) => { for (let p = el; p; p = p.parent)
125
+ if (p === area)
126
+ return true; return false; };
127
+ const tableOf = (row) => { for (let p = row.parent; p; p = p.parent)
128
+ if (TABLES.has(p.role))
129
+ return p; };
130
+ const isHeader = (row) => !!row.cells?.length && row.cells.every(c => c.role === "columnheader");
131
+ /** Column of `name` among the header's: same text, else the header containing it (a sort arrow, "Quantity ▲"). */
132
+ const column = (header, name) => {
133
+ const i = header.findIndex(h => norm(h) === norm(name));
134
+ return i >= 0 ? i : header.findIndex(h => containsValue(h, name));
135
+ };
136
+ /**
137
+ * A check's table (header row, then expected rows) against the page rows in each plausible area: every expected row
138
+ * must match a page row by column header (extra page rows allowed, order free); `exact`: the same rows in this order.
139
+ */
140
+ function matchRows(a, obs, table, exact, log) {
141
+ log(` Area: ${fmt(a.area)}`);
142
+ const areas = plausible(a.area);
143
+ const verdicts = [];
144
+ for (const ref of areas) {
145
+ if (ref === AREA_MISSING) {
146
+ verdicts.push({ outcome: "ASSERTION_FAILED", reason: "the table the check names is not on this screen" });
147
+ continue;
148
+ }
149
+ const area = obs.areas.find(e => e.ref === ref);
150
+ if (ref !== AREA_PAGE && !area)
151
+ return { outcome: "ELEMENT_NOT_FOUND", reason: `Jev chose an area that is no longer on the page (${ref})` };
152
+ verdicts.push(rowsIn(area, obs, table, exact));
153
+ }
154
+ return agreement(verdicts, areaDisagreement(a, obs), weakMass("area", a.area, areas, obs));
155
+ }
156
+ /** `area` undefined: the whole page. */
157
+ function rowsIn(area, obs, table, exact) {
158
+ const [names, ...expected] = table;
159
+ const where = area ? area.line : "the page";
160
+ const rows = obs.areas.filter(r => r.role === "row" && r.cells?.length && (!area || within(r, area)));
161
+ const header = (row) => rows.concat(obs.areas).find(h => h.role === "row" && isHeader(h) && tableOf(h) === tableOf(row))?.cells.map(cellText) ?? [];
162
+ const data = rows.filter(r => !isHeader(r));
163
+ if (!data.length)
164
+ return { outcome: "ASSERTION_FAILED", reason: `${where} shows no table rows` };
165
+ const noColumn = names.find(n => !data.some(r => column(header(r), n) >= 0));
166
+ if (noColumn !== undefined)
167
+ return { outcome: "ASSERTION_FAILED", reason: `the table in ${where} has no column "${noColumn}" (columns: ${[...new Set(data.flatMap(header))].join(", ")})` };
168
+ const matches = (row, want) => names.every((n, i) => {
169
+ const cell = row.cells[column(header(row), n)];
170
+ return cell !== undefined && (want[i] ? containsValue(cellText(cell), want[i]) : !cellText(cell)); // an empty expected cell is an empty cell
171
+ });
172
+ const show = (want) => names.map((n, i) => `${n}=${want[i]}`).join(", ");
173
+ const shown = `its rows: ${data.map(r => r.cells.map(cellText).join(" | ")).join("; ")}`;
174
+ if (exact) {
175
+ if (data.length === expected.length && expected.every((want, i) => matches(data[i], want)))
176
+ return { outcome: "PASSED" };
177
+ return { outcome: "ASSERTION_FAILED", reason: `expected exactly these rows in this order: ${expected.map(show).join("; ")}; ${where} has ${shown}` };
178
+ }
179
+ const free = [...data];
180
+ for (const want of expected) {
181
+ const i = free.findIndex(r => matches(r, want));
182
+ if (i < 0)
183
+ return { outcome: "ASSERTION_FAILED", reason: `expected row ${show(want)} is not in ${where}; ${shown}` };
184
+ free.splice(i, 1); // each page row matches one expected row
185
+ }
186
+ return { outcome: "PASSED" };
187
+ }
188
+ function stateOf(ref, a, obs) {
189
+ if (ref === "none")
190
+ return { outcome: "ASSERTION_FAILED", reason: "no element on the page matches what the check describes" };
191
+ const el = obs.readable.find(e => e.ref === ref);
192
+ if (!el)
193
+ return { outcome: "ELEMENT_NOT_FOUND", reason: `Jev chose an element that is no longer on the page (${ref})` };
194
+ let [flag, on] = STATE_FLAG[a.state.choice];
195
+ if (flag === "selected" && el.role === "link")
196
+ flag = "current"; // a navigation link is "selected" via aria-current
197
+ if (STATE_ROLES[flag] && !STATE_ROLES[flag].includes(el.role)) {
198
+ return { outcome: "UNVERIFIABLE", reason: `a ${el.role} cannot be ${a.state.choice}`, inapplicable: true };
199
+ }
200
+ const has = el.state.split(/\s+/).includes(`[${flag}]`);
201
+ return has === on ? { outcome: "PASSED" } : { outcome: "ASSERTION_FAILED", reason: `expected ${el.line} to be ${a.state.choice}, but it is not` };
202
+ }
203
+ function valueOf(ref, _a, obs, values, log) {
204
+ if (ref === "none")
205
+ return { outcome: "ASSERTION_FAILED", reason: "no element on the page shows what the check describes" };
206
+ const el = obs.readable.find(e => e.ref === ref);
207
+ if (!el)
208
+ return { outcome: "ELEMENT_NOT_FOUND", reason: `Jev chose an element that is no longer on the page (${ref})` };
209
+ const actual = assertableText(el);
210
+ if (actual === undefined)
211
+ return { outcome: "UNVERIFIABLE", reason: `${el.line} has no text to compare (only a label)`, inapplicable: true };
212
+ log(` Compare: "${values[0]}" in "${actual}"`);
213
+ if (el.role === "download" && el.state === "[empty]")
214
+ return { outcome: "ASSERTION_FAILED", reason: `the downloaded file is empty (0 bytes)` };
215
+ // URL paths are case-sensitive and not words: plain substring.
216
+ if (el.role === "url")
217
+ return actual.includes(values[0]) ? { outcome: "PASSED" } : { outcome: "ASSERTION_FAILED", reason: `expected the URL to contain "${values[0]}", but it is "${actual}"` };
218
+ if (containsValue(actual, values[0]))
219
+ return { outcome: "PASSED" };
220
+ // The subject shows part of the quoted text (prose split across a link and bare text, "<b>2</b> items left"):
221
+ // the rest can only be in the container holding it, so read that one, not anywhere on the page.
222
+ const host = el.parent;
223
+ if (actual && host && containsValue(values[0], actual)) {
224
+ log(` Compare: "${values[0]}" in "${host.text.trim()}" (${host.line}, holding ${el.line})`);
225
+ if (containsValue(host.text, values[0]))
226
+ return { outcome: "PASSED" };
227
+ }
228
+ return { outcome: "ASSERTION_FAILED", reason: `expected "${values[0]}", but ${el.role}${el.name && el.name !== actual ? ` "${el.name}"` : ""} shows "${actual}"` };
229
+ }
230
+ /** Pure Check verdict; diagnostics are returned for the runner to log, never emitted here. */
231
+ export function verdict(a, obs, expectation) {
232
+ const details = [` Kind: ${fmt(a.kind)}`];
233
+ const result = check(a, obs, expectation, line => { details.push(line); });
234
+ if (result.outcome === "PASSED" && expectation.recall) {
235
+ result.marginal = [...weakGate("earlier value", expectation.recall), ...result.marginal ?? []];
236
+ }
237
+ return { ...result, details };
238
+ }
239
+ function check(a, obs, { values, table, list = false, exact = false }, log) {
240
+ // Gate on the result, not the label: evaluate every plausible kind (most likely first, until they
241
+ // cover the gate's confidence) and accept only if they all reach the same outcome.
242
+ // A reading that does not apply to the element Jev picked (e.g. "selected" on a combobox) carries no evidence.
243
+ // A table's shape decides the kind, except that Jev's absence reading must not be lost ("should not show:").
244
+ const negated = plausible(a.kind).includes("absence");
245
+ if (table && !list && negated)
246
+ return { outcome: "UNVERIFIABLE", reason: "a check that table rows are not shown is not supported; check each row's absence in its own Step" };
247
+ const kinds = !table ? plausible(a.kind) : list ? [...new Set(plausible(a.kind).map(k => k === "absence" ? k : "presence"))] : ["rows"];
248
+ const all = kinds.map(kind => ({ kind, ...kind === "rows" ? matchRows(a, obs, table, exact, log) : evaluate(kind, a, obs, values, log) }));
249
+ const results = all.filter(r => !r.inapplicable);
250
+ if (!results.length)
251
+ return { outcome: "UNVERIFIABLE", reason: all.map(r => r.reason).join("; ") };
252
+ return agreement(results, `Jev could not tell what kind of check this is, and the readings disagree: ${results.map(r => `as a ${r.kind} check it is ${r.outcome}${r.reason ? ` (${r.reason})` : ""}`).join("; ")}`, table ? [] : weakMass("kind", a.kind, results.map(r => r.kind)));
253
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfiguration, runCucumber } from "@cucumber/cucumber/api";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { availableParallelism, tmpdir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseArgs } from "node:util";
8
+ import { RECORD_MODES } from "./cassette.js";
9
+ import { parseFeature, stepFeature } from "./feature.js";
10
+ import { applyOptions, ourStdout } from "./options.js";
11
+ import { doctor } from "./doctor.js";
12
+ import { lint } from "./lint.js";
13
+ import { INFRA_MARKER, RunDir } from "./trace.js";
14
+ import { MODEL } from "./jev.js";
15
+ // ".ts" from source, ".js" from the built package in dist/.
16
+ const EXT = import.meta.url.slice(-3);
17
+ const USAGE = `Usage:
18
+ decisium step "<step>" --url <url|file> [--headed] [--judged] [--strict] [-v] [--debug]
19
+ decisium run <file.feature[:line]>... [--url <url|file>] [--headed] [--judged] [--strict] [--record <mode>]
20
+ [--parallel <n|auto>] [--format <name[:path]>] [--import <file>] [-p <profile>] [--no-agent-report] [-v] [--debug]
21
+ decisium doctor [<file.feature>...]
22
+ decisium lint <file.feature>...
23
+
24
+ The start URL comes from --url or an @url:<url> tag (scenario tags override feature tags). file.feature:line runs only
25
+ the scenario (or Examples row) at that line.
26
+ --record (run only): what each Step resolved to (element role, name, container, position; values) is recorded in
27
+ <file>.cassette.json and replayed without Jev while exactly one element still matches it.
28
+ new (default): replay, resolve new or no-longer-matching Steps live and write them back (reported as healed);
29
+ none: replay only, a missing or broken resolution fails as UI_CHANGED (CI); all: resolve everything live, rewrite.
30
+ --parallel <n|auto>: worker processes. Features run side by side, a feature's scenarios one after another. Only for
31
+ features that do not share app state. auto: min(CPUs, 4) when calling Jev, every CPU with --record none. Default 0.
32
+ doctor: checks Node, Chromium and TYPESAFE_API_KEY (one tiny Jev call); given features, also opens
33
+ every start URL.
34
+ lint: no browser, no Jev. Per Step: its kind, the quoted values, keys, secret placeholders, and warnings for wordings
35
+ docs/cookbook.md shows failing.
36
+ Configuration is cucumber-js's: run loads your cucumber.{js,mjs,cjs,json,yaml} (and -p profiles); --format, --import and
37
+ -p (repeatable) are cucumber's flags, added to it. --format name[:path]: cucumber's junit, json, html, message or any
38
+ formatter package, e.g. --format junit:junit.xml or --format cucumberjs-qase-reporter:/dev/null. Only one formatter may
39
+ write to stdout: one configured without a path takes it, else Decisium's console output does.
40
+ --import: your own cucumber step definitions or hooks. A line one of them matches runs it, not Jev; the World gives them
41
+ this.page (the current tab) and this.context (Playwright). See docs/cookbook.md.
42
+ Options in the configuration: worldParameters: { decisium: { url, record, headed, judged, strict, verbose, debug,
43
+ agentReport } }; flags and DECISIUM_* env (DECISIUM_URL, DECISIUM_RECORD, DECISIUM_STRICT=1, ...) override them.
44
+ -v prints every Observation and Jev answer (always saved to runs/<timestamp>/log.txt); --debug shows stack traces.
45
+ --judged lets Jev judge fuzzy checks; they report JUDGED_PASSED/JUDGED_FAILED, never PASSED.
46
+ --strict: a check that passes on a barely-confident Jev answer (shown as "marginal pass") fails as AMBIGUOUS.
47
+
48
+ Each run writes runs/<timestamp>/: trace.jsonl (Observations, Jev answers, actions, outcomes) and per failed scenario a
49
+ screenshot per Turn, playwright-trace.zip, console-errors.txt and ui.txt (the page text). Passed scenarios keep none.
50
+ A run with a failure writes report.md: every failure with Jev's answers, the page text around it and a rerun command,
51
+ for a coding agent (--no-agent-report: off).
52
+
53
+ Runs inside cucumber-js: raw \`npx cucumber-js <file>\` works too with
54
+ import: ["node_modules/decisium/dist/steps.js"] and format: ["decisium/formatter"] in its cucumber.mjs.
55
+
56
+ Secrets: write "$NAME" in a Step's quotes (e.g. with password "$SAUCE_PASSWORD"): Jev, logs, trace and cassette see only
57
+ the token; the value comes from the environment (or .env) and is typed into the page. Wherever the page shows it, it is
58
+ masked back to the token. A scenario that typed one keeps no Playwright trace (it would store the value). A value
59
+ written literally in a Step is sent to Jev, recorded in the cassette and written to log.txt.
60
+
61
+ Exit codes: 0 passed, 1 a scenario failed, 2 configuration/infrastructure error.`;
62
+ async function main() {
63
+ const feature = (path) => path.replace(/:\d+$/, ""); // file.feature:line -> file.feature
64
+ if (existsSync(".env"))
65
+ process.loadEnvFile(".env"); // TYPESAFE_API_KEY; never logged
66
+ const { positionals, values } = parseArgs({
67
+ allowPositionals: true,
68
+ allowNegative: true,
69
+ // No defaults: an option not given falls back to DECISIUM_* env, then worldParameters.decisium (src/options.ts).
70
+ options: { url: { type: "string" }, headed: { type: "boolean" }, judged: { type: "boolean" },
71
+ strict: { type: "boolean" }, "agent-report": { type: "boolean" }, record: { type: "string" }, parallel: { type: "string" },
72
+ format: { type: "string", multiple: true, default: [] }, import: { type: "string", multiple: true, default: [] },
73
+ profile: { type: "string", short: "p", multiple: true, default: [] },
74
+ verbose: { type: "boolean", short: "v" }, debug: { type: "boolean" } },
75
+ });
76
+ const env = process.env;
77
+ const flag = (on) => (on === undefined ? undefined : on ? "1" : "0");
78
+ for (const [name, value] of Object.entries({ URL: values.url, RECORD: values.record, HEADED: flag(values.headed), JUDGED: flag(values.judged),
79
+ STRICT: flag(values.strict), NO_AGENT_REPORT: flag(values["agent-report"] === undefined ? undefined : !values["agent-report"]),
80
+ VERBOSE: flag(values.verbose), DEBUG: flag(values.debug) }))
81
+ if (value !== undefined)
82
+ env[`DECISIUM_${name}`] = value;
83
+ const [cmd, arg] = positionals;
84
+ if (cmd === "doctor")
85
+ return doctor(positionals.slice(1));
86
+ if (cmd === "lint" && arg) {
87
+ try {
88
+ console.log(positionals.slice(1).map(f => lint(f)).join("\n\n"));
89
+ return 0;
90
+ }
91
+ catch (err) {
92
+ console.error(err.message);
93
+ return 2;
94
+ }
95
+ }
96
+ let files;
97
+ let scenarios;
98
+ try {
99
+ if (cmd === "step" && arg) {
100
+ const file = join(mkdtempSync(join(tmpdir(), "decisium-")), "step.feature");
101
+ writeFileSync(file, stepFeature(arg));
102
+ files = [file];
103
+ const dir = dirname(file);
104
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true })); // every exit path, early returns included
105
+ }
106
+ else if (cmd === "run" && arg) {
107
+ files = positionals.slice(1);
108
+ const missing = files.find(f => !existsSync(feature(f)));
109
+ if (missing) {
110
+ console.error(`File not found: ${missing}`);
111
+ return 2;
112
+ }
113
+ }
114
+ else {
115
+ console.error(USAGE);
116
+ return 2;
117
+ }
118
+ scenarios = files.flatMap(f => parseFeature(readFileSync(feature(f), "utf8"), feature(f))); // syntax and unsupported arguments: one line, before running
119
+ }
120
+ catch (err) {
121
+ console.error(err.message);
122
+ return 2;
123
+ }
124
+ const parallelSet = values.parallel === "auto" ? -1 : Number(values.parallel ?? 0); // -1: auto, resolved once --record is known
125
+ if (!Number.isInteger(parallelSet) || parallelSet < -1) {
126
+ console.error(`--parallel must be a whole number or auto\n\n${USAGE}`);
127
+ return 2;
128
+ }
129
+ const here = (f) => fileURLToPath(import.meta.resolve(f));
130
+ // The client's cucumber configuration and profiles, plus ours; `step` runs one line, so none of the client's features.
131
+ const { useConfiguration, runConfiguration } = await loadConfiguration({ file: cmd === "step" ? false : undefined, profiles: values.profile, provided: {
132
+ paths: files, import: [here(`./steps${EXT}`), ...values.import.map(f => resolve(f))], format: values.format,
133
+ } });
134
+ if (ourStdout(useConfiguration.format, runConfiguration.formats.stdout))
135
+ runConfiguration.formats.stdout = here(`./formatter${EXT}`);
136
+ try {
137
+ applyOptions(runConfiguration.runtime.worldParameters?.decisium);
138
+ }
139
+ catch (err) {
140
+ console.error(err.message);
141
+ return 2;
142
+ }
143
+ // Values after the merge: the configuration may set what no flag did.
144
+ const record = env.DECISIUM_RECORD || "new";
145
+ if (!RECORD_MODES.includes(record)) {
146
+ console.error(`--record must be one of ${RECORD_MODES.join(", ")}\n\n${USAGE}`);
147
+ return 2;
148
+ }
149
+ const missingUrl = scenarios.find(s => !(env.DECISIUM_URL || s.url));
150
+ if (missingUrl) {
151
+ console.error(`No start URL for "${missingUrl.name}": pass --url or add an @url:<url> tag.\n\n${USAGE}`);
152
+ return 2;
153
+ }
154
+ if (!env.TYPESAFE_API_KEY && (cmd === "step" || record !== "none" || env.DECISIUM_JUDGED === "1")) {
155
+ console.error("TYPESAFE_API_KEY is not set.");
156
+ return 2;
157
+ }
158
+ // Opt-in: features sharing one stateful app (campaign-manager) break each other's state when run side by side.
159
+ // Workers only pay off across features (a feature's scenarios share its cassette and run in sequence).
160
+ if (values.parallel !== undefined)
161
+ runConfiguration.runtime.parallel = files.length < 2 ? 0
162
+ : parallelSet === -1 ? Math.min(availableParallelism(), record === "none" ? Infinity : 4) : parallelSet;
163
+ const run = new RunDir(); // shared by every worker
164
+ Object.assign(env, { DECISIUM_STEP: cmd === "step" ? "1" : "", DECISIUM_RUN_DIR: run.path });
165
+ console.log(`Model: ${MODEL}\nRun directory: ${run.path}`);
166
+ const { success } = await runCucumber(runConfiguration);
167
+ run.done();
168
+ console.log(`\nRun directory: ${run.path} (log.txt has the full decision log)`);
169
+ // steps.ts marks an infrastructure error in the run directory (it may have happened in a worker).
170
+ return process.exitCode === 2 || existsSync(join(run.path, INFRA_MARKER)) ? 2 : success ? 0 : 1;
171
+ }
172
+ // No process.exit on success paths: a reporter (e.g. Qase's) may finish uploading after cucumber returns.
173
+ main().then(code => { process.exitCode = code; }, err => {
174
+ console.error(process.env.DECISIUM_DEBUG ? err : `\nError: ${err.message.split("\n")[0]}\nRerun with --debug for details.`);
175
+ process.exitCode = 2;
176
+ });
@@ -0,0 +1,124 @@
1
+ import type { Element, Observation } from "./observation.ts";
2
+ export interface Pick {
3
+ choice: string;
4
+ probabilities: Record<string, number>;
5
+ }
6
+ export interface TurnAnswer {
7
+ done: number;
8
+ target: Pick;
9
+ /** How to use the target, an ACTIONS key or `press <key>` (task 0065); only the target's capabilities count. Absent: its first. */
10
+ action?: Pick;
11
+ }
12
+ /** The page itself as a target (task 0065): scrolling it, history, tabs, keys, waiting. */
13
+ export declare const PAGE = "page";
14
+ /** Everything a target can be used for (task 0065, ADR 0005); capabilities() picks a target's own. */
15
+ export declare const ACTIONS: {
16
+ readonly click: "Click it.";
17
+ readonly type: "Type a value from `step` into it (an empty value clears it).";
18
+ readonly select: "Select an option in it.";
19
+ readonly upload: "Click it to choose a file to upload, one `step` names.";
20
+ readonly paste: "Paste the copied text into it.";
21
+ readonly hover: "Only hover over it with the mouse pointer (e.g. to open a menu or show a tooltip).";
22
+ readonly "double click": "Double-click it (e.g. to edit it inline).";
23
+ readonly "right click": "Right-click it (e.g. to open its context menu).";
24
+ readonly drag: "Drag it with the mouse and drop it onto another element (e.g. to reorder it or move it to another list).";
25
+ readonly "scroll down": "Scroll it down: show or load more of its content further down (the page: the needed element may load further down).";
26
+ readonly "scroll up": "Scroll it up: show or load more of its content further up.";
27
+ readonly wait: "Wait: the page is still loading what is needed.";
28
+ readonly back: "Go back to the previous page in the browser history.";
29
+ readonly forward: "Go forward in the browser history.";
30
+ readonly reload: "Reload the page.";
31
+ readonly "close tab": "Close this browser tab and return to the previous one.";
32
+ readonly "switch tab": "Switch to the previous browser tab, keeping this one open.";
33
+ readonly enter: "Press Enter in it, to submit what was typed (e.g. run a search).";
34
+ };
35
+ /**
36
+ * What `target` can be used for. Elements: their role's verb (a typeable field also takes paste, a clickable one an
37
+ * upload), click-like gestures on every target (no DOM evidence for them, task 0064), drag/scroll only with the fact.
38
+ * The page: scroll, wait, history; tabs while more than one is open; keys the Step names (`keys`, task 0039). Enter:
39
+ * the focused text field's.
40
+ */
41
+ export declare function capabilities(target: Element | typeof PAGE, obs: Observation, keys?: string[]): string[];
42
+ /** Choice text of an action: ACTIONS, or a key the Step names. */
43
+ export declare const describeAction: (a: string) => "Click it to choose a file to upload, one `step` names." | "Click it." | "Close this browser tab and return to the previous one." | "Double-click it (e.g. to edit it inline)." | "Drag it with the mouse and drop it onto another element (e.g. to reorder it or move it to another list)." | "Go back to the previous page in the browser history." | "Go forward in the browser history." | "Only hover over it with the mouse pointer (e.g. to open a menu or show a tooltip)." | "Paste the copied text into it." | "Press Enter in it, to submit what was typed (e.g. run a search)." | "Reload the page." | "Right-click it (e.g. to open its context menu)." | "Scroll it down: show or load more of its content further down (the page: the needed element may load further down)." | "Scroll it up: show or load more of its content further up." | "Select an option in it." | "Switch to the previous browser tab, keeping this one open." | "Type a value from `step` into it (an empty value clears it)." | "Wait: the page is still loading what is needed.";
44
+ /**
45
+ * Equivalent targets sum their confidence before the gate (task 0065, 0057): links with the same name and URL lead to
46
+ * the same page (Wikipedia's two `Deutsch` links, 42% / 38%). The first keeps the sum. Buttons never: their handlers
47
+ * may differ.
48
+ */
49
+ export declare function grouped(p: Pick, obs: Observation): Pick;
50
+ export declare const ASSERTION_KINDS: {
51
+ readonly value: "Checks that some element shows a specific text or value.";
52
+ readonly presence: "Checks that something is shown or exists on the page.";
53
+ readonly absence: "Checks that something is not shown or does not exist on the page.";
54
+ readonly state: "Checks whether a control is disabled, enabled, checked, expanded, selected or pressed.";
55
+ readonly fuzzy: "A subjective or holistic check that none of the other kinds can express.";
56
+ };
57
+ /** State labels Jev can pick; code reads the matching snapshot flag. */
58
+ export declare const STATES: {
59
+ readonly disabled: "disabled";
60
+ readonly enabled: "enabled (not disabled)";
61
+ readonly checked: "checked";
62
+ readonly unchecked: "not checked";
63
+ readonly expanded: "expanded / open";
64
+ readonly collapsed: "collapsed / closed";
65
+ readonly selected: "selected";
66
+ readonly unselected: "not selected";
67
+ readonly pressed: "pressed / toggled on";
68
+ readonly unpressed: "not pressed / toggled off";
69
+ };
70
+ export interface Assessment {
71
+ kind: Pick;
72
+ subject: Pick;
73
+ area: Pick;
74
+ state: Pick;
75
+ judged?: number;
76
+ }
77
+ export declare const AREA_PAGE = "page";
78
+ export declare const AREA_MISSING = "missing";
79
+ /** A Jev answer for people: element refs replaced by the element, top 3 as percentages. */
80
+ export declare const named: (p: Pick, obs?: Observation) => string;
81
+ export declare const fmt: (p: Pick) => string;
82
+ /** The seam to the decision model: Jev implements it (jev.ts); tests use a scripted fake. */
83
+ export interface Decider {
84
+ /** `recent`: this Step's actions so far, oldest first, e.g. `CLICK combobox "Activation period"`. */
85
+ /** `attempt`: done means the user performed the actions, whether or not the app accepted them. */
86
+ /** `keys`: the Named keys the Step's text names (readStep), offered as the page's `press <key>` actions. */
87
+ turn(step: string, obs: Observation, recent: string[], attempt?: boolean, keys?: string[]): Promise<TurnAnswer>;
88
+ value(step: string, obs: Observation, el: Element, candidates: string[]): Promise<Pick>;
89
+ /** Drag: the readable element or area to drop `source` onto, or "none" (task 0045). */
90
+ drop(step: string, obs: Observation, source: Element): Promise<Pick>;
91
+ /** Assertion steps: what kind of check, and which element it is about. Code decides pass/fail. */
92
+ assess(step: string, obs: Observation, judged: boolean): Promise<Assessment>;
93
+ /** Memory: which value from an earlier Step (`earlier`: value -> Step it came from) the check refers to, or "none". */
94
+ recall(step: string, earlier: Record<string, string>): Promise<Pick>;
95
+ /** The open browser dialog (`obs.dialog`): "accept", "dismiss", or for a prompt one of the Step's quoted values. */
96
+ dialog(step: string, obs: Observation, answers: string[]): Promise<Pick>;
97
+ }
98
+ /**
99
+ * Thrown by a Decider that needs a later Observation (a replayed control not on a still-loading page): the runner
100
+ * waits for the page to change and asks again, and throws `final` once ASSERT_TIMEOUT_MS has passed.
101
+ */
102
+ export declare class NotYet extends Error {
103
+ final: Error;
104
+ constructor(final: Error);
105
+ }
106
+ /** Confidence gate: act only on a clear, confident top answer. */
107
+ export declare const GATE_TOP = 0.8;
108
+ export declare const GATE_MARGIN = 0.3;
109
+ /**
110
+ * Which element to act on is gated lower than a check (task 0002): a wrong action fails a later Step loudly,
111
+ * while a wrong check can pass falsely. Fitted on 26 labelled AMBIGUOUS Turns from the real-site corpus, where
112
+ * every top answer naming an element was the right one down to 0.47; below 0.1 margin the two answers are a coin flip.
113
+ */
114
+ export declare const GATE_TARGET_TOP = 0.5;
115
+ export declare const GATE_TARGET_MARGIN = 0.1;
116
+ export declare function gate(pick: Pick, minTop?: number, minMargin?: number): boolean;
117
+ /** Most likely answers first, until together they cover the gate's confidence (cassette.ts stores exactly these). */
118
+ export declare function plausible(pick: Pick): string[];
119
+ /**
120
+ * `d` with each answer written as a trace event named after its question (turn, value, assess...): the Step (every
121
+ * question's first argument) and the answer. A question answering plainly needs no code here; a value chosen for a
122
+ * Secret field is masked, and a dialog answer carries the dialog. Only Decider questions are reached through it.
123
+ */
124
+ export declare function traced(d: Decider, event: (type: string, data: Record<string, unknown>) => void): Decider;