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/jev.js ADDED
@@ -0,0 +1,198 @@
1
+ import { choice, noul, TypeSafeClient } from "@typesafe-ai/sdk";
2
+ import { AREA_MISSING, AREA_PAGE, ASSERTION_KINDS, capabilities, describeAction, PAGE, STATES } from "./decision.js";
3
+ export const MODEL = "jev-1.14.0";
4
+ /** What answering each kind of browser dialog means, for Jev (task 0040). */
5
+ const DIALOG_ANSWERS = {
6
+ accept: {
7
+ confirm: "Click OK: `step` clearly asks for what `dialog` asks to confirm.",
8
+ beforeunload: "Leave the page: `step` clearly intends to leave this page, even if changes are lost.",
9
+ },
10
+ dismiss: {
11
+ confirm: "Click Cancel: `step` does not clearly ask for what `dialog` asks to confirm, or says to cancel.",
12
+ beforeunload: "Stay on the page: `step` does not clearly intend to leave this page.",
13
+ prompt: "Click Cancel: none of the other answers is what `step` asks to enter, or `step` says to cancel.",
14
+ },
15
+ };
16
+ const refs = (els) => Object.fromEntries(els.map(e => [e.ref, e.line]));
17
+ /** Jev API limit: options per choice question. */
18
+ const MAX_CHOICES = 255;
19
+ const FINALISTS = 3; // kept from each batch
20
+ /**
21
+ * Pages can offer more candidates than one question accepts (GitHub: 336 links and buttons). Ask in batches, keep each
22
+ * batch's most likely candidates, repeat until they fit; the caller asks the real question over what is left, so the
23
+ * confidence gate still reads one comparable distribution. `fixed` answers (none, scroll, ...) are in every batch.
24
+ */
25
+ export async function narrow(candidates, fixed, ask, max = MAX_CHOICES) {
26
+ let left = Object.entries(candidates);
27
+ const size = max - Object.keys(fixed).length;
28
+ while (left.length > size) {
29
+ const kept = [];
30
+ for (let i = 0; i < left.length; i += size) {
31
+ const batch = left.slice(i, i + size);
32
+ const { probabilities } = await ask({ ...Object.fromEntries(batch), ...fixed });
33
+ const best = batch.map(([k]) => k).sort((a, b) => (probabilities[b] ?? 0) - (probabilities[a] ?? 0)).slice(0, FINALISTS);
34
+ kept.push(...batch.filter(([k]) => best.includes(k)));
35
+ }
36
+ left = kept;
37
+ }
38
+ return { ...Object.fromEntries(left), ...fixed };
39
+ }
40
+ /**
41
+ * Jev rejects requests over its token limit (`max_tokens_exceeded`): measured between 115k and 117k characters of
42
+ * Wikipedia snapshot in one request. `ui` stays under this budget so 255 choices still fit next to it (task 0021).
43
+ */
44
+ export const UI_BUDGET = 80_000;
45
+ const SECTION = 8_000; // a page over budget is read in sections of about this size
46
+ /**
47
+ * A `ui` over budget, reduced to the sections that matter for `step`. Sections are cut at lines and carry their
48
+ * ancestor lines (so a list item keeps its list); narrow() keeps the likeliest ones per batch until they fit, so every
49
+ * question of the Turn reads one reduced `ui` and their probabilities stay comparable.
50
+ */
51
+ export async function reduceUi(ui, ask, budget = UI_BUDGET, section = SECTION) {
52
+ if (ui.length <= budget)
53
+ return ui;
54
+ const lines = ui.split("\n");
55
+ const indent = (l) => /^\s*/.exec(l)[0].length;
56
+ const parts = [];
57
+ const open = []; // ancestors of the current line, by indentation
58
+ let part = [], size = 0;
59
+ lines.forEach((l, i) => {
60
+ while (open.length && indent(lines[open.at(-1)]) >= indent(l))
61
+ open.pop();
62
+ if (size + l.length > section && part.length) {
63
+ parts.push(part);
64
+ part = [...open];
65
+ size = open.reduce((n, a) => n + lines[a].length + 1, 0);
66
+ }
67
+ part.push(i);
68
+ size += l.length + 1;
69
+ open.push(i);
70
+ });
71
+ parts.push(part);
72
+ const text = (p) => p.map(i => lines[i]).join("\n");
73
+ const kept = await narrow(Object.fromEntries(parts.map((p, i) => [`s${i}`, text(p)])), {}, ask, Math.floor(budget / (section * 1.5)));
74
+ const keep = new Set(Object.keys(kept).flatMap(k => parts[Number(k.slice(1))]));
75
+ return lines.filter((_, i) => keep.has(i)).join("\n");
76
+ }
77
+ /** Live Jev requests of this process and their tokens; cassette replays cost nothing and are not counted. */
78
+ export const jevUsage = { calls: 0, inputTokens: 0, outputTokens: 0 };
79
+ /** Every question sends the Step text as-is, so a value quoted in a Step (a password included) reaches the API. */
80
+ export class JevDecider {
81
+ #client; // created on the first live call: replay-only runs need no TYPESAFE_API_KEY
82
+ #reduced = new WeakMap(); // per Observation and Step: asked once per Turn
83
+ /** `ui` for `step` on this Observation, reduced to the sections that matter when the page is over budget. */
84
+ #ui(obs, about, step) {
85
+ const byStep = this.#reduced.get(obs) ?? new Map();
86
+ this.#reduced.set(obs, byStep);
87
+ if (!byStep.has(step))
88
+ byStep.set(step, reduceUi(obs.text, async (sections) => {
89
+ const res = await this.#ask({
90
+ state: { [about]: step, ...sections },
91
+ questions: { pick: choice(`Which of these sections of the page holds what \`${about}\` needs?`, Object.fromEntries(Object.keys(sections).map(k => [k, `section \`${k}\``]))) },
92
+ });
93
+ return res.answers.pick;
94
+ }));
95
+ return byStep.get(step);
96
+ }
97
+ async #ask(request) {
98
+ const res = await (this.#client ??= new TypeSafeClient({ defaultModel: MODEL })).systemOne(request); // reads TYPESAFE_API_KEY
99
+ jevUsage.calls++;
100
+ jevUsage.inputTokens += res.usage?.input_tokens ?? 0;
101
+ jevUsage.outputTokens += res.usage?.output_tokens ?? 0;
102
+ return { model: res.model, answers: res.answers };
103
+ }
104
+ /** One choice question over `criteria`, for narrow(). */
105
+ #choose(state, question) {
106
+ return async (criteria) => (await this.#ask({ state, questions: { pick: choice(question, criteria) } })).answers.pick;
107
+ }
108
+ async turn(step, obs, recent, attempt = false, keys = []) {
109
+ const state = { step, ui: await this.#ui(obs, "step", step), actions_taken_for_step: recent.length ? recent : "none yet" };
110
+ const question = "Which element in `ui` should be interacted with next to accomplish `step`?";
111
+ const page = `page "${obs.readable.find(e => e.ref === "title")?.value ?? ""}": the page itself (scroll it, wait, history, tabs, keys)`;
112
+ const targets = await narrow(refs(obs.elements), { none: "No element on screen helps accomplish `step`.", [PAGE]: page }, this.#choose(state, question));
113
+ // Every action some target offers; code keeps the chosen target's own (task 0065, ADR 0005).
114
+ const actions = [...new Set([PAGE, ...obs.elements].flatMap(t => capabilities(t, obs, keys)))];
115
+ const res = await this.#ask({
116
+ state,
117
+ questions: {
118
+ done: attempt
119
+ ? noul("Given `actions_taken_for_step`, has the user performed every action `step` asks for, regardless of whether the app accepted it?")
120
+ : noul("Given `actions_taken_for_step`, does `ui` show that `step` has been fully accomplished?"),
121
+ target: choice(question, targets),
122
+ // Same request as target: one call per Turn (task 0065: as accurate as asking after the target, half the time).
123
+ action: choice("How should the element chosen to interact with next be used to accomplish `step`?", Object.fromEntries(actions.map(a => [a, describeAction(a)]))),
124
+ },
125
+ });
126
+ return { done: res.answers.done.noul, target: res.answers.target, action: res.answers.action };
127
+ }
128
+ async drop(step, obs, source) {
129
+ const state = { step, ui: await this.#ui(obs, "step", step), dragged: source.line };
130
+ const question = "`dragged` is being dragged with the mouse to accomplish `step`. Onto which element in `ui` should it be dropped?";
131
+ const among = [...new Set([...obs.readable, ...obs.areas])].filter(e => e !== source);
132
+ const targets = await narrow(refs(among), { none: "No element in `ui` is where `step` says to drop it." }, this.#choose(state, question));
133
+ return (await this.#ask({ state, questions: { target: choice(question, targets) } })).answers.target;
134
+ }
135
+ async recall(step, earlier) {
136
+ const values = Object.keys(earlier);
137
+ const criteria = Object.fromEntries(values.map((v, i) => [`v${i}`, `"${v}", from the earlier step: ${earlier[v]}`]));
138
+ criteria.none = "`check` does not refer to any of these values.";
139
+ const res = await this.#ask({
140
+ state: { check: step },
141
+ questions: { value: choice("`check` quotes no value. Which value from an earlier step of the same test does it refer to (e.g. \"the campaign\" meaning the campaign named earlier)?", criteria) },
142
+ });
143
+ const a = res.answers.value;
144
+ const name = (k) => k === "none" ? k : values[Number(k.slice(1))] ?? k;
145
+ return { choice: name(a.choice), probabilities: Object.fromEntries(Object.entries(a.probabilities).map(([k, p]) => [name(k), p])) };
146
+ }
147
+ async dialog(step, obs, answers) {
148
+ const { type, message } = obs.dialog;
149
+ const criteria = Object.fromEntries(answers.map((a, i) => a === "accept" || a === "dismiss"
150
+ ? [a, DIALOG_ANSWERS[a][type]] : [`v${i}`, `Enter "${a}" and click OK.`]));
151
+ const res = await this.#ask({
152
+ state: { step, dialog: message },
153
+ questions: { answer: choice(`A browser ${type === "beforeunload" ? "\"Leave site?\"" : type} dialog is open, saying \`dialog\`. How should it be answered to accomplish \`step\`?`, criteria) },
154
+ });
155
+ const a = res.answers.answer;
156
+ const name = (k) => /^v\d+$/.test(k) ? answers[Number(k.slice(1))] : k;
157
+ return { choice: name(a.choice), probabilities: Object.fromEntries(Object.entries(a.probabilities).map(([k, p]) => [name(k), p])) };
158
+ }
159
+ async value(step, obs, el, candidates) {
160
+ const state = { step, ui: await this.#ui(obs, "step", step), element: el.line };
161
+ // A clicked element gets a value only to upload a file (task 0044): "entered into a button" read as nothing.
162
+ const question = el.verb === "CLICK" ? "Which file should be uploaded with `element` to accomplish `step`?"
163
+ : "Which value should be entered into `element` to accomplish `step`?";
164
+ const criteria = await narrow(Object.fromEntries(candidates.map((c, i) => [`v${i}`, c])), { none: "None of these values is what `step` asks for." }, this.#choose(state, question));
165
+ const res = await this.#ask({ state, questions: { value: choice(question, criteria) } });
166
+ const a = res.answers.value;
167
+ // Map label keys back to the values so traces and execution use real text.
168
+ const name = (k) => criteria[k] !== undefined && k !== "none" ? criteria[k] : k;
169
+ return {
170
+ choice: name(a.choice),
171
+ probabilities: Object.fromEntries(Object.entries(a.probabilities).map(([k, p]) => [name(k), p])),
172
+ };
173
+ }
174
+ async assess(step, obs, judged) {
175
+ const state = { check: step, ui: await this.#ui(obs, "check", step) };
176
+ const subjectQ = "Which element in `ui` is the one `check` is about, regardless of whether the check passes?";
177
+ const areaQ = "Where does `check` say to look? Pick the element in `ui` that is that area (a list, table, section, dialog...), regardless of whether the check passes.";
178
+ const [subjects, areas] = await Promise.all([
179
+ narrow(refs(obs.readable), { none: "No element in `ui` is the one `check` is about." }, this.#choose(state, subjectQ)),
180
+ narrow(refs(obs.areas), {
181
+ [AREA_PAGE]: "`check` names no specific area; anywhere on the page counts.",
182
+ [AREA_MISSING]: "`check` names a specific area, but no element in `ui` is that area.",
183
+ }, this.#choose(state, areaQ)),
184
+ ]);
185
+ const res = await this.#ask({
186
+ state,
187
+ questions: {
188
+ kind: choice("What kind of check is `check`?", ASSERTION_KINDS),
189
+ subject: choice(subjectQ, subjects),
190
+ state: choice("Which control state does `check` require?", STATES),
191
+ area: choice(areaQ, areas),
192
+ ...(judged ? { judged: noul("Does `ui` satisfy `check`?") } : {}),
193
+ },
194
+ });
195
+ const a = res.answers;
196
+ return { kind: a.kind, subject: a.subject, area: a.area, state: a.state, judged: a.judged?.noul };
197
+ }
198
+ }
package/dist/lint.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { type ParsedStep } from "./step.ts";
2
+ /** Cookbook warnings for one Step. `before`: the scenario's earlier Steps. */
3
+ export declare function warnings(step: ParsedStep, before: ParsedStep[], env?: Record<string, string | undefined>): string[];
4
+ /** The explanation of one feature file, for people. */
5
+ export declare function lint(path: string, env?: Record<string, string | undefined>): string;
package/dist/lint.js ADDED
@@ -0,0 +1,109 @@
1
+ // `decisium lint <file.feature>`: what each Step will be taken as, read without a browser or Jev (task 0031).
2
+ // Per Step: its kind, the quoted values Jev can pick from, the keys it presses, secret placeholders, and warnings for wordings that
3
+ // docs/cookbook.md shows failing.
4
+ import { readFileSync } from "node:fs";
5
+ import { parseFeature } from "./feature.js";
6
+ import { KIND } from "./report.js";
7
+ import { placeholder, readStep } from "./step.js";
8
+ const SEE = "see docs/cookbook.md";
9
+ /** Cookbook warnings for one Step. `before`: the scenario's earlier Steps. */
10
+ export function warnings(step, before, env = process.env) {
11
+ const t = step.text;
12
+ const read = readStep(step);
13
+ const earlier = before.flatMap(s => readStep(s).quoted).filter(v => !placeholder(v));
14
+ const { values } = read; // a check with a table returns before reading these
15
+ const out = [];
16
+ for (const name of new Set(read.placeholders.map(v => placeholder(v)))) {
17
+ if (!env[name])
18
+ out.push(`$${name} is not set in this environment: the run stops before it starts (exit 2)`);
19
+ }
20
+ const secret = /\b(?:password|passcode|token|secret|api key)\b[^"“]*["“]([^"”]+)["”]/i.exec(t)?.[1];
21
+ if (secret && !placeholder(secret) && step.kind !== "assertion") {
22
+ out.push(`a literal value next to "password/token/secret" is sent to Jev and written to the cassette and logs; write "$NAME" and set it in the environment (${SEE}, Secrets)`);
23
+ }
24
+ if (step.kind !== "assertion") {
25
+ if (/\bsearch(es)? (for|the)\b/i.test(t) && !/\b(open|opens|submit|submits)\b/i.test(t)) {
26
+ out.push(`"search for" runs a search but does not open a result, and Jev may count typing alone as done; say "I open the "X" ... through the search", or add "and submit the search" (${SEE}, Navigate)`);
27
+ }
28
+ if (/^(I\s+)?(un)?(check|tick)\b/i.test(t) && /\b(and|then)\b|,/i.test(t))
29
+ out.push(`checking a box is decided by code only when the Step does nothing else; split "and ..." into its own Step (${SEE}, Submit and act)`);
30
+ for (const name of read.unknownKeys)
31
+ out.push(`"${name}" is not a key name, so it is never pressed; use Escape, Tab, ArrowDown, PageDown, Home, F2, Esc, down arrow, Space, Ctrl+K... (${SEE}, Keys)`);
32
+ if (!/\b(press(es|ed|ing)?|hit(s|ting)?)\b/i.test(t) && /\b(Esc|Escape)\b|\b(Tab|Enter|Return|Space|Backspace|Delete|Home|End|arrow|F\d+) key\b|\b(Ctrl|Control|Shift|Alt|Cmd|Meta)\s*\+\s*\w/i.test(t))
33
+ out.push(`names a key without "press" or "hit", so no key is offered: say "press Escape" (${SEE}, Keys)`);
34
+ if (/^(I\s+)?(accept|dismiss|cancel|answer|confirm)\s+(the\s+)?(alert|confirm|confirmation|prompt|browser dialog)\b/i.test(t))
35
+ out.push(`a browser dialog is answered by the Step that opens it and dismissed when that Step ends, so this Step finds none open; say the intent in the Step that opens it ("I delete the note", "I start deleting the note but cancel") (${SEE}, Browser dialogs)`);
36
+ if (step.table && read.header && step.table.length < 2)
37
+ out.push(`an action's table with 3+ columns starts with its header row, so this one has no row to act on: AMBIGUOUS; add one row per entry under the header (${SEE}, Data tables)`);
38
+ return out;
39
+ }
40
+ // Checks. A table is matched by code, row by row under its header; a header alone compares nothing.
41
+ if (step.table) {
42
+ if (read.header && step.table.length < 2)
43
+ out.push(`a check's table with 2+ columns starts with its header row, so this one has no row to compare: UNVERIFIABLE; add a header row naming the page table's columns (${SEE}, Data tables)`);
44
+ if (read.header && /\b(not|no longer|never)\b/i.test(t))
45
+ out.push(`code can check that table rows are shown, not that they are absent: UNVERIFIABLE; check each row's absence in its own Step (${SEE}, Data tables)`);
46
+ return out;
47
+ }
48
+ // Checks. A state check ("should be disabled") needs no quoted value.
49
+ const state = /\bshould (?:not )?be (disabled|enabled|checked|unchecked|expanded|collapsed|selected|unselected|pressed|unpressed)\b/i.test(t);
50
+ const subjective = !values.length && /\b(well|nicely|good|nice|tidy|clean|readable|beautiful|professional|properly|correctly|looks?)\b/i.test(t);
51
+ if (!values.length && !state && !subjective) {
52
+ out.push(earlier.length
53
+ ? `quotes nothing: Jev must work out which earlier value it means (${[...new Set(earlier)].map(v => `"${v}"`).join(", ")}); if it is unsure the check is UNVERIFIABLE, so quote the value when there are several (${SEE}, Memory)`
54
+ : `quotes nothing for code to compare: UNVERIFIABLE; quote the expected text or value (${SEE}, Presence)`);
55
+ }
56
+ if (values.length > 1 && /\bshould (be|say|show|read|equal)\b(?! (shown|visible|displayed|listed))/i.test(t)) {
57
+ out.push(`a value check compares exactly one quoted value; with ${values.length} it is UNVERIFIABLE (split it into one check per value) (${SEE}, Value checks)`);
58
+ }
59
+ if (/\btitle\b/i.test(t) && !/\b(page|tab|browser|window) title\b/i.test(t)) {
60
+ out.push(`"title" also names the browser tab's title, so Jev may be unsure which one you mean (AMBIGUOUS); say "heading" (${SEE}, Value checks)`);
61
+ }
62
+ if (subjective) {
63
+ out.push(`reads as subjective: code cannot compare it (UNVERIFIABLE) unless the run has --judged; quote something concrete (${SEE}, Value checks)`);
64
+ }
65
+ const AREA = "(?:[\\w-]+ ){0,3}(?:list|calendar|table|results|menu|section|dialog|sidebar|panel|grid|cart|tab|form|header|footer|toolbar|campaigns)";
66
+ // "... in the campaign list", or the area as subject: "the campaign list should contain ...".
67
+ const area = new RegExp(`\\b(?:in|on|inside|within|among|under) (?:the |a )?(${AREA})\\b`, "i").exec(t)
68
+ ?? new RegExp(`^(?:the )?(${AREA}) should (?:not )?(?:contain|include|list|show)\\b`, "i").exec(t);
69
+ // The cookbook's failing pattern: the last action opened something else (a campaign), and the check names an area
70
+ // (the campaign list) that is therefore not on the screen.
71
+ const lastAction = before.findLast(s => s.kind !== "assertion")?.text ?? "";
72
+ const noun = area?.[1].split(" ").at(-1);
73
+ if (area && /\bopen/i.test(lastAction) && !new RegExp(`\\b${noun}`, "i").test(lastAction))
74
+ out.push(`looks only in "${area[1]}", but the Step before opened something else: if that area is not on the screen, the check is AMBIGUOUS; open it first (${SEE}, Scoped presence)`);
75
+ return out;
76
+ }
77
+ /** The explanation of one feature file, for people. */
78
+ export function lint(path, env = process.env) {
79
+ const out = [path];
80
+ let flagged = 0;
81
+ for (const s of parseFeature(readFileSync(path, "utf8"), path)) {
82
+ out.push("", ` Scenario: ${s.name}${s.url ? ` (${s.url})` : ""}`);
83
+ const before = [];
84
+ for (const step of s.steps) {
85
+ const read = readStep(step);
86
+ const values = step.kind === "assertion" ? read.quoted : step.docString === undefined ? read.candidates : read.candidates.slice(0, -1);
87
+ const secrets = [...new Set(read.placeholders)];
88
+ out.push(` ${String(step.line ?? "").padStart(4)} ${KIND[step.kind].padEnd(7)} ${read.label}`);
89
+ if (step.table && step.kind !== "assertion")
90
+ for (const row of read.rows)
91
+ out.push(`${" ".repeat(15)}row: ${row}`);
92
+ const shown = values.filter(v => !placeholder(v));
93
+ if (shown.length)
94
+ out.push(`${" ".repeat(15)}${step.kind === "assertion" ? "compares" : "values"}: ${shown.map(v => `"${v}"`).join(", ")}`);
95
+ const { keys } = read;
96
+ if (keys.length)
97
+ out.push(`${" ".repeat(15)}keys: ${keys.join(", ")}`);
98
+ if (secrets.length)
99
+ out.push(`${" ".repeat(15)}secrets (from the environment, never sent to Jev): ${secrets.join(", ")}`);
100
+ for (const w of warnings(step, before, env)) {
101
+ out.push(`${" ".repeat(15)}⚠ ${w}`);
102
+ flagged++;
103
+ }
104
+ before.push(step);
105
+ }
106
+ }
107
+ out.push("", flagged ? `${flagged} warning${flagged > 1 ? "s" : ""}.` : "No warnings.");
108
+ return out.join("\n");
109
+ }
@@ -0,0 +1,9 @@
1
+ import type { BrowserContext } from "playwright";
2
+ export interface NetworkReplay {
3
+ glob: string;
4
+ har: string;
5
+ mode: "record" | "replay";
6
+ }
7
+ export declare function recordNetwork(context: BrowserContext, { glob, har }: NetworkReplay): Promise<void>;
8
+ /** `missed` gets "METHOD url" for each matching request the HAR does not have. */
9
+ export declare function replayNetwork(context: BrowserContext, { glob, har }: NetworkReplay, missed: (request: string) => void): Promise<void>;
@@ -0,0 +1,35 @@
1
+ // Network record/replay for `@replay-network:<glob>` (task 0048). Recording: Playwright's routeFromHAR writes only the
2
+ // matching requests to a HAR (bodies in files beside it). Replay: our own route handler, not routeFromHAR, because that
3
+ // matches request bodies exactly and misses every POST with a timestamp or id in it. A request is matched by method +
4
+ // URL + occurrence (the n-th call to that URL), body ignored; one not in the recording is aborted and reported, never
5
+ // sent live.
6
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ export async function recordNetwork(context, { glob, har }) {
9
+ mkdirSync(dirname(har), { recursive: true });
10
+ await context.routeFromHAR(har, { url: glob, update: true, updateContent: "attach", updateMode: "minimal" });
11
+ }
12
+ /** `missed` gets "METHOD url" for each matching request the HAR does not have. */
13
+ export async function replayNetwork(context, { glob, har }, missed) {
14
+ if (!existsSync(har))
15
+ throw new Error(`No network recording ${har} for @replay-network:${glob}; record it with --record all.`);
16
+ const entries = JSON.parse(readFileSync(har, "utf8")).log.entries;
17
+ const served = new Map(); // "METHOD url" -> times served
18
+ await context.route(glob, route => {
19
+ const req = route.request();
20
+ const key = `${req.method()} ${req.url()}`;
21
+ const n = served.get(key) ?? 0;
22
+ served.set(key, n + 1);
23
+ const entry = entries.filter(e => `${e.request.method} ${e.request.url}` === key)[n];
24
+ if (!entry) {
25
+ missed(n ? `${key} (call ${n + 1})` : key);
26
+ return route.abort();
27
+ }
28
+ const { status, headers, content } = entry.response;
29
+ const body = content._file ? readFileSync(join(dirname(har), content._file))
30
+ : Buffer.from(content.text ?? "", content.encoding === "base64" ? "base64" : "utf8");
31
+ // content-length/encoding describe the recorded transfer, not the decoded body served here.
32
+ const kept = headers.filter(h => !/^(content-length|content-encoding|transfer-encoding)$/i.test(h.name));
33
+ return route.fulfill({ status, headers: Object.fromEntries(kept.map(h => [h.name, h.value])), body });
34
+ });
35
+ }
@@ -0,0 +1,87 @@
1
+ /** A node of `ariaSnapshotJSON`: free-form, states as extra keys (`checked`, `level`, ...); text nodes are strings. */
2
+ export type AriaNode = string | {
3
+ role: string;
4
+ name?: string;
5
+ ref?: string;
6
+ cursor?: string;
7
+ url?: string;
8
+ text?: string;
9
+ children?: AriaNode[];
10
+ [state: string]: unknown;
11
+ };
12
+ /**
13
+ * The snapshot's text form, one line per node, without YAML quoting: the YAML text quoted lines whose name held a colon
14
+ * (`- 'heading "Releases: x/y" [ref=e1]'`) and the line parser dropped them (task 0064). Names escape `\` and `"`.
15
+ */
16
+ export declare function renderSnapshot(nodes: AriaNode[], indent?: string): string;
17
+ export type Verb = "CLICK" | "TYPE" | "SELECT";
18
+ export interface Element {
19
+ ref: string;
20
+ role: string;
21
+ name: string;
22
+ value: string;
23
+ description?: string;
24
+ state: string;
25
+ secret: boolean;
26
+ line: string;
27
+ verb: Verb;
28
+ options: string[];
29
+ text: string;
30
+ parent?: Element;
31
+ cells?: Element[];
32
+ url?: string;
33
+ }
34
+ export interface Observation {
35
+ text: string;
36
+ elements: Element[];
37
+ readable: Element[];
38
+ areas: Element[];
39
+ pageText: string;
40
+ dialog?: Dialog;
41
+ }
42
+ /** A browser dialog: open, or the last one shown, readable until the next action Step (task 0040). */
43
+ export interface Dialog {
44
+ type: "alert" | "confirm" | "prompt" | "beforeunload";
45
+ message: string;
46
+ state: "open" | "accepted" | "dismissed";
47
+ }
48
+ export declare const MASK = "\u2022\u2022\u2022\u2022";
49
+ /**
50
+ * `secretRefs`: refs of password fields (the snapshot itself shows them as plain textboxes with their value).
51
+ * `editableRefs`: comboboxes you type into (search boxes with suggestions); other comboboxes are clicked open.
52
+ * `targetRefs`: non-controls with their own pointer cursor or tabindex: Turn targets (task 0064).
53
+ */
54
+ export declare function parseSnapshot(snapshot: string, secretRefs?: ReadonlySet<string>, editableRefs?: ReadonlySet<string>, targetRefs?: ReadonlySet<string>): Observation;
55
+ /**
56
+ * Text an Assertion step compares against, or undefined when the element's text is only its label.
57
+ * Value-bearing controls compare their value (empty counts); other controls (buttons, checkboxes) only
58
+ * have a name, so "the 'Schedule' button should be disabled" must not pass just because the name matches.
59
+ * Links are the exception: their text is content (a breadcrumb or title showing a name), like a heading's.
60
+ */
61
+ export declare function assertableText(el: Element): string | undefined;
62
+ /**
63
+ * Adds the page URL and title as two readable pseudo-elements (refs `url`, `title`) on top of `ui`, so a value check
64
+ * can pick them as the subject (task 0025). Not actionable, and not in `pageText`: text only in the URL is not shown.
65
+ * `tabs` (task 0041): e.g. "2 open, this is tab 2", only while more than one is open, so one-tab pages read as before.
66
+ */
67
+ export declare function withPage(obs: Observation, url: string, title: string, tabs?: string): Observation;
68
+ /**
69
+ * Adds a browser dialog as a readable pseudo-element (ref `dialog`) on top of `ui`, named "browser dialog" so it is not
70
+ * taken for an ARIA alert, with its state (open, accepted, dismissed: Jev read a check about the page after a closed
71
+ * "Delete the Ideas note?" as an absence check). Its message is information for Jev and what a value check compares,
72
+ * never something to click. Like the URL it is not an area or page text: a check about the page must not read it.
73
+ */
74
+ export declare function withDialog(obs: Observation, dialog?: Dialog): Observation;
75
+ /** The last download of the scenario (task 0044), saved to its run folder. `text`: its contents, unless binary. */
76
+ export interface Download {
77
+ name: string;
78
+ size: number;
79
+ text?: string;
80
+ }
81
+ /**
82
+ * Adds the last download as a readable pseudo-element (ref `download`): its value is the file name and, for a text file,
83
+ * its contents, so the value check compares either. An empty file is `[empty]`, and a value check on it fails.
84
+ */
85
+ export declare function withDownload(obs: Observation, download?: Download): Observation;
86
+ /** Adds the clipboard text (task 0062) as a readable pseudo-element (ref `clipboard`), so a value check reads copied text. */
87
+ export declare function withClipboard(obs: Observation, text?: string): Observation;