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/steps.js ADDED
@@ -0,0 +1,205 @@
1
+ // cucumber-js support code (ADR 0003): one catch-all step definition runs every Step through Jev; hooks give each
2
+ // scenario a fresh browser, its run-directory folder, the feature's cassette and an empty memory.
3
+ // Options come from worldParameters.decisium and DECISIUM_* env (src/options.ts), so raw `npx cucumber-js` works too.
4
+ import { After, AfterAll, AfterStep, Before, BeforeAll, BeforeStep, defineStep, formatterHelpers, setDefaultTimeout, setParallelCanAssign, setWorldConstructor, Status, supportCodeLibraryBuilder, World } from "@cucumber/cucumber";
5
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { Browser } from "./browser.js";
8
+ import { Cassette, RECORD_MODES, stepKey } from "./cassette.js";
9
+ import { executeStep } from "./execute.js";
10
+ import { contextFromTags, fromPickle, tagValue, toUrl, urlFromTags } from "./feature.js";
11
+ import { JevDecider, MODEL } from "./jev.js";
12
+ import { MARGINAL, NO_TRACE, outcomeMessage } from "./report.js";
13
+ import { placeholder, readStep } from "./step.js";
14
+ import { INFRA_MARKER, RunDir } from "./trace.js";
15
+ import { applyOptions } from "./options.js";
16
+ const env = process.env;
17
+ const opt = (name) => env[`DECISIUM_${name}`] === "1";
18
+ let record; // set in BeforeAll, once worldParameters are known
19
+ // Jev steps take seconds per Turn; the runner has its own caps (8 Turns, 5 s waits).
20
+ setDefaultTimeout(10 * 60_000);
21
+ // Parallel workers (`--parallel`) are separate processes: the CLI creates the run directory (DECISIUM_RUN_DIR) and an
22
+ // infrastructure error is signalled through a marker file in it, read by the CLI and by the other workers.
23
+ let run;
24
+ const cliOwnsRun = !!env.DECISIUM_RUN_DIR; // the CLI prints the run directory once, not each worker
25
+ const broken = () => !!run && existsSync(join(run.path, INFRA_MARKER)); // after one, the remaining scenarios are skipped
26
+ const deciders = new Map(); // per feature file
27
+ class Scenario extends World {
28
+ browser;
29
+ rt;
30
+ cassette;
31
+ live;
32
+ key = ""; // the running Step in its cassette: scenario, position, text
33
+ dir = "";
34
+ kind = "action";
35
+ arg = {}; // the running Step's data table or doc string
36
+ at = ""; // file:line of the running Step, in its trace event (scripts/cookbook.ts)
37
+ earlier = {}; // memory: quoted value -> the Step that named it
38
+ /** Public API for client step definitions (task 0048): the current tab (a popup becomes it) and its Playwright context. */
39
+ get page() { return this.browser.page; }
40
+ get context() { return this.browser.context; }
41
+ }
42
+ setWorldConstructor(Scenario);
43
+ // A feature's scenarios run one after another (they share its cassette); different features run side by side.
44
+ setParallelCanAssign((pickle, running) => !running.some(p => p.uri === pickle.uri));
45
+ BeforeAll(function () {
46
+ if (existsSync(".env"))
47
+ process.loadEnvFile(".env"); // TYPESAFE_API_KEY; never logged
48
+ applyOptions(this.parameters.decisium);
49
+ record = (env.DECISIUM_RECORD || "new");
50
+ if (!RECORD_MODES.includes(record))
51
+ throw new Error(`DECISIUM_RECORD must be one of ${RECORD_MODES.join(", ")}`);
52
+ if (!env.TYPESAFE_API_KEY && (record !== "none" || opt("STEP") || opt("JUDGED")))
53
+ throw new Error("TYPESAFE_API_KEY is not set.");
54
+ run = new RunDir("runs", env.DECISIUM_RUN_DIR || undefined);
55
+ if (!cliOwnsRun)
56
+ console.log(`Model: ${MODEL}\nRun directory: ${run.path}`);
57
+ });
58
+ Before(async function ({ pickle }) {
59
+ if (broken())
60
+ return "skipped";
61
+ const url = env.DECISIUM_URL || urlFromTags(pickle.tags);
62
+ if (!url)
63
+ throw infra(new Error(`No start URL for "${pickle.name}": pass --url or add an @url:<url> tag.`));
64
+ try {
65
+ if (!deciders.has(pickle.uri)) { // a corrupt cassette is a setup error, not a failed scenario
66
+ const cassette = opt("STEP") ? undefined : new Cassette(pickle.uri.replace(/\.feature$/, "") + ".cassette.json", MODEL, record, run.path);
67
+ deciders.set(pickle.uri, { cassette, live: new JevDecider() });
68
+ }
69
+ }
70
+ catch (err) {
71
+ throw infra(err);
72
+ }
73
+ ({ cassette: this.cassette, live: this.live } = deciders.get(pickle.uri));
74
+ // Secret placeholders ("$NAME"): a missing variable is a setup error, not a failed Step (task 0030).
75
+ const secrets = {};
76
+ for (const token of pickle.steps.flatMap(s => readStep(fromPickle(s)).placeholders)) {
77
+ const name = placeholder(token);
78
+ if (!env[name])
79
+ throw infra(new Error(`"${token}" in "${pickle.name}": environment variable ${name} is not set (or empty).`));
80
+ secrets[token] = env[name];
81
+ }
82
+ run.log(`\n=== Scenario: ${pickle.name} (${url})`);
83
+ this.dir = run.scenario(pickle.name);
84
+ const glob = tagValue(pickle.tags, "replay-network");
85
+ // One recording per scenario: replay matches the n-th call to a URL. Outline rows share a name, so the last row's wins.
86
+ const har = `${pickle.uri.replace(/\.feature$/, "")}.network/${pickle.name.replace(/[^\w-]+/g, "_")}.har`;
87
+ // Follows --record: all re-records, none replays, new replays an existing recording and records a missing one.
88
+ const network = glob ? { glob, har, mode: record === "all" || (record === "new" && !existsSync(har)) ? "record" : "replay" } : undefined;
89
+ try {
90
+ this.browser = await Browser.open(toUrl(url), opt("HEADED"), true, secrets, dirname(pickle.uri), this.dir, // fresh browser per scenario: no shared state
91
+ contextFromTags(pickle.tags), network);
92
+ }
93
+ catch (err) {
94
+ throw infra(err);
95
+ }
96
+ this.rt = run.runtime(this.browser);
97
+ networkMissed(this.browser);
98
+ });
99
+ /** Fails the Step (or the page load) that made a `@replay-network` request the recording does not have. */
100
+ function networkMissed(browser) {
101
+ const missed = browser?.networkMisses.splice(0);
102
+ if (missed?.length) {
103
+ const err = new Error(`network not in recording: ${missed.join(", ")} (re-record with --record all)`);
104
+ err.stack = err.message;
105
+ throw err;
106
+ }
107
+ }
108
+ AfterStep(function () { networkMissed(this.browser); });
109
+ BeforeStep(function ({ pickle, pickleStep, gherkinDocument }) {
110
+ const line = formatterHelpers.GherkinDocumentParser.getGherkinStepMap(gherkinDocument)[pickleStep.astNodeIds[0]]?.location.line;
111
+ this.at = `${pickle.uri}:${line}`;
112
+ const { kind, text: _, ...arg } = fromPickle(pickleStep);
113
+ this.kind = kind;
114
+ this.arg = arg;
115
+ const labels = pickle.steps.map(s => readStep(fromPickle(s)).label);
116
+ this.key = stepKey(pickle.name, labels, pickle.steps.findIndex(s => s.id === pickleStep.id));
117
+ Object.defineProperty(step, "length", { value: pickleStep.argument ? 2 : 1 });
118
+ });
119
+ // cucumber-js passes a data table or doc string as one more argument and checks the function's arity against it;
120
+ // the argument is read from the pickle step in BeforeStep instead, so the arity follows the Step (set there).
121
+ async function step(text) {
122
+ const log = (line) => { run.log(line); if (opt("VERBOSE"))
123
+ console.log(line); };
124
+ let r;
125
+ try {
126
+ r = await executeStep(text, { rt: this.rt, live: this.live, cassette: this.cassette, key: this.key, kind: this.kind, arg: this.arg,
127
+ earlier: this.earlier, log, trace: d => run.decider(d), judged: opt("JUDGED"), strict: opt("STRICT") });
128
+ }
129
+ catch (err) {
130
+ throw infra(err);
131
+ }
132
+ run.event("step", { kind: this.kind, step: readStep({ text, kind: this.kind, ...this.arg }).label, at: this.at, ...r });
133
+ if (r.outcome === "JUDGED_PASSED")
134
+ this.log(`JUDGED_PASSED: judged by Jev (${r.reason})`);
135
+ // A file attachment any reporter keeps (text/plain opens as .txt); the console formatter reads it by its prefix.
136
+ if (r.marginal)
137
+ this.attach(`${MARGINAL}${r.marginal}`, { mediaType: "text/plain", fileName: "marginal-pass.txt" });
138
+ if (!["DONE", "PASSED", "JUDGED_PASSED"].includes(r.outcome)) {
139
+ // Failed Step only; fileName carries the path to the console formatter.
140
+ const shot = run.lastScreenshot;
141
+ if (shot && existsSync(shot))
142
+ this.attach(readFileSync(shot), { mediaType: "image/png", fileName: shot });
143
+ const err = new Error(outcomeMessage(r));
144
+ err.stack = err.message; // no stack frames: reporters such as Qase's read keywords in them
145
+ throw err;
146
+ }
147
+ }
148
+ const CATCH_ALL = /^(.*)$/;
149
+ defineStep(CATCH_ALL, step);
150
+ // Client step definitions (task 0048): a line another definition matches runs that one, not Jev. Without this cucumber
151
+ // reports it Ambiguous. Relies on cucumber internals (finalize, expression.match); "client steps" in test/decisium.test.ts
152
+ // fails when they change.
153
+ const finalize = supportCodeLibraryBuilder.finalize.bind(supportCodeLibraryBuilder);
154
+ supportCodeLibraryBuilder.finalize = canonicalIds => {
155
+ const lib = finalize(canonicalIds);
156
+ const ours = lib.stepDefinitions.find(d => d.pattern === CATCH_ALL);
157
+ const others = lib.stepDefinitions.filter(d => d !== ours);
158
+ const match = ours.expression.match.bind(ours.expression);
159
+ ours.expression.match = text => (others.some(d => d.matchesStepName(text)) ? null : match(text));
160
+ return lib;
161
+ };
162
+ After(async function ({ result }) {
163
+ run.flush(); // the scenario's whole log in one write
164
+ if (!this.browser)
165
+ return;
166
+ const ok = result?.status === Status.PASSED;
167
+ if (!ok)
168
+ run.failure(this.browser.consoleErrors);
169
+ // A passed scenario keeps its trace events but not its screenshots: a screenshot per Turn filled runs/ (2 GB).
170
+ else
171
+ for (const f of readdirSync(this.dir))
172
+ if (f.endsWith(".png"))
173
+ rmSync(join(this.dir, f));
174
+ if (!ok && this.browser.typedSecret)
175
+ writeFileSync(join(this.dir, NO_TRACE), "The scenario typed a secret value; Playwright traces store typed text unmasked, so none is kept.\n");
176
+ await this.browser.close(ok ? undefined : `${this.dir}/playwright-trace.zip`);
177
+ run.event("result", { passed: ok });
178
+ for (const { cassette } of deciders.values())
179
+ cassette?.save(); // per scenario, so a crash keeps what was recorded
180
+ run.flush();
181
+ });
182
+ AfterAll(function () {
183
+ if (!run)
184
+ return;
185
+ run.flush(); // a scenario skipped or broken before its browser opened
186
+ if (!cliOwnsRun)
187
+ run.done();
188
+ if (!cliOwnsRun)
189
+ console.log(`\nRun directory: ${run.path} (log.txt has the full decision log)`);
190
+ for (const { cassette: c } of deciders.values())
191
+ if (c)
192
+ console.log(`Cassette ${c.path}: ${c.replayed} replayed, ${c.recorded} recorded, ${c.healed} healed`);
193
+ });
194
+ /** Infrastructure error (app not running, bad API key, network): one clear line, `--debug` for the stack. Exit code 2. */
195
+ function infra(err) {
196
+ process.exitCode = 2;
197
+ if (run)
198
+ writeFileSync(join(run.path, INFRA_MARKER), String(err?.message ?? err)); // read by `decisium run`
199
+ if (opt("DEBUG"))
200
+ return err;
201
+ const e = err;
202
+ const out = new Error(`${e.message.split("\n")[0]}${e.status === 401 ? " (check TYPESAFE_API_KEY in .env)" : ""}\nRerun with --debug for details.`);
203
+ out.stack = out.message;
204
+ return out;
205
+ }
@@ -0,0 +1,28 @@
1
+ import type { Browser } from "./browser.ts";
2
+ import { type Decider } from "./decision.ts";
3
+ import type { Runtime } from "./runner.ts";
4
+ /** File in the run directory marking an infrastructure error (exit 2), written by any worker, read by the CLI. */
5
+ export declare const INFRA_MARKER = "infrastructure-error";
6
+ /** runs/<timestamp>/: trace.jsonl for the whole run, one folder per scenario with a screenshot per Turn. */
7
+ export declare class RunDir {
8
+ #private;
9
+ readonly path: string;
10
+ /** `existing`: a run directory the CLI created, shared by parallel workers. */
11
+ constructor(root?: string, existing?: string);
12
+ /** Full verbose log of the run (what -v prints). Buffered per scenario so parallel workers do not interleave lines. */
13
+ log(line: string): void;
14
+ /** Appends the buffered scenario log to log.txt in one write. Call at the end of every scenario. */
15
+ flush(): void;
16
+ /** End of the run: drop the folder-number claims. */
17
+ done(): void;
18
+ /** Last screenshot of the current scenario, for failure reports. */
19
+ get lastScreenshot(): string | undefined;
20
+ event(type: string, data?: Record<string, unknown>): void;
21
+ scenario(name: string): string;
22
+ /** Every Observation is a Turn: screenshot it and log the Observation text. */
23
+ runtime(b: Browser): Runtime;
24
+ /** Jev's answers with their probabilities, one event per question. Values chosen for secret fields are masked. */
25
+ decider(d: Decider): Decider;
26
+ /** On failure: console errors and the last page text next to the screenshots (the Playwright trace is saved by Browser.close). */
27
+ failure(errors: string[]): void;
28
+ }
package/dist/trace.js ADDED
@@ -0,0 +1,112 @@
1
+ import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { traced } from "./decision.js";
4
+ /** File in the run directory marking an infrastructure error (exit 2), written by any worker, read by the CLI. */
5
+ export const INFRA_MARKER = "infrastructure-error";
6
+ /** runs/<timestamp>/: trace.jsonl for the whole run, one folder per scenario with a screenshot per Turn. */
7
+ export class RunDir {
8
+ path;
9
+ #scenario = "";
10
+ #dir = "";
11
+ #shot = 0;
12
+ #ui = ""; // the scenario's last Observation, written next to the screenshots on failure
13
+ /** `existing`: a run directory the CLI created, shared by parallel workers. */
14
+ constructor(root = "runs", existing) {
15
+ if (existing) {
16
+ mkdirSync(existing, { recursive: true });
17
+ this.path = existing;
18
+ return;
19
+ }
20
+ mkdirSync(root, { recursive: true });
21
+ const base = join(root, new Date().toISOString().replace(/[:.]/g, "-"));
22
+ // Parallel runs can start in the same millisecond: mkdir without `recursive` fails if the directory exists.
23
+ for (let i = 1;; i++) {
24
+ const path = i === 1 ? base : `${base}-${i}`;
25
+ try {
26
+ mkdirSync(path);
27
+ this.path = path;
28
+ return;
29
+ }
30
+ catch (err) {
31
+ if (err.code !== "EEXIST")
32
+ throw err;
33
+ }
34
+ }
35
+ }
36
+ #lines = [];
37
+ /** Full verbose log of the run (what -v prints). Buffered per scenario so parallel workers do not interleave lines. */
38
+ log(line) {
39
+ this.#lines.push(line);
40
+ }
41
+ /** Appends the buffered scenario log to log.txt in one write. Call at the end of every scenario. */
42
+ flush() {
43
+ if (this.#lines.length)
44
+ appendFileSync(join(this.path, "log.txt"), this.#lines.join("\n") + "\n");
45
+ this.#lines = [];
46
+ }
47
+ /** End of the run: drop the folder-number claims. */
48
+ done() {
49
+ rmSync(join(this.path, ".claimed"), { recursive: true, force: true });
50
+ }
51
+ /** Last screenshot of the current scenario, for failure reports. */
52
+ get lastScreenshot() {
53
+ return this.#shot ? join(this.#dir, `${String(this.#shot).padStart(3, "0")}.png`) : undefined;
54
+ }
55
+ event(type, data = {}) {
56
+ appendFileSync(join(this.path, "trace.jsonl"), JSON.stringify({ t: new Date().toISOString(), scenario: this.#scenario, type, ...data }) + "\n");
57
+ }
58
+ scenario(name) {
59
+ this.#scenario = name;
60
+ this.#shot = 0;
61
+ this.#ui = "";
62
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
63
+ // Numbered in start order across parallel workers: claim a number with an exclusive mkdir, then name the folder.
64
+ mkdirSync(join(this.path, ".claimed"), { recursive: true });
65
+ let i = 1;
66
+ for (;; i++) {
67
+ try {
68
+ mkdirSync(join(this.path, ".claimed", String(i)));
69
+ break;
70
+ }
71
+ catch (err) {
72
+ if (err.code !== "EEXIST")
73
+ throw err;
74
+ }
75
+ }
76
+ this.#dir = join(this.path, `${String(i).padStart(2, "0")}-${slug}`);
77
+ mkdirSync(this.#dir, { recursive: true });
78
+ this.event("scenario");
79
+ return this.#dir;
80
+ }
81
+ /** Every Observation is a Turn: screenshot it and log the Observation text. */
82
+ runtime(b) {
83
+ const rt = {
84
+ observe: async () => {
85
+ const obs = await b.observe();
86
+ const screenshot = `${String(++this.#shot).padStart(3, "0")}.png`;
87
+ await b.screenshot(join(this.#dir, screenshot));
88
+ this.event("observe", { screenshot, ui: obs.text });
89
+ this.#ui = obs.text;
90
+ return obs;
91
+ },
92
+ execute: async (a) => {
93
+ this.event("execute", { ...a, value: a.value === undefined ? undefined : "(see log; may be secret)" });
94
+ await b.execute(a);
95
+ },
96
+ waitForChange: (since, ms) => b.waitForChange(since, ms),
97
+ stale: () => b.stale(),
98
+ actionStep: (run, log) => b.actionStep(run, log, rt.execute),
99
+ };
100
+ return rt;
101
+ }
102
+ /** Jev's answers with their probabilities, one event per question. Values chosen for secret fields are masked. */
103
+ decider(d) {
104
+ return traced(d, (type, data) => this.event(type, data));
105
+ }
106
+ /** On failure: console errors and the last page text next to the screenshots (the Playwright trace is saved by Browser.close). */
107
+ failure(errors) {
108
+ writeFileSync(join(this.#dir, "console-errors.txt"), errors.length ? errors.join("\n") + "\n" : "(none)\n");
109
+ if (this.#ui)
110
+ writeFileSync(join(this.#dir, "ui.txt"), this.#ui + "\n");
111
+ }
112
+ }
@@ -0,0 +1,53 @@
1
+ # Configuration and reporters
2
+
3
+ `decisium run` loads configuration exactly like cucumber-js:
4
+ your `cucumber.{js,mjs,cjs,json,yaml}`, `-p <profile>`, and cucumber's `--format name[:path]` and `--import <file>`
5
+ flags, added to the file's values. Decisium adds its own step definitions to `import`.
6
+
7
+ ## Decisium options
8
+
9
+ In `worldParameters.decisium`, so raw `npx cucumber-js` reads them too. Flags (`--url`, `--record`, `--strict`, ...)
10
+ and `DECISIUM_*` env (`DECISIUM_URL`, `DECISIUM_RECORD`, `DECISIUM_STRICT=1`, ...) override them.
11
+
12
+ ```js
13
+ // cucumber.mjs
14
+ export default {
15
+ worldParameters: { decisium: { record: "none", strict: true } },
16
+ // url, record, headed, judged, strict, verbose, debug, agentReport
17
+ };
18
+ ```
19
+
20
+ ## Reporters
21
+
22
+ Any cucumber formatter: cucumber's own `junit`, `json`, `html`, `message`, or a package such as the official Qase
23
+ reporter. Only one formatter may write to stdout: one configured without a path takes it; otherwise Decisium's console
24
+ output does.
25
+
26
+ ```sh
27
+ decisium run features/login.feature --format junit:reports/junit.xml
28
+ ```
29
+
30
+ ### Qase
31
+
32
+ Decisium ships nothing Qase-specific; wire the official reporter as in any cucumber project.
33
+
34
+ ```sh
35
+ npm install --save-dev cucumberjs-qase-reporter
36
+ echo '{"mode":"report"}' > qase.config.json # or mode "testops" with QASE_TESTOPS_API_TOKEN, QASE_TESTOPS_PROJECT
37
+ decisium run features/login.feature --format cucumberjs-qase-reporter:/dev/null
38
+ ```
39
+
40
+ Or in your configuration, which raw `npx cucumber-js` uses too:
41
+
42
+ ```js
43
+ // cucumber.mjs
44
+ export default {
45
+ // Raw cucumber-js only: Decisium's steps and formatter (decisium run adds both itself).
46
+ import: ["node_modules/decisium/dist/steps.js"],
47
+ format: ["decisium/formatter", "cucumberjs-qase-reporter:/dev/null"],
48
+ };
49
+ ```
50
+
51
+ Tag scenarios `@QaseID=<id>`. A failing Step's message starts with its outcome; app failures (`ASSERTION_FAILED`,
52
+ `JUDGED_FAILED`, `BLOCKED`, `ACTION_FAILED`) report as `failed`, every other outcome as `invalid`. A marginal pass
53
+ reaches every reporter as a `text/plain` attachment `marginal-pass.txt` on its Step (Qase stores it as `file.txt`).