@mostra/verify 0.0.0-placeholder → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +5 -1
- package/dist/index.d.ts +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +48 -0
- package/dist/main.js.map +1 -0
- package/dist/runner-DCmzgY2w.js +964 -0
- package/dist/runner-DCmzgY2w.js.map +1 -0
- package/package.json +57 -4
- package/src/index.ts +19 -0
- package/src/main.ts +64 -0
- package/src/network.ts +161 -0
- package/src/observe.ts +159 -0
- package/src/recorder.ts +97 -0
- package/src/runner.ts +457 -0
- package/src/smoke.ts +400 -0
package/src/smoke.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The smoke test (ROUND-3 §9, D55, D72, D138): uses every enabled control of the rendered page
|
|
4
|
+
* once, within 200 interactions or 60 s, and reports what it observed.
|
|
5
|
+
*
|
|
6
|
+
* - Buttons, tabs, menus and other triggers are clicked; tooltip and hover-card triggers hovered;
|
|
7
|
+
* context-menu triggers right-clicked. Disabled controls and already-active ones (the selected
|
|
8
|
+
* tab, the current page) are left alone, as EVAL-01's calibration showed they rightly do nothing.
|
|
9
|
+
* - Inputs, text areas, selects, checkboxes and radios are filled or toggled with values that fit
|
|
10
|
+
* their type, then blurred so the page saves; one whose value saves nothing is unbound (a hint).
|
|
11
|
+
* - Links to other origins, `mailto:`, downloads and links that open a new tab aren't followed.
|
|
12
|
+
* - A control with no observable effect (no change to the page, no request, no saved field, no
|
|
13
|
+
* navigation) is a warning that says what was observed, never an error (D72).
|
|
14
|
+
* - Actions that didn't opt into `dryRun()` are refused by the sandbox; faults that follow from
|
|
15
|
+
* the refusal are attributed to it (see `judge`).
|
|
16
|
+
*/
|
|
17
|
+
import { createDiagnostic, type Diagnostic } from "@mostra/protocol";
|
|
18
|
+
import type { Frame, Locator, Page } from "playwright-core";
|
|
19
|
+
import type { NetworkWatch } from "./network.ts";
|
|
20
|
+
import { judge, recorded, settle } from "./observe.ts";
|
|
21
|
+
|
|
22
|
+
/** One control the smoke test found. */
|
|
23
|
+
interface Control {
|
|
24
|
+
readonly index: number;
|
|
25
|
+
readonly tag: string;
|
|
26
|
+
readonly role: string;
|
|
27
|
+
readonly type: string;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** How it is used. */
|
|
30
|
+
readonly use: "click" | "hover" | "context" | "fill" | "toggle" | "select" | "skip";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options for {@link runSmoke}. */
|
|
34
|
+
export interface SmokeOptions {
|
|
35
|
+
readonly page: Page;
|
|
36
|
+
readonly frame: Frame;
|
|
37
|
+
readonly network: NetworkWatch;
|
|
38
|
+
readonly viewport: string;
|
|
39
|
+
/** The recorder's entries already judged. */
|
|
40
|
+
readonly from: number;
|
|
41
|
+
readonly maxInteractions: number;
|
|
42
|
+
readonly deadline: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the smoke test found. */
|
|
46
|
+
export interface SmokeOutcome {
|
|
47
|
+
readonly findings: Diagnostic[];
|
|
48
|
+
readonly interactions: number;
|
|
49
|
+
readonly stateChanges: number;
|
|
50
|
+
readonly budget: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MARK = "data-mostra-smoke";
|
|
54
|
+
/** How many levels of revealed controls the smoke test follows: a dialog, then its menu. */
|
|
55
|
+
const REVEAL_DEPTH = 2;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Finds the page's controls not marked yet and marks each with its index, numbering from `start`
|
|
59
|
+
* (runs in the frame): the page's controls at first, then those an interaction revealed.
|
|
60
|
+
*/
|
|
61
|
+
function findControls([mark, start]: readonly [string, number]): Control[] {
|
|
62
|
+
const selector = [
|
|
63
|
+
"button",
|
|
64
|
+
"[role=button]",
|
|
65
|
+
"a[href]",
|
|
66
|
+
"input:not([type=hidden])",
|
|
67
|
+
"textarea",
|
|
68
|
+
"select",
|
|
69
|
+
"summary",
|
|
70
|
+
"[role=tab]",
|
|
71
|
+
"[role=checkbox]",
|
|
72
|
+
"[role=switch]",
|
|
73
|
+
"[role=radio]",
|
|
74
|
+
"[role=menuitem]",
|
|
75
|
+
"[role=combobox]",
|
|
76
|
+
"[role=option]",
|
|
77
|
+
"[aria-haspopup]",
|
|
78
|
+
'[data-slot$="-trigger"]',
|
|
79
|
+
'[contenteditable="true"]',
|
|
80
|
+
'[contenteditable=""]',
|
|
81
|
+
].join(",");
|
|
82
|
+
const controls: Control[] = [];
|
|
83
|
+
const seen = new Set<Element>();
|
|
84
|
+
for (const element of document.querySelectorAll(selector)) {
|
|
85
|
+
if (seen.has(element) || controls.length >= 400 || element.hasAttribute(mark)) continue;
|
|
86
|
+
seen.add(element);
|
|
87
|
+
const html = element as HTMLElement;
|
|
88
|
+
const tag = element.tagName.toLowerCase();
|
|
89
|
+
const type = (element.getAttribute("type") ?? "").toLowerCase();
|
|
90
|
+
const role = element.getAttribute("role") ?? "";
|
|
91
|
+
const slot = element.getAttribute("data-slot") ?? "";
|
|
92
|
+
const name = (
|
|
93
|
+
element.getAttribute("aria-label") ??
|
|
94
|
+
((html as HTMLInputElement).labels?.[0]?.textContent || undefined) ??
|
|
95
|
+
element.getAttribute("placeholder") ??
|
|
96
|
+
element.getAttribute("title") ??
|
|
97
|
+
html.innerText ??
|
|
98
|
+
""
|
|
99
|
+
)
|
|
100
|
+
.trim()
|
|
101
|
+
.replace(/\s+/g, " ")
|
|
102
|
+
.slice(0, 60);
|
|
103
|
+
let use: Control["use"] = "click";
|
|
104
|
+
if (tag === "a") {
|
|
105
|
+
const anchor = element as HTMLAnchorElement;
|
|
106
|
+
let url: URL | undefined;
|
|
107
|
+
try {
|
|
108
|
+
url = new URL(anchor.href, location.href);
|
|
109
|
+
} catch {
|
|
110
|
+
url = undefined;
|
|
111
|
+
}
|
|
112
|
+
const leaves =
|
|
113
|
+
!url ||
|
|
114
|
+
url.origin !== location.origin ||
|
|
115
|
+
anchor.target === "_blank" ||
|
|
116
|
+
anchor.hasAttribute("download");
|
|
117
|
+
if (leaves) use = "skip";
|
|
118
|
+
} else if (slot === "tooltip-trigger" || slot === "hover-card-trigger") use = "hover";
|
|
119
|
+
else if (slot === "context-menu-trigger") use = "context";
|
|
120
|
+
else if (tag === "select") use = "select";
|
|
121
|
+
else if (tag === "textarea" || html.isContentEditable) use = "fill";
|
|
122
|
+
else if (tag === "input") {
|
|
123
|
+
if (type === "checkbox" || type === "radio") use = "toggle";
|
|
124
|
+
else if (["submit", "button", "reset", "image"].includes(type)) use = "click";
|
|
125
|
+
else if (type === "file") use = "skip";
|
|
126
|
+
else use = "fill";
|
|
127
|
+
}
|
|
128
|
+
const index = start + controls.length;
|
|
129
|
+
element.setAttribute(mark, String(index));
|
|
130
|
+
controls.push({ index, tag, role, type, name, use });
|
|
131
|
+
}
|
|
132
|
+
return controls;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a control is disabled or already active, read live (runs in the frame). */
|
|
136
|
+
function stateOf(element: Element): { readonly disabled: boolean; readonly active: boolean } {
|
|
137
|
+
const html = element as HTMLButtonElement;
|
|
138
|
+
const disabled =
|
|
139
|
+
html.disabled === true ||
|
|
140
|
+
element.getAttribute("aria-disabled") === "true" ||
|
|
141
|
+
element.closest("fieldset[disabled]") !== null;
|
|
142
|
+
const current = element.getAttribute("aria-current");
|
|
143
|
+
const active =
|
|
144
|
+
element.getAttribute("aria-selected") === "true" ||
|
|
145
|
+
(current !== null && current !== "false") ||
|
|
146
|
+
element.getAttribute("data-state") === "active" ||
|
|
147
|
+
element.hasAttribute("data-active") ||
|
|
148
|
+
element.hasAttribute("data-selected");
|
|
149
|
+
return { disabled, active };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The attributes that show a control's state, as one string (runs in the frame). */
|
|
153
|
+
function attributesOf(element: Element): string {
|
|
154
|
+
return [
|
|
155
|
+
"aria-expanded",
|
|
156
|
+
"aria-checked",
|
|
157
|
+
"aria-selected",
|
|
158
|
+
"aria-pressed",
|
|
159
|
+
"data-state",
|
|
160
|
+
"data-checked",
|
|
161
|
+
"data-open",
|
|
162
|
+
"data-popup-open",
|
|
163
|
+
"data-pressed",
|
|
164
|
+
"data-panel-open",
|
|
165
|
+
"open",
|
|
166
|
+
]
|
|
167
|
+
.map((name) => `${name}=${element.getAttribute(name)}`)
|
|
168
|
+
.join("|");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A value that fits an input's type. */
|
|
172
|
+
function valueFor(type: string): string {
|
|
173
|
+
const values: Record<string, string> = {
|
|
174
|
+
email: "verify@example.com",
|
|
175
|
+
number: "7",
|
|
176
|
+
range: "7",
|
|
177
|
+
date: "2026-01-15",
|
|
178
|
+
time: "12:30",
|
|
179
|
+
"datetime-local": "2026-01-15T12:30",
|
|
180
|
+
month: "2026-01",
|
|
181
|
+
week: "2026-W03",
|
|
182
|
+
url: "https://example.com/",
|
|
183
|
+
tel: "5550100",
|
|
184
|
+
color: "#336699",
|
|
185
|
+
password: "verify-pass",
|
|
186
|
+
};
|
|
187
|
+
return values[type] ?? "Verify";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const describe = (control: Control): string => {
|
|
191
|
+
const kind =
|
|
192
|
+
control.role || (control.tag === "input" ? `${control.type || "text"} input` : control.tag);
|
|
193
|
+
return control.name ? `${kind} "${control.name}"` : kind;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Counts the frame document's mutations from now on (runs in the frame). */
|
|
197
|
+
function countMutations(): void {
|
|
198
|
+
const scope = globalThis as unknown as { __mostraSmoke?: { count: number } };
|
|
199
|
+
if (scope.__mostraSmoke) return;
|
|
200
|
+
const state = { count: 0 };
|
|
201
|
+
scope.__mostraSmoke = state;
|
|
202
|
+
new MutationObserver((records) => {
|
|
203
|
+
state.count += records.length;
|
|
204
|
+
}).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const mutations = (frame: Frame): Promise<number> =>
|
|
208
|
+
frame
|
|
209
|
+
.evaluate(
|
|
210
|
+
() =>
|
|
211
|
+
(globalThis as unknown as { __mostraSmoke?: { count: number } }).__mostraSmoke?.count ?? -1,
|
|
212
|
+
)
|
|
213
|
+
.catch(() => -1);
|
|
214
|
+
|
|
215
|
+
/** What one use of a control showed. */
|
|
216
|
+
interface Use {
|
|
217
|
+
readonly used: boolean;
|
|
218
|
+
readonly changed: boolean;
|
|
219
|
+
readonly writes: number;
|
|
220
|
+
readonly navigated: boolean;
|
|
221
|
+
readonly newDocument: boolean;
|
|
222
|
+
readonly mark: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Runs the smoke test on a rendered page. */
|
|
226
|
+
export async function runSmoke(options: SmokeOptions): Promise<SmokeOutcome> {
|
|
227
|
+
const { page, frame, network, viewport } = options;
|
|
228
|
+
const findings: Diagnostic[] = [];
|
|
229
|
+
const startMark = network.mark();
|
|
230
|
+
let seen = options.from;
|
|
231
|
+
let interactions = 0;
|
|
232
|
+
let budget = false;
|
|
233
|
+
let stopped = false;
|
|
234
|
+
await frame.evaluate(countMutations).catch(() => undefined);
|
|
235
|
+
const controls = await frame
|
|
236
|
+
.evaluate(findControls, [MARK, 0] as const)
|
|
237
|
+
.catch(() => [] as Control[]);
|
|
238
|
+
const usable = controls.filter((control) => control.use !== "skip");
|
|
239
|
+
let next = controls.length;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Uses a control once and judges what the page reported meanwhile. Controls it revealed (a
|
|
243
|
+
* dialog's buttons, a menu's items, a tab's panel) are used too, before anything is dismissed,
|
|
244
|
+
* down to {@link REVEAL_DEPTH} levels (GPT review finding 3).
|
|
245
|
+
*/
|
|
246
|
+
const exercise = async (control: Control, depth = 0): Promise<Use> => {
|
|
247
|
+
const none: Use = {
|
|
248
|
+
used: false,
|
|
249
|
+
changed: false,
|
|
250
|
+
writes: 0,
|
|
251
|
+
navigated: false,
|
|
252
|
+
newDocument: false,
|
|
253
|
+
mark: network.mark(),
|
|
254
|
+
};
|
|
255
|
+
if (frame.isDetached()) return none;
|
|
256
|
+
const target: Locator = frame.locator(`[${MARK}="${control.index}"]`);
|
|
257
|
+
if (!(await target.isVisible().catch(() => false))) return none;
|
|
258
|
+
const state = await target.evaluate(stateOf).catch(() => ({ disabled: true, active: false }));
|
|
259
|
+
if (state.disabled || state.active) return none;
|
|
260
|
+
const mark = network.mark();
|
|
261
|
+
const beforeMutations = await mutations(frame);
|
|
262
|
+
const beforeAttributes = await target.evaluate(attributesOf).catch(() => "");
|
|
263
|
+
const beforeUrl = frame.url();
|
|
264
|
+
try {
|
|
265
|
+
const timeout = Math.max(500, Math.min(2000, options.deadline - Date.now()));
|
|
266
|
+
if (control.use === "hover") await target.hover({ timeout });
|
|
267
|
+
else if (control.use === "context") await target.click({ button: "right", timeout });
|
|
268
|
+
else if (control.use === "fill") {
|
|
269
|
+
await target.fill(valueFor(control.type), { timeout });
|
|
270
|
+
await target.press("Tab", { timeout });
|
|
271
|
+
} else if (control.use === "toggle") {
|
|
272
|
+
await target.click({ timeout });
|
|
273
|
+
await target.press("Tab", { timeout }).catch(() => undefined);
|
|
274
|
+
} else if (control.use === "select") {
|
|
275
|
+
const option = await target.evaluate((element) => {
|
|
276
|
+
const select = element as HTMLSelectElement;
|
|
277
|
+
return [...select.options].find((entry) => !entry.selected && !entry.disabled)?.value;
|
|
278
|
+
});
|
|
279
|
+
if (option === undefined) return none;
|
|
280
|
+
await target.selectOption(option, { timeout });
|
|
281
|
+
await target.press("Tab", { timeout }).catch(() => undefined);
|
|
282
|
+
} else await target.click({ timeout });
|
|
283
|
+
} catch {
|
|
284
|
+
return none; // Covered, moving or gone: not a control the smoke test could use.
|
|
285
|
+
}
|
|
286
|
+
interactions += 1;
|
|
287
|
+
await settle(frame, network, options.deadline, 2000, 5000);
|
|
288
|
+
const navigated = frame.url() !== beforeUrl;
|
|
289
|
+
const afterMutations = await mutations(frame);
|
|
290
|
+
const newDocument = afterMutations === -1;
|
|
291
|
+
const afterAttributes = newDocument
|
|
292
|
+
? beforeAttributes
|
|
293
|
+
: await target.evaluate(attributesOf).catch(() => beforeAttributes);
|
|
294
|
+
// Whatever the page reported meanwhile (an error) is an effect too: judged as itself.
|
|
295
|
+
const reported = (await recorded(frame, seen)).length > 0;
|
|
296
|
+
const changed =
|
|
297
|
+
navigated ||
|
|
298
|
+
newDocument ||
|
|
299
|
+
reported ||
|
|
300
|
+
network.requestsSince(mark) > 0 ||
|
|
301
|
+
afterAttributes !== beforeAttributes ||
|
|
302
|
+
(afterMutations > beforeMutations && beforeMutations >= 0);
|
|
303
|
+
const moment = { kind: "interaction" as const, control: describe(control) };
|
|
304
|
+
if (newDocument) {
|
|
305
|
+
// The frame loaded a new document: its recorder started again, and the controls found on
|
|
306
|
+
// the old one are gone.
|
|
307
|
+
findings.push(...judge(await recorded(frame), moment, network.skippedSince(mark), viewport));
|
|
308
|
+
stopped = true;
|
|
309
|
+
} else {
|
|
310
|
+
const entries = await recorded(frame, seen);
|
|
311
|
+
seen += entries.length;
|
|
312
|
+
findings.push(...judge(entries, moment, network.skippedSince(mark), viewport));
|
|
313
|
+
if (depth < REVEAL_DEPTH) await exerciseRevealed(depth);
|
|
314
|
+
if (depth === 0) {
|
|
315
|
+
await page.keyboard.press("Escape").catch(() => undefined);
|
|
316
|
+
await page.mouse.move(0, 0).catch(() => undefined);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { used: true, changed, writes: network.writesSince(mark), navigated, newDocument, mark };
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const within = (): boolean =>
|
|
323
|
+
!stopped && interactions < options.maxInteractions && Date.now() < options.deadline;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Uses the controls the last interaction revealed. They are judged for what the page reports
|
|
327
|
+
* and for inputs that save nothing, never for having no effect: a second try would need their
|
|
328
|
+
* container opened again, and silence is the conservative answer (D72).
|
|
329
|
+
*/
|
|
330
|
+
const exerciseRevealed = async (depth: number): Promise<void> => {
|
|
331
|
+
if (!within() || frame.isDetached()) return;
|
|
332
|
+
const revealed = await frame
|
|
333
|
+
.evaluate(findControls, [MARK, next] as const)
|
|
334
|
+
.catch(() => [] as Control[]);
|
|
335
|
+
next += revealed.length;
|
|
336
|
+
for (const control of revealed) {
|
|
337
|
+
if (!within()) break;
|
|
338
|
+
if (control.use === "skip" || control.tag === "a") continue;
|
|
339
|
+
const outcome = await exercise(control, depth + 1);
|
|
340
|
+
if (!outcome.used) continue;
|
|
341
|
+
unbound(control, outcome);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/** Reports an input the smoke test changed whose value saved nothing (a hint). */
|
|
346
|
+
const unbound = (control: Control, outcome: Use): void => {
|
|
347
|
+
if (control.use !== "fill" && control.use !== "toggle" && control.use !== "select") return;
|
|
348
|
+
if (outcome.writes > 0 || outcome.navigated || outcome.newDocument) return;
|
|
349
|
+
findings.push(
|
|
350
|
+
createDiagnostic("MOSTRA_FIELD_UNBOUND", {
|
|
351
|
+
message: `The smoke test changed ${describe(control)} and the page saved nothing: the input isn't bound to a field.`,
|
|
352
|
+
location: { viewport, selector: `[${MARK}="${control.index}"]` },
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
};
|
|
356
|
+
/** Controls that changed nothing the first time: tried again once the others were used. */
|
|
357
|
+
const again: Control[] = [];
|
|
358
|
+
let used = 0;
|
|
359
|
+
// Links go last: following one may load a new document, which ends the test.
|
|
360
|
+
const links = usable.filter((control) => control.tag === "a");
|
|
361
|
+
for (const control of usable.filter((entry) => entry.tag !== "a")) {
|
|
362
|
+
if (!within()) break;
|
|
363
|
+
used += 1;
|
|
364
|
+
const outcome = await exercise(control);
|
|
365
|
+
if (!outcome.used) continue;
|
|
366
|
+
if (control.use === "fill" || control.use === "toggle" || control.use === "select") {
|
|
367
|
+
unbound(control, outcome);
|
|
368
|
+
} else if (!outcome.changed) again.push(control);
|
|
369
|
+
}
|
|
370
|
+
// A control can rightly do nothing the first time: the option already chosen, the filter
|
|
371
|
+
// already applied. After the others changed the page, it gets a second try; only one that
|
|
372
|
+
// changes nothing either time is reported (D72: observations, never errors).
|
|
373
|
+
for (const control of again) {
|
|
374
|
+
if (!within()) break;
|
|
375
|
+
const outcome = await exercise(control);
|
|
376
|
+
if (!outcome.used || outcome.changed) continue;
|
|
377
|
+
findings.push(
|
|
378
|
+
createDiagnostic("MOSTRA_SMOKE_NO_EFFECT", {
|
|
379
|
+
message: `Observed: ${control.use === "hover" ? "hovering over" : "clicking"} ${describe(control)} twice changed nothing verify could see (no change to the page, no request, no saved field, no navigation).`,
|
|
380
|
+
location: { viewport, selector: `[${MARK}="${control.index}"]` },
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
// A link to the page already shown rightly changes nothing, so links aren't judged for it.
|
|
385
|
+
for (const control of links) {
|
|
386
|
+
if (!within()) break;
|
|
387
|
+
used += 1;
|
|
388
|
+
await exercise(control);
|
|
389
|
+
}
|
|
390
|
+
budget = !stopped && used < usable.length;
|
|
391
|
+
if (budget) {
|
|
392
|
+
findings.push(
|
|
393
|
+
createDiagnostic("MOSTRA_SMOKE_BUDGET", {
|
|
394
|
+
message: `The smoke test stopped after ${interactions} interactions, at its budget of ${options.maxInteractions} interactions or 60 s; ${usable.length - used} controls weren't used.`,
|
|
395
|
+
location: { viewport },
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return { findings, interactions, stateChanges: network.writesSince(startMark), budget };
|
|
400
|
+
}
|