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.
@@ -0,0 +1,143 @@
1
+ /** The page itself as a target (task 0065): scrolling it, history, tabs, keys, waiting. */
2
+ export const PAGE = "page";
3
+ /** Everything a target can be used for (task 0065, ADR 0005); capabilities() picks a target's own. */
4
+ export const ACTIONS = {
5
+ click: "Click it.",
6
+ type: "Type a value from `step` into it (an empty value clears it).",
7
+ select: "Select an option in it.",
8
+ upload: "Click it to choose a file to upload, one `step` names.",
9
+ paste: "Paste the copied text into it.",
10
+ hover: "Only hover over it with the mouse pointer (e.g. to open a menu or show a tooltip).",
11
+ "double click": "Double-click it (e.g. to edit it inline).",
12
+ "right click": "Right-click it (e.g. to open its context menu).",
13
+ drag: "Drag it with the mouse and drop it onto another element (e.g. to reorder it or move it to another list).",
14
+ "scroll down": "Scroll it down: show or load more of its content further down (the page: the needed element may load further down).",
15
+ "scroll up": "Scroll it up: show or load more of its content further up.",
16
+ wait: "Wait: the page is still loading what is needed.",
17
+ back: "Go back to the previous page in the browser history.",
18
+ forward: "Go forward in the browser history.",
19
+ reload: "Reload the page.",
20
+ "close tab": "Close this browser tab and return to the previous one.",
21
+ "switch tab": "Switch to the previous browser tab, keeping this one open.",
22
+ enter: "Press Enter in it, to submit what was typed (e.g. run a search).",
23
+ };
24
+ /**
25
+ * What `target` can be used for. Elements: their role's verb (a typeable field also takes paste, a clickable one an
26
+ * upload), click-like gestures on every target (no DOM evidence for them, task 0064), drag/scroll only with the fact.
27
+ * The page: scroll, wait, history; tabs while more than one is open; keys the Step names (`keys`, task 0039). Enter:
28
+ * the focused text field's.
29
+ */
30
+ export function capabilities(target, obs, keys = []) {
31
+ if (target === PAGE)
32
+ return ["scroll down", "scroll up", "wait", "back", "forward", "reload",
33
+ ...obs.readable.some(e => e.ref === "tabs") ? ["close tab", "switch tab"] : [],
34
+ ...keys.filter(k => k !== "Enter").map(k => `press ${k}`)];
35
+ // Enter goes to the focused field: offered there, so the field is the target (as the page, Jev split it with the
36
+ // Search button: 0.47 / 0.29, AMBIGUOUS on Wikipedia).
37
+ const own = target.verb === "TYPE" ? ["type", "paste", ...(/\[active\]/.test(target.state) ? ["enter"] : [])]
38
+ : target.verb === "SELECT" ? ["select"] : ["click", "upload"];
39
+ return [...own, "hover", "double click", "right click",
40
+ ...(/\[draggable\]/.test(target.state) ? ["drag"] : []), ...(/\[scrollable\]/.test(target.state) ? ["scroll down", "scroll up"] : [])];
41
+ }
42
+ /** Choice text of an action: ACTIONS, or a key the Step names. */
43
+ export const describeAction = (a) => ACTIONS[a] ?? `Press the ${a.slice(6)} key, as \`step\` says.`;
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 function grouped(p, obs) {
50
+ const first = new Map();
51
+ const probabilities = {};
52
+ for (const [k, v] of Object.entries(p.probabilities)) {
53
+ const e = obs.elements.find(x => x.ref === k);
54
+ const id = e?.role === "link" && e.url ? `${e.name}|${e.url}` : undefined;
55
+ const into = id ? (first.get(id) ?? (first.set(id, k), k)) : k;
56
+ probabilities[into] = (probabilities[into] ?? 0) + v;
57
+ }
58
+ const choice = Object.entries(probabilities).sort((a, b) => b[1] - a[1])[0]?.[0] ?? p.choice;
59
+ return { choice, probabilities };
60
+ }
61
+ export const ASSERTION_KINDS = {
62
+ value: "Checks that some element shows a specific text or value.",
63
+ presence: "Checks that something is shown or exists on the page.",
64
+ absence: "Checks that something is not shown or does not exist on the page.",
65
+ state: "Checks whether a control is disabled, enabled, checked, expanded, selected or pressed.",
66
+ fuzzy: "A subjective or holistic check that none of the other kinds can express.",
67
+ };
68
+ /** State labels Jev can pick; code reads the matching snapshot flag. */
69
+ export const STATES = {
70
+ disabled: "disabled", enabled: "enabled (not disabled)",
71
+ checked: "checked", unchecked: "not checked",
72
+ expanded: "expanded / open", collapsed: "collapsed / closed",
73
+ selected: "selected", unselected: "not selected",
74
+ pressed: "pressed / toggled on", unpressed: "not pressed / toggled off",
75
+ };
76
+ export const AREA_PAGE = "page";
77
+ export const AREA_MISSING = "missing";
78
+ const SPECIAL = {
79
+ none: "nothing", [AREA_PAGE]: "anywhere on the page", [AREA_MISSING]: "an area not on this screen",
80
+ };
81
+ /** A Jev answer for people: element refs replaced by the element, top 3 as percentages. */
82
+ export const named = (p, obs) => Object.entries(p.probabilities).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => {
83
+ const el = obs && [...obs.readable, ...obs.areas, ...obs.elements].find(e => e.ref === k);
84
+ return `${el ? el.line : SPECIAL[k] ?? k} ${Math.round(v * 100)}%`;
85
+ }).join(", ");
86
+ export const fmt = (p) => Object.entries(p.probabilities).sort((a, b) => b[1] - a[1]).slice(0, 3)
87
+ .map(([k, v]) => `${k}=${v.toFixed(2)}`).join(" ");
88
+ /**
89
+ * Thrown by a Decider that needs a later Observation (a replayed control not on a still-loading page): the runner
90
+ * waits for the page to change and asks again, and throws `final` once ASSERT_TIMEOUT_MS has passed.
91
+ */
92
+ export class NotYet extends Error {
93
+ final;
94
+ constructor(final) { super(final.message); this.final = final; }
95
+ }
96
+ /** Confidence gate: act only on a clear, confident top answer. */
97
+ export const GATE_TOP = 0.8;
98
+ export const GATE_MARGIN = 0.3;
99
+ /**
100
+ * Which element to act on is gated lower than a check (task 0002): a wrong action fails a later Step loudly,
101
+ * while a wrong check can pass falsely. Fitted on 26 labelled AMBIGUOUS Turns from the real-site corpus, where
102
+ * 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.
103
+ */
104
+ export const GATE_TARGET_TOP = 0.5;
105
+ export const GATE_TARGET_MARGIN = 0.1;
106
+ export function gate(pick, minTop = GATE_TOP, minMargin = GATE_MARGIN) {
107
+ const [top = 0, second = 0] = Object.values(pick.probabilities).sort((a, b) => b - a);
108
+ return top >= minTop && top - second >= minMargin;
109
+ }
110
+ /** Most likely answers first, until together they cover the gate's confidence (cassette.ts stores exactly these). */
111
+ export function plausible(pick) {
112
+ const out = [];
113
+ let covered = 0;
114
+ for (const [k, p] of Object.entries(pick.probabilities).sort((x, y) => y[1] - x[1])) {
115
+ if (covered >= GATE_TOP)
116
+ break;
117
+ out.push(k);
118
+ covered += p;
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * `d` with each answer written as a trace event named after its question (turn, value, assess...): the Step (every
124
+ * question's first argument) and the answer. A question answering plainly needs no code here; a value chosen for a
125
+ * Secret field is masked, and a dialog answer carries the dialog. Only Decider questions are reached through it.
126
+ */
127
+ export function traced(d, event) {
128
+ return new Proxy(d, {
129
+ get(target, question) {
130
+ const ask = Reflect.get(target, question);
131
+ if (typeof ask !== "function")
132
+ return ask;
133
+ return async (...args) => {
134
+ const answer = await ask.apply(target, args);
135
+ const el = args[2]; // value(step, obs, el, candidates)
136
+ const extra = question === "value" ? { element: el.line, answer: el.secret ? "(secret)" : answer }
137
+ : question === "dialog" ? { dialog: args[1].dialog, answer } : { answer };
138
+ event(question, { step: args[0], ...extra });
139
+ return answer;
140
+ };
141
+ },
142
+ });
143
+ }
@@ -0,0 +1,3 @@
1
+ /** Node with type stripping on by default: the CLI runs .ts files directly. */
2
+ export declare const nodeOk: (version?: string) => boolean;
3
+ export declare function doctor(files: string[]): Promise<number>;
package/dist/doctor.js ADDED
@@ -0,0 +1,46 @@
1
+ // `decisium doctor`: the setup checks a CI job wants before the first run, one [OK]/[FAIL] line each.
2
+ import { noul, TypeSafeClient } from "@typesafe-ai/sdk";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { chromium } from "playwright";
5
+ import { Browser } from "./browser.js";
6
+ import { parseFeature, toUrl } from "./feature.js";
7
+ import { MODEL } from "./jev.js";
8
+ /** Node with type stripping on by default: the CLI runs .ts files directly. */
9
+ export const nodeOk = (version = process.versions.node) => {
10
+ const [major, minor] = version.split(".").map(Number);
11
+ return major > 23 || (major === 23 && minor >= 6) || (major === 22 && minor >= 18);
12
+ };
13
+ export async function doctor(files) {
14
+ let failed = false;
15
+ const line = (ok, what) => { failed ||= !ok; console.log(`[${ok ? "OK" : "FAIL"}] ${what}`); };
16
+ const attempt = async (what, fn) => {
17
+ try {
18
+ await fn();
19
+ line(true, what);
20
+ }
21
+ catch (err) {
22
+ line(false, `${what}: ${err.message.split("\n")[0]}`);
23
+ }
24
+ };
25
+ line(nodeOk(), `Node ${process.versions.node} (needs 22.18+ or 23.6+)`);
26
+ line(existsSync(chromium.executablePath()), `Chromium at ${chromium.executablePath()} (else: npx playwright install chromium)`);
27
+ if (!process.env.TYPESAFE_API_KEY)
28
+ line(false, "TYPESAFE_API_KEY is not set");
29
+ else
30
+ await attempt(`TYPESAFE_API_KEY accepted by Jev (${MODEL})`, () => new TypeSafeClient({ defaultModel: MODEL })
31
+ .systemOne({ state: { word: "yes" }, questions: { q: noul("Is `word` yes?") } }));
32
+ const scenarios = [];
33
+ for (const f of files) {
34
+ try {
35
+ scenarios.push(...parseFeature(readFileSync(f, "utf8"), f));
36
+ line(true, `${f} parses`);
37
+ }
38
+ catch (err) {
39
+ line(false, `${f}: ${err.message}`);
40
+ }
41
+ }
42
+ for (const url of new Set(scenarios.map(s => s.url).filter(u => u !== undefined))) {
43
+ await attempt(`opens ${url}`, async () => (await Browser.open(toUrl(url))).close());
44
+ }
45
+ return failed ? 2 : 0;
46
+ }
@@ -0,0 +1,34 @@
1
+ import { type Cassette } from "./cassette.ts";
2
+ import type { Decider } from "./decision.ts";
3
+ import { type Runtime, type StepResult } from "./runner.ts";
4
+ import { type StepArgument, type StepKind } from "./step.ts";
5
+ export interface StepContext {
6
+ rt: Runtime;
7
+ live: Decider;
8
+ cassette?: Cassette;
9
+ /** The Step's cassette key (stepKey); a table row adds ` (row n)`. */
10
+ key: string;
11
+ kind: StepKind;
12
+ arg: StepArgument;
13
+ /** Scenario memory: quoted value -> the Step that named it. This Step's values are added once it ends. */
14
+ earlier: Record<string, string>;
15
+ log: (line: string) => void;
16
+ /** Wraps each Decider the Step uses (the run's trace). */
17
+ trace?: (d: Decider) => Decider;
18
+ judged?: boolean;
19
+ /** A Marginal pass fails as AMBIGUOUS. */
20
+ strict?: boolean;
21
+ }
22
+ export interface StepReport extends StepResult {
23
+ /** How the Cassette served the Step; undefined without one, or when nothing was kept. */
24
+ cassette?: "replayed" | "recorded" | "healed";
25
+ seconds: number;
26
+ jevCalls: number;
27
+ inputTokens: number;
28
+ outputTokens: number;
29
+ }
30
+ /**
31
+ * Runs one Step: an Action step's Data table as one Step per row (the first that is not DONE stops it), each with its
32
+ * own Cassette entry. A page too large for Jev and a broken replay are Step outcomes; any other error is thrown (setup).
33
+ */
34
+ export declare function executeStep(text: string, ctx: StepContext): Promise<StepReport>;
@@ -0,0 +1,61 @@
1
+ // Step execution (task 0051): one Gherkin Step from text to Step outcome, without cucumber. steps.ts adapts it.
2
+ import { UiChanged } from "./cassette.js";
3
+ import { jevUsage } from "./jev.js";
4
+ import { runAssertion, runStep } from "./runner.js";
5
+ import { readStep } from "./step.js";
6
+ /**
7
+ * Runs one Step: an Action step's Data table as one Step per row (the first that is not DONE stops it), each with its
8
+ * own Cassette entry. A page too large for Jev and a broken replay are Step outcomes; any other error is thrown (setup).
9
+ */
10
+ export async function executeStep(text, ctx) {
11
+ const { rt, live, cassette, key, kind, arg, earlier, log, trace = d => d } = ctx;
12
+ const start = Date.now(), before = { ...jevUsage };
13
+ const { table, docString } = arg;
14
+ const perRow = !!table && kind !== "assertion";
15
+ const { rows } = readStep({ text, kind, ...arg });
16
+ // A header row alone (an action table with 3+ columns) leaves no row to act on; otherwise the rows decide.
17
+ let r = rows.length ? { outcome: "DONE" }
18
+ : { outcome: "AMBIGUOUS", reason: "the table has only its header row, so there is no row to act on" };
19
+ const served = [];
20
+ for (const [i, row] of rows.entries()) {
21
+ const entry = cassette?.step(perRow ? `${key} (row ${i + 1})` : key, live, log);
22
+ const decider = trace(entry ?? live);
23
+ try {
24
+ r = kind === "assertion"
25
+ ? await runAssertion(text, rt, decider, log, !!ctx.judged, { ...earlier }, arg)
26
+ : await runStep(row, rt, decider, log, kind === "attempt", docString);
27
+ }
28
+ catch (err) {
29
+ if (/max_tokens_exceeded/.test(String(err?.message))) {
30
+ // The page, not the setup: fail this Step and let the rest of the feature run (task 0021).
31
+ log(` => PAGE_TOO_LARGE`);
32
+ r = { outcome: "PAGE_TOO_LARGE", reason: "the page is too large for one Jev request, even reduced to the sections that matter" };
33
+ }
34
+ else if (err instanceof UiChanged) {
35
+ log(` => UI_CHANGED`);
36
+ r = { outcome: "UI_CHANGED", reason: err.reason };
37
+ }
38
+ else
39
+ throw err;
40
+ }
41
+ served.push(entry?.done(!["UI_CHANGED", "PAGE_TOO_LARGE"].includes(r.outcome)));
42
+ if (r.outcome !== "DONE" && perRow)
43
+ r = { ...r, reason: `row ${i + 1} (${row})${r.reason ? `: ${r.reason}` : ""}` };
44
+ if (r.outcome !== "DONE")
45
+ break;
46
+ }
47
+ if (r.marginal && ctx.strict)
48
+ r = { outcome: "AMBIGUOUS", reason: `passed, but Jev was barely confident (--strict): ${r.marginal}` };
49
+ for (const row of rows)
50
+ for (const v of readStep({ text: row, kind }).quoted)
51
+ earlier[v] = row;
52
+ // Cost and time per Step: live Jev calls and tokens (cassette replays cost 0), wall-clock seconds.
53
+ return {
54
+ ...r,
55
+ cassette: served.find(c => c !== "replayed") ?? served[0],
56
+ seconds: (Date.now() - start) / 1000,
57
+ jevCalls: jevUsage.calls - before.calls,
58
+ inputTokens: jevUsage.inputTokens - before.inputTokens,
59
+ outputTokens: jevUsage.outputTokens - before.outputTokens,
60
+ };
61
+ }
@@ -0,0 +1,37 @@
1
+ import { type PickleStepArgument, type PickleStepType } from "@cucumber/messages";
2
+ import { type BrowserContextOptions } from "playwright";
3
+ import { type ParsedStep } from "./step.ts";
4
+ export interface Scenario {
5
+ name: string;
6
+ tags: string[];
7
+ url?: string;
8
+ steps: ParsedStep[];
9
+ }
10
+ /** Start URL from the last `@url:<url>` tag (pickle tags list feature tags first). */
11
+ export declare const urlFromTags: (tags: readonly {
12
+ name: string;
13
+ }[]) => string | undefined;
14
+ /** The value of the last `@<name>:<value>` tag. */
15
+ export declare const tagValue: (tags: readonly {
16
+ name: string;
17
+ }[], name: string) => string | undefined;
18
+ /**
19
+ * Browser context options from `@viewport:<w>x<h>` (or a Playwright device name, dashes for spaces: `@viewport:iPhone-13`),
20
+ * `@locale:<bcp47>`, `@timezone:<IANA id>`, `@geolocation:<lat>,<lon>` (task 0048). A malformed value throws.
21
+ */
22
+ export declare function contextFromTags(tags: readonly {
23
+ name: string;
24
+ }[]): BrowserContextOptions;
25
+ /** A pickle step as a Step: its text, kind and Data table or Doc string. */
26
+ export declare const fromPickle: (step: {
27
+ text: string;
28
+ type?: PickleStepType;
29
+ argument?: PickleStepArgument;
30
+ }) => ParsedStep;
31
+ /** Parses a `.feature` source. Throws on Gherkin syntax errors. */
32
+ export declare function parseFeature(source: string, uri?: string): Scenario[];
33
+ /** One Step given on the command line, e.g. `Then the status should be "Draft"`; no keyword means `When`. */
34
+ export declare function parseStep(line: string): ParsedStep;
35
+ /** A one-Step `.feature` source, so `decisium step` runs through the same runner as files. */
36
+ export declare function stepFeature(line: string): string;
37
+ export declare const toUrl: (u: string) => string;
@@ -0,0 +1,92 @@
1
+ // Gherkin `.feature` files -> runnable Scenarios. The official parser expands Background steps and Scenario
2
+ // Outline rows ("pickles"), so the runner only sees flat Step lists.
3
+ import { AstBuilder, compile, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
4
+ import { IdGenerator } from "@cucumber/messages";
5
+ import { resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { devices } from "playwright";
8
+ import { readStep, stepKind } from "./step.js";
9
+ /** Start URL from the last `@url:<url>` tag (pickle tags list feature tags first). */
10
+ export const urlFromTags = (tags) => tagValue(tags, "url");
11
+ /** The value of the last `@<name>:<value>` tag. */
12
+ export const tagValue = (tags, name) => tags.map(t => t.name.startsWith(`@${name}:`) ? t.name.slice(name.length + 2) : undefined).filter(Boolean).at(-1);
13
+ /**
14
+ * Browser context options from `@viewport:<w>x<h>` (or a Playwright device name, dashes for spaces: `@viewport:iPhone-13`),
15
+ * `@locale:<bcp47>`, `@timezone:<IANA id>`, `@geolocation:<lat>,<lon>` (task 0048). A malformed value throws.
16
+ */
17
+ export function contextFromTags(tags) {
18
+ const o = {};
19
+ const viewport = tagValue(tags, "viewport"), geo = tagValue(tags, "geolocation");
20
+ if (viewport) {
21
+ const size = /^(\d+)x(\d+)$/.exec(viewport);
22
+ const device = devices[viewport.replaceAll("-", " ")];
23
+ if (size)
24
+ o.viewport = { width: Number(size[1]), height: Number(size[2]) };
25
+ else if (device) {
26
+ const { defaultBrowserType: _, ...d } = device;
27
+ Object.assign(o, d);
28
+ }
29
+ else
30
+ throw new Error(`@viewport:${viewport}: expected <width>x<height> or a Playwright device name such as iPhone-13`);
31
+ }
32
+ if (geo) {
33
+ const m = /^(-?[\d.]+),(-?[\d.]+)$/.exec(geo);
34
+ if (!m)
35
+ throw new Error(`@geolocation:${geo}: expected <latitude>,<longitude>`);
36
+ o.geolocation = { latitude: Number(m[1]), longitude: Number(m[2]) };
37
+ o.permissions = ["geolocation"];
38
+ }
39
+ const locale = tagValue(tags, "locale"), timezoneId = tagValue(tags, "timezone");
40
+ if (locale)
41
+ o.locale = locale;
42
+ if (timezoneId)
43
+ o.timezoneId = timezoneId;
44
+ return o;
45
+ }
46
+ /** A pickle step's data table or doc string, only the one it has. */
47
+ const stepArgument = ({ argument }) => argument?.dataTable ? { table: argument.dataTable.rows.map(r => r.cells.map(c => c.value)) }
48
+ : argument?.docString ? { docString: argument.docString.content } : {};
49
+ /** A pickle step as a Step: its text, kind and Data table or Doc string. */
50
+ export const fromPickle = (step) => ({ text: step.text, kind: stepKind(step), ...stepArgument(step) });
51
+ /** Parses a `.feature` source. Throws on Gherkin syntax errors. */
52
+ export function parseFeature(source, uri = "test.feature") {
53
+ const newId = IdGenerator.uuid();
54
+ const doc = new Parser(new AstBuilder(newId), new GherkinClassicTokenMatcher()).parse(source);
55
+ const pickles = compile(doc, uri, newId);
56
+ // Gherkin reads keyword-less lines before the first step as a description, so a typo can leave no steps at all.
57
+ const empty = pickles.find(p => !p.steps.length);
58
+ if (empty)
59
+ throw new Error(`Scenario "${empty.name}" has no steps (lines without Given/When/Then/And/But are read as description)`);
60
+ if (!pickles.length)
61
+ throw new Error("No scenarios found");
62
+ const lines = new Map((doc.feature?.children ?? []).flatMap(c => c.rule ? c.rule.children : [c])
63
+ .flatMap(c => (c.background ?? c.scenario)?.steps ?? []).map(s => [s.id, s.location.line]));
64
+ for (const p of pickles)
65
+ for (const s of p.steps) {
66
+ const cell = readStep(fromPickle(s)).badCell;
67
+ if (cell !== undefined)
68
+ throw new Error(`Line ${lines.get(s.astNodeIds[0])}: an action's table cannot have ${cell.trim() ? `a cell with a double quote (${cell})` : "an empty cell"}; it becomes a quoted value`);
69
+ }
70
+ return pickles.map(pickle => ({
71
+ name: pickle.name,
72
+ tags: pickle.tags.map(t => t.name),
73
+ url: urlFromTags(pickle.tags),
74
+ steps: pickle.steps.map(step => ({ ...fromPickle(step), line: lines.get(step.astNodeIds[0]) })),
75
+ }));
76
+ }
77
+ /** One Step given on the command line, e.g. `Then the status should be "Draft"`; no keyword means `When`. */
78
+ export function parseStep(line) {
79
+ return parseFeature(stepFeature(line))[0].steps[0];
80
+ }
81
+ /** A one-Step `.feature` source, so `decisium step` runs through the same runner as files. */
82
+ export function stepFeature(line) {
83
+ const text = /^(Given|When|Then|And|But|\*)\s/i.test(line) ? line : `When ${line}`;
84
+ return `Feature: step\n Scenario: step\n ${text}\n`;
85
+ }
86
+ // Local paths keep their ?query (e.g. index.html?decoy=1) instead of having it encoded into the file name.
87
+ export const toUrl = (u) => {
88
+ if (/^[a-z]+:\/\//i.test(u))
89
+ return u;
90
+ const [path, query] = u.split(/(?=\?)/);
91
+ return pathToFileURL(resolve(path)).href + (query ?? "");
92
+ };
@@ -0,0 +1,5 @@
1
+ import { Formatter, type IFormatterOptions } from "@cucumber/cucumber";
2
+ export default class DecisiumFormatter extends Formatter {
3
+ static readonly documentation = "Decisium console output";
4
+ constructor(options: IFormatterOptions);
5
+ }
@@ -0,0 +1,119 @@
1
+ // cucumber-js console formatter: one ✓/✗ line per Step, the reason under a failed one, and an end-of-run Failures block.
2
+ import { Formatter, formatterHelpers, Status } from "@cucumber/cucumber";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { fromPickle, urlFromTags } from "./feature.js";
7
+ import { readStep } from "./step.js";
8
+ import { agentReport, failureReport, KIND, MARGINAL, MARGINAL_HINT, NO_TRACE, parseOutcome, stepEvents } from "./report.js";
9
+ const env = process.env;
10
+ /** Shell-quoted for a copy-paste command line. */
11
+ const q = (s) => /^[\w./:@%+=,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
12
+ /** The command that reruns one scenario (or the one command-line Step) with this run's options. */
13
+ function rerun(target) {
14
+ const cli = relative(process.cwd(), fileURLToPath(import.meta.resolve(`./cli${import.meta.url.slice(-3)}`)));
15
+ const flags = [env.DECISIUM_URL && `--url ${q(env.DECISIUM_URL)}`, env.DECISIUM_JUDGED === "1" && "--judged", env.DECISIUM_STRICT === "1" && "--strict"];
16
+ return ["node", cli, ...target.map(q), ...flags].filter(Boolean).join(" ");
17
+ }
18
+ export default class DecisiumFormatter extends Formatter {
19
+ static documentation = "Decisium console output";
20
+ constructor(options) {
21
+ super(options);
22
+ const scenarios = [];
23
+ const failures = []; // built at the end: the Playwright trace is saved after the step
24
+ const warnings = [];
25
+ const hookIds = new Map(); // testRunHookStarted id -> hook definition id
26
+ const hookFailures = []; // a failed BeforeAll/AfterAll (e.g. no TYPESAFE_API_KEY): cucumber runs no scenario
27
+ options.eventBroadcaster.on("envelope", (e) => {
28
+ if (e.testCaseStarted) {
29
+ const { pickle } = this.eventDataCollector.getTestCaseAttempt(e.testCaseStarted.id);
30
+ this.log(`\n${pickle.name} (${process.env.DECISIUM_URL || urlFromTags(pickle.tags) || "no URL"})\n`);
31
+ }
32
+ else if (e.testStepFinished) {
33
+ const { testCaseStartedId, testStepId, testStepResult: result } = e.testStepFinished;
34
+ const { pickle, gherkinDocument, testCase, stepAttachments } = this.eventDataCollector.getTestCaseAttempt(testCaseStartedId);
35
+ const step = pickle.steps.find(s => s.id === testCase.testSteps.find(t => t.id === testStepId)?.pickleStepId);
36
+ const failed = result.status === Status.FAILED;
37
+ if (!step) { // hook: only infrastructure errors (browser could not open, ...)
38
+ if (failed)
39
+ this.log(` Error: ${result.message ?? "hook failed"}\n`);
40
+ return;
41
+ }
42
+ if (result.status === Status.UNDEFINED) { // no Decisium step definitions loaded (a wrong "import" path)
43
+ this.log(` ? Step: ${step.text}\n no step definition: is Decisium's steps file in "import"?\n`);
44
+ return;
45
+ }
46
+ if (![Status.PASSED, Status.FAILED].includes(result.status))
47
+ return; // skipped after a failure
48
+ const parsed = fromPickle(step);
49
+ const { kind } = parsed;
50
+ const label = readStep(parsed).label; // with its table or doc string
51
+ const attachments = stepAttachments[testStepId] ?? [];
52
+ const judged = attachments.some(a => a.body.startsWith("JUDGED_PASSED"));
53
+ this.log(` ${failed ? "✗" : "✓"} ${KIND[kind]}: ${label}${judged ? " (judged by Jev)" : ""}\n`);
54
+ const marginal = attachments.find(a => a.body.startsWith(MARGINAL))?.body.slice(MARGINAL.length);
55
+ if (marginal) {
56
+ this.log(` ⚠ marginal pass: ${marginal}\n`);
57
+ warnings.push(`⚠ ${pickle.name}\n ${KIND[kind]}: ${label}\n Jev was barely confident: ${marginal}`);
58
+ }
59
+ if (!failed)
60
+ return;
61
+ const r = parseOutcome(result.message ?? "");
62
+ this.log(` ${r ? `${r.outcome}${r.reason ? `: ${r.reason}` : ""}` : `Error: ${result.message}`}`.replace(/\n/g, "\n ") + "\n");
63
+ if (!r)
64
+ return;
65
+ const line = formatterHelpers.GherkinDocumentParser.getGherkinStepMap(gherkinDocument)[step.astNodeIds[0]]?.location.line;
66
+ const screenshot = attachments.findLast(a => a.mediaType === "image/png")?.fileName;
67
+ const cmdLine = env.DECISIUM_STEP === "1";
68
+ const where = cmdLine ? "command line" : `${pickle.uri}:${line}`;
69
+ // The scenario's own line; for an Outline, its Examples row (the last AST node).
70
+ const scenarioLine = formatterHelpers.GherkinDocumentParser.getGherkinScenarioLocationMap(gherkinDocument)[pickle.astNodeIds.at(-1)]?.line;
71
+ failures.push(() => {
72
+ const trace = screenshot && join(dirname(screenshot), "playwright-trace.zip");
73
+ const ui = screenshot && join(dirname(screenshot), "ui.txt");
74
+ const noTrace = screenshot && join(dirname(screenshot), NO_TRACE);
75
+ return {
76
+ where, scenario: pickle.name, kind, step: label, ...r, screenshot,
77
+ trace: trace && existsSync(trace) ? trace : noTrace && existsSync(noTrace) ? `not kept: ${readFileSync(noTrace, "utf8").trim()}` : undefined, ui: ui && existsSync(ui) ? ui : undefined,
78
+ rerun: rerun(cmdLine ? ["step", step.text] : ["run", `${pickle.uri}:${scenarioLine}`]),
79
+ };
80
+ });
81
+ }
82
+ else if (e.testCaseFinished && !e.testCaseFinished.willBeRetried) {
83
+ const { pickle, worstTestStepResult } = this.eventDataCollector.getTestCaseAttempt(e.testCaseFinished.testCaseStartedId);
84
+ scenarios.push({ name: pickle.name, passed: worstTestStepResult.status === Status.PASSED });
85
+ }
86
+ else if (e.testRunHookStarted) {
87
+ hookIds.set(e.testRunHookStarted.id, e.testRunHookStarted.hookId);
88
+ }
89
+ else if (e.testRunHookFinished?.result.status === Status.FAILED) {
90
+ const before = this.supportCodeLibrary.beforeTestRunHookDefinitions.some(h => h.id === hookIds.get(e.testRunHookFinished.testRunHookStartedId));
91
+ const { message, exception } = e.testRunHookFinished.result;
92
+ hookFailures.push(`✗ ${before ? "BeforeAll" : "AfterAll"} hook failed\n ${(exception?.message ?? message ?? "").split("\n")[0]}`);
93
+ }
94
+ else if (e.testRunFinished) {
95
+ if (hookFailures.length)
96
+ this.log(`\n${hookFailures.join("\n")}\n`);
97
+ const failed = failures.map(f => f());
98
+ if (failed.length)
99
+ this.log(`\n${"─".repeat(60)}\nFailures\n\n${failed.map(f => failureReport(f.where, f.scenario, f.kind, f.step, f, f.screenshot, f.trace)).join("\n\n")}\n`);
100
+ if (warnings.length)
101
+ this.log(`\n${"─".repeat(60)}\nMarginal passes\n\n${warnings.join("\n\n")}\n\nWhat to do: ${MARGINAL_HINT}\n`);
102
+ const failedNames = scenarios.filter(s => !s.passed).map(s => s.name);
103
+ if (!scenarios.length && hookFailures.length)
104
+ return;
105
+ this.log(`\n${scenarios.length - failedNames.length}/${scenarios.length} scenarios passed${failedNames.length ? `; failed: ${failedNames.join(", ")}` : ""}\n`);
106
+ // The run directory: the CLI's, or (raw cucumber-js) the one the screenshots are in.
107
+ const shot = failed.find(f => f.screenshot)?.screenshot;
108
+ const runDir = env.DECISIUM_RUN_DIR || (shot && dirname(dirname(shot)));
109
+ if (failed.length && runDir && env.DECISIUM_NO_AGENT_REPORT !== "1") {
110
+ const tracePath = join(runDir, "trace.jsonl");
111
+ const events = existsSync(tracePath) ? readFileSync(tracePath, "utf8").trim().split("\n").filter(Boolean).map(l => JSON.parse(l)) : [];
112
+ const path = join(runDir, "report.md");
113
+ writeFileSync(path, agentReport(failed.map(f => ({ ...f, events: stepEvents(events, f.scenario, f.step) }))));
114
+ this.log(`Agent report: ${path}\n`);
115
+ }
116
+ }
117
+ });
118
+ }
119
+ }
package/dist/jev.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { type Assessment, type Decider, type Pick, type TurnAnswer } from "./decision.ts";
2
+ import type { Element, Observation } from "./observation.ts";
3
+ export declare const MODEL = "jev-1.14.0";
4
+ /**
5
+ * Pages can offer more candidates than one question accepts (GitHub: 336 links and buttons). Ask in batches, keep each
6
+ * batch's most likely candidates, repeat until they fit; the caller asks the real question over what is left, so the
7
+ * confidence gate still reads one comparable distribution. `fixed` answers (none, scroll, ...) are in every batch.
8
+ */
9
+ export declare function narrow(candidates: Record<string, string>, fixed: Record<string, string>, ask: (criteria: Record<string, string>) => Promise<Pick>, max?: number): Promise<Record<string, string>>;
10
+ /**
11
+ * Jev rejects requests over its token limit (`max_tokens_exceeded`): measured between 115k and 117k characters of
12
+ * Wikipedia snapshot in one request. `ui` stays under this budget so 255 choices still fit next to it (task 0021).
13
+ */
14
+ export declare const UI_BUDGET = 80000;
15
+ /**
16
+ * A `ui` over budget, reduced to the sections that matter for `step`. Sections are cut at lines and carry their
17
+ * ancestor lines (so a list item keeps its list); narrow() keeps the likeliest ones per batch until they fit, so every
18
+ * question of the Turn reads one reduced `ui` and their probabilities stay comparable.
19
+ */
20
+ export declare function reduceUi(ui: string, ask: (sections: Record<string, string>) => Promise<Pick>, budget?: number, section?: number): Promise<string>;
21
+ /** Live Jev requests of this process and their tokens; cassette replays cost nothing and are not counted. */
22
+ export declare const jevUsage: {
23
+ calls: number;
24
+ inputTokens: number;
25
+ outputTokens: number;
26
+ };
27
+ /** Every question sends the Step text as-is, so a value quoted in a Step (a password included) reaches the API. */
28
+ export declare class JevDecider implements Decider {
29
+ #private;
30
+ turn(step: string, obs: Observation, recent: string[], attempt?: boolean, keys?: string[]): Promise<TurnAnswer>;
31
+ drop(step: string, obs: Observation, source: Element): Promise<Pick>;
32
+ recall(step: string, earlier: Record<string, string>): Promise<Pick>;
33
+ dialog(step: string, obs: Observation, answers: string[]): Promise<Pick>;
34
+ value(step: string, obs: Observation, el: Element, candidates: string[]): Promise<Pick>;
35
+ assess(step: string, obs: Observation, judged: boolean): Promise<Assessment>;
36
+ }