@g9tm/app-pilot 0.0.0-stage → 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 +16 -0
- package/package.json +58 -4
- package/src/args.ts +26 -0
- package/src/blackbox.ts +554 -0
- package/src/config.ts +182 -0
- package/src/design.ts +120 -0
- package/src/dispatch.ts +111 -0
- package/src/errors.ts +59 -0
- package/src/events.ts +86 -0
- package/src/har.ts +148 -0
- package/src/idle.ts +125 -0
- package/src/index.ts +105 -0
- package/src/lease.ts +145 -0
- package/src/oracles.ts +384 -0
- package/src/recorder.ts +382 -0
- package/src/registry.ts +98 -0
- package/src/scopes.ts +30 -0
- package/src/stage.ts +314 -0
- package/src/vitals.ts +196 -0
- package/README.md +0 -3
package/src/oracles.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// The always-awake oracles: while an agent or a runner drives the app, things that are wrong without anyone asking are
|
|
2
|
+
// noticed and kept — a JS error, a promise nobody caught, `console.error`, a busy token that never ended, a control a
|
|
3
|
+
// finger cannot reach, a button with no name, a translation key on screen instead of its text. A flow does not need a
|
|
4
|
+
// step for each; the controller reads them (`oracle.list`, or the `oracle.violation` events) and turns each into a
|
|
5
|
+
// finding with its evidence.
|
|
6
|
+
//
|
|
7
|
+
// installOracles({ rules: { 'i18n.latinDigits': 'warn' } }); // the built-in rules, the commands below
|
|
8
|
+
// reportViolation('sync.silentRejection', key, 'A refused change has no message on screen', { table, row });
|
|
9
|
+
// registerScreenCheck('layout.underBar', (nodes) => …); // an adapter's own screen rule
|
|
10
|
+
//
|
|
11
|
+
// oracle.list {since?, rule?, severity?} what was seen (one entry per rule + key, with a count), newest last
|
|
12
|
+
// oracle.check run the screen rules on the current screen now (also after each command)
|
|
13
|
+
// oracle.rules · oracle.set {rule, severity: finding|warn|off} · oracle.clear
|
|
14
|
+
//
|
|
15
|
+
// Platform-free: the screen rules read the `ui.snapshot` the adapter registered. Debug builds only, like every pilot module.
|
|
16
|
+
import { optionalString, requireString } from './args.ts';
|
|
17
|
+
import { onLogRecorded } from './blackbox.ts';
|
|
18
|
+
import { onAnswer } from './dispatch.ts';
|
|
19
|
+
import { emit } from './events.ts';
|
|
20
|
+
import { PilotError } from './errors.ts';
|
|
21
|
+
import { busySnapshot } from './idle.ts';
|
|
22
|
+
import { commandHandler, registerCommand } from './registry.ts';
|
|
23
|
+
|
|
24
|
+
export type OracleSeverity = 'finding' | 'warn' | 'off';
|
|
25
|
+
|
|
26
|
+
export interface OracleViolation {
|
|
27
|
+
/** Order of first sight (1, 2, …); `oracle.list {since}` pages by it. */
|
|
28
|
+
readonly seq: number;
|
|
29
|
+
readonly rule: string;
|
|
30
|
+
readonly severity: Exclude<OracleSeverity, 'off'>;
|
|
31
|
+
/** What makes two sightings the same (a control id, an error's first line). */
|
|
32
|
+
readonly key: string;
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly data: unknown;
|
|
35
|
+
readonly firstAt: number;
|
|
36
|
+
lastAt: number;
|
|
37
|
+
count: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A control as `ui.snapshot` lists it (the fields the screen rules read). */
|
|
41
|
+
export interface ScreenNode {
|
|
42
|
+
readonly automationId: string;
|
|
43
|
+
readonly type: string;
|
|
44
|
+
readonly name?: string | null;
|
|
45
|
+
readonly layer?: string;
|
|
46
|
+
readonly visible?: boolean;
|
|
47
|
+
readonly enabled?: boolean;
|
|
48
|
+
readonly bounds?: { readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly screenWidth?: number; readonly screenHeight?: number; readonly onScreen?: boolean } | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ScreenFinding {
|
|
52
|
+
readonly key: string;
|
|
53
|
+
readonly message: string;
|
|
54
|
+
readonly data?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ScreenCheck = (nodes: readonly ScreenNode[], context: ScreenContext) => readonly ScreenFinding[] | Promise<readonly ScreenFinding[]>;
|
|
58
|
+
|
|
59
|
+
export interface ScreenContext {
|
|
60
|
+
/** The page's language when the adapter knows it ('fa', 'en', …). */
|
|
61
|
+
readonly language: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The built-in rules and their default severity. */
|
|
65
|
+
export const DEFAULT_ORACLE_RULES: Readonly<Record<string, OracleSeverity>> = {
|
|
66
|
+
'js.error': 'finding',
|
|
67
|
+
'js.unhandledRejection': 'finding',
|
|
68
|
+
'console.error': 'warn',
|
|
69
|
+
'log.error': 'warn',
|
|
70
|
+
'busy.leak': 'finding',
|
|
71
|
+
'a11y.unlabeled': 'finding',
|
|
72
|
+
'i18n.rawKey': 'finding',
|
|
73
|
+
'i18n.latinDigits': 'warn',
|
|
74
|
+
'layout.offScreen': 'warn',
|
|
75
|
+
// vitals.ts: warn, not finding — a Debug build is slower than the release a person runs (a stall here may not be one there).
|
|
76
|
+
'perf.longFrame': 'warn',
|
|
77
|
+
'perf.memoryGrowth': 'warn',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A busy token held this long is a leak (the page will never be idle for a waiting runner). */
|
|
81
|
+
export const BUSY_LEAK_MS = 30_000;
|
|
82
|
+
const KEEP = 300;
|
|
83
|
+
|
|
84
|
+
/** Types a finger acts on: what must stay reachable. */
|
|
85
|
+
const TAPPABLE = new Set(['Button', 'IconButton', 'Tile', 'TabItem', 'Card', 'Switch', 'CheckBox', 'Entry', 'Picker', 'Link', 'MenuItem']);
|
|
86
|
+
/**
|
|
87
|
+
* Types whose name is a LABEL (what a screen reader says). A field's or a picker's snapshot name is its current value —
|
|
88
|
+
* empty until something is typed or chosen, which is no missing label (found on the first real run, 2026-11-04).
|
|
89
|
+
*/
|
|
90
|
+
const LABELLED = new Set(['Button', 'IconButton', 'Tile', 'TabItem', 'Card', 'Switch', 'CheckBox', 'Link', 'MenuItem']);
|
|
91
|
+
|
|
92
|
+
let rules: Record<string, OracleSeverity> = { ...DEFAULT_ORACLE_RULES };
|
|
93
|
+
const seen = new Map<string, OracleViolation>();
|
|
94
|
+
const screenChecks = new Map<string, ScreenCheck>();
|
|
95
|
+
let seq = 0;
|
|
96
|
+
let languageOf: () => string | null = () => null;
|
|
97
|
+
let rawKeyTest: (text: string) => boolean = looksLikeKey;
|
|
98
|
+
|
|
99
|
+
let oraclesEnabled = true;
|
|
100
|
+
/** The configuration's `oracles.enabled` (config.ts): off, every rule reports nothing. */
|
|
101
|
+
export function setOraclesEnabled(on: boolean): void {
|
|
102
|
+
oraclesEnabled = on;
|
|
103
|
+
}
|
|
104
|
+
const severityOf = (rule: string): OracleSeverity => (oraclesEnabled ? (rules[rule] ?? 'finding') : 'off');
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reports what a rule saw. The same rule + key again only counts (the first sighting is the evidence); a rule set to
|
|
108
|
+
* 'off' is dropped. Each NEW violation is an `oracle.violation` event. Returns the violation, or null when off.
|
|
109
|
+
*/
|
|
110
|
+
export function reportViolation(rule: string, key: string, message: string, data: unknown = null): OracleViolation | null {
|
|
111
|
+
const severity = severityOf(rule);
|
|
112
|
+
if (severity === 'off') return null;
|
|
113
|
+
const id = `${rule}\u0000${key}`;
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const known = seen.get(id);
|
|
116
|
+
if (known) {
|
|
117
|
+
known.count++;
|
|
118
|
+
known.lastAt = now;
|
|
119
|
+
return known;
|
|
120
|
+
}
|
|
121
|
+
const v: OracleViolation = { seq: ++seq, rule, severity, key, message: message.slice(0, 400), data, firstAt: now, lastAt: now, count: 1 };
|
|
122
|
+
seen.set(id, v);
|
|
123
|
+
if (seen.size > KEEP) seen.delete(seen.keys().next().value as string);
|
|
124
|
+
emit('oracle.violation', { seq: v.seq, rule, severity, key, message: v.message });
|
|
125
|
+
return v;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** An adapter's or an app's own screen rule (it runs with the built-in ones). Returns the remover. */
|
|
129
|
+
export function registerScreenCheck(rule: string, check: ScreenCheck): () => void {
|
|
130
|
+
screenChecks.set(rule, check);
|
|
131
|
+
return () => {
|
|
132
|
+
if (screenChecks.get(rule) === check) screenChecks.delete(rule);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- the built-in screen rules (pure; exported for tests) ------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** A translation key on screen instead of its text: «MapFarmSelectorTitle» (PascalCase, two humps or more, no spaces). */
|
|
139
|
+
export function looksLikeKey(text: string): boolean {
|
|
140
|
+
const t = text.trim();
|
|
141
|
+
return t.length >= 8 && !/\s/.test(t) && /^[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*){2,}$/.test(t);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The Arabic-script block U+0600–U+06FF (written as its two characters: the range's ends).
|
|
145
|
+
const PERSIAN = /[-ۿ]/;
|
|
146
|
+
// Latin digits standing as a NUMBER in Persian text — a version («6.2.13»), an id («C2»), a unit after a number and
|
|
147
|
+
// anything in an LTR isolate are a different matter, so only a bare run of digits between Persian words counts.
|
|
148
|
+
const LATIN_NUMBER = /(^|[\s-ۿ،:])([0-9]{1,6})(?=$|[\s-ۿ،:)])/;
|
|
149
|
+
|
|
150
|
+
export function unlabeledControls(nodes: readonly ScreenNode[]): ScreenFinding[] {
|
|
151
|
+
return nodes
|
|
152
|
+
.filter((n) => n.visible !== false && LABELLED.has(n.type) && !(n.name ?? '').trim())
|
|
153
|
+
.map((n) => ({ key: n.automationId, message: `«${n.automationId}» (${n.type}) has no name: a screen reader says nothing and an agent cannot find it by text.`, data: { automationId: n.automationId, type: n.type } }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function rawKeysOnScreen(nodes: readonly ScreenNode[], isKey: (text: string) => boolean = looksLikeKey): ScreenFinding[] {
|
|
157
|
+
return nodes
|
|
158
|
+
.filter((n) => n.visible !== false && !!n.name && isKey(n.name))
|
|
159
|
+
.map((n) => ({ key: `${n.automationId}:${n.name}`, message: `«${n.automationId}» shows the key «${n.name}», not its text (a missing translation).`, data: { automationId: n.automationId, text: n.name } }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function latinDigitsInPersian(nodes: readonly ScreenNode[], language: string | null): ScreenFinding[] {
|
|
163
|
+
if (language !== 'fa') return [];
|
|
164
|
+
return nodes
|
|
165
|
+
.filter((n) => n.visible !== false && !!n.name && PERSIAN.test(n.name) && LATIN_NUMBER.test(n.name))
|
|
166
|
+
.map((n) => ({ key: `${n.automationId}:${n.name}`, message: `«${n.automationId}» shows a Latin number in Persian text: «${(n.name ?? '').slice(0, 80)}».`, data: { automationId: n.automationId, text: n.name } }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function offScreenControls(nodes: readonly ScreenNode[]): ScreenFinding[] {
|
|
170
|
+
const out: ScreenFinding[] = [];
|
|
171
|
+
for (const n of nodes) {
|
|
172
|
+
const b = n.bounds;
|
|
173
|
+
if (n.visible === false || !b || !TAPPABLE.has(n.type) || !b.screenWidth) continue;
|
|
174
|
+
const cut = b.x < -1 || b.x + b.width > b.screenWidth + 1;
|
|
175
|
+
if (cut && b.x + b.width > 0 && b.x < b.screenWidth) {
|
|
176
|
+
out.push({ key: n.automationId, message: `«${n.automationId}» is cut by the screen's side edge (x ${Math.round(b.x)}, width ${Math.round(b.width)} of ${Math.round(b.screenWidth)}).`, data: { automationId: n.automationId, bounds: b } });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const BUILT_IN: ReadonlyArray<readonly [string, ScreenCheck]> = [
|
|
183
|
+
['a11y.unlabeled', (nodes) => unlabeledControls(nodes)],
|
|
184
|
+
['i18n.rawKey', (nodes) => rawKeysOnScreen(nodes, rawKeyTest)],
|
|
185
|
+
['i18n.latinDigits', (nodes, ctx) => latinDigitsInPersian(nodes, ctx.language)],
|
|
186
|
+
['layout.offScreen', (nodes) => offScreenControls(nodes)],
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
/** Runs every screen rule on the current screen now; returns how many violations were new. */
|
|
190
|
+
export async function checkScreen(): Promise<number> {
|
|
191
|
+
const snapshot = commandHandler('ui.snapshot');
|
|
192
|
+
if (!snapshot) return 0;
|
|
193
|
+
let nodes: readonly ScreenNode[];
|
|
194
|
+
try {
|
|
195
|
+
const result = (await snapshot({ maxNodes: 400 }, { timeoutMs: 5_000, leaseId: null, kid: null, scopes: ['observe'] })) as { nodes?: ScreenNode[] };
|
|
196
|
+
nodes = result?.nodes ?? [];
|
|
197
|
+
} catch {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
const context: ScreenContext = { language: languageOf() };
|
|
201
|
+
const before = seq;
|
|
202
|
+
for (const [rule, check] of [...BUILT_IN, ...screenChecks]) {
|
|
203
|
+
if (severityOf(rule) === 'off') continue;
|
|
204
|
+
try {
|
|
205
|
+
for (const f of await check(nodes, context)) reportViolation(rule, f.key, f.message, f.data ?? null);
|
|
206
|
+
} catch {
|
|
207
|
+
// one rule failing never stops the others
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return seq - before;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- install ---------------------------------------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export interface OracleOptions {
|
|
216
|
+
/** Severity per rule (merged over DEFAULT_ORACLE_RULES); 'off' switches one off. */
|
|
217
|
+
readonly rules?: Readonly<Record<string, OracleSeverity>>;
|
|
218
|
+
/** The page's language for the language rules. */
|
|
219
|
+
readonly language?: () => string | null;
|
|
220
|
+
/** What a translation key looks like in this app (default `looksLikeKey`). */
|
|
221
|
+
readonly isRawKey?: (text: string) => boolean;
|
|
222
|
+
/** Listen to the JS runtime's uncaught errors and rejections and to console.error (default true). */
|
|
223
|
+
readonly runtime?: boolean;
|
|
224
|
+
/** Check the screen after every command a controller sends (throttled; default true). */
|
|
225
|
+
readonly afterCommands?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* The console the page's code calls (default globalThis.console). A bundle whose runtime injects `console` into the
|
|
228
|
+
* module scope (Lynx) passes its own: wrapping the global one would miss every call the app makes.
|
|
229
|
+
*/
|
|
230
|
+
readonly consoleTarget?: { error?: (...args: unknown[]) => void };
|
|
231
|
+
/**
|
|
232
|
+
* Which rule an error line of the app's log belongs to (default `log.error`): a host that hands uncaught errors to the
|
|
233
|
+
* app's logger says which lines are `js.error` / `js.unhandledRejection` — and the key that tells them apart.
|
|
234
|
+
*/
|
|
235
|
+
readonly logRule?: (text: string) => { readonly rule: string; readonly key?: string; readonly message?: string } | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The built-in rules and the oracle commands. Returns the uninstaller. */
|
|
239
|
+
export function installOracles(options: OracleOptions = {}): () => void {
|
|
240
|
+
rules = { ...DEFAULT_ORACLE_RULES, ...(options.rules ?? {}) };
|
|
241
|
+
if (options.language) languageOf = options.language;
|
|
242
|
+
if (options.isRawKey) rawKeyTest = options.isRawKey;
|
|
243
|
+
const undo: Array<() => void> = [];
|
|
244
|
+
if (options.runtime !== false) undo.push(installRuntimeHooks(options.consoleTarget));
|
|
245
|
+
// What the app's own logger says is an error (the black box's log): the one path every host has — a Lynx bundle's
|
|
246
|
+
// console is a host object a wrapper cannot replace, its uncaught errors go to the native host.
|
|
247
|
+
undo.push(
|
|
248
|
+
onLogRecorded((entry) => {
|
|
249
|
+
if (entry.level !== 'error') return;
|
|
250
|
+
const line = entry.text.split('\n')[0]?.slice(0, 200) ?? '';
|
|
251
|
+
const mapped = options.logRule?.(entry.text) ?? null;
|
|
252
|
+
if (mapped) reportViolation(mapped.rule, mapped.key ?? line, mapped.message ?? line, { seq: entry.seq, text: entry.text.slice(0, 600) });
|
|
253
|
+
else reportViolation('log.error', line, `The app logged an error: ${line}`, { seq: entry.seq });
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
// A busy token that never ends: sampled, so a long but honest wait is not reported before BUSY_LEAK_MS.
|
|
257
|
+
const busyTimer = setInterval(() => {
|
|
258
|
+
const busy = busySnapshot();
|
|
259
|
+
if (busy.idle || busy.oldestMs < BUSY_LEAK_MS) return;
|
|
260
|
+
// reasons[0] is the oldest open one: `name (12.3s)` or `name ×2 (40.1s) — suspected leak`.
|
|
261
|
+
const name = (busy.reasons[0] ?? 'unknown').replace(/\s*(?:×\d+\s*)?\(.*$/, '').trim() || 'unknown';
|
|
262
|
+
reportViolation('busy.leak', name, `The busy reason «${name}» has been open ${Math.round(busy.oldestMs / 1000)} s: a runner waiting for idle waits for ever.`, { reasons: busy.reasons, oldestMs: busy.oldestMs });
|
|
263
|
+
}, 5_000);
|
|
264
|
+
undo.push(() => clearInterval(busyTimer));
|
|
265
|
+
if (options.afterCommands !== false) {
|
|
266
|
+
let last = 0;
|
|
267
|
+
let pending = false;
|
|
268
|
+
undo.push(
|
|
269
|
+
onAnswer((request) => {
|
|
270
|
+
if (request.command.startsWith('oracle.') || pending || Date.now() - last < 1_500) return;
|
|
271
|
+
pending = true;
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
last = Date.now();
|
|
274
|
+
void checkScreen().finally(() => {
|
|
275
|
+
pending = false;
|
|
276
|
+
});
|
|
277
|
+
}, 400);
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
undo.push(
|
|
282
|
+
registerCommand(
|
|
283
|
+
'oracle.list',
|
|
284
|
+
(args) => {
|
|
285
|
+
const since = typeof args.since === 'number' ? args.since : 0;
|
|
286
|
+
const rule = optionalString(args, 'rule');
|
|
287
|
+
const severity = optionalString(args, 'severity');
|
|
288
|
+
const list = [...seen.values()].filter((v) => v.seq > since && (!rule || v.rule === rule) && (!severity || v.severity === severity)).sort((a, b) => a.seq - b.seq);
|
|
289
|
+
return { violations: list, last: seq, findings: list.filter((v) => v.severity === 'finding').length };
|
|
290
|
+
},
|
|
291
|
+
{ scope: 'observe', description: 'What the oracles saw: one entry per rule + key with a count, newest last. Page with `since` (the returned `last`).', example: { since: 0, severity: 'finding' } },
|
|
292
|
+
),
|
|
293
|
+
registerCommand('oracle.check', async () => ({ fresh: await checkScreen(), last: seq }), { scope: 'observe', description: 'Runs the screen rules on the current screen now.' }),
|
|
294
|
+
registerCommand('oracle.rules', () => ({ rules: { ...rules }, screenRules: [...BUILT_IN.map(([r]) => r), ...screenChecks.keys()] }), { scope: 'observe', description: 'Every rule and its severity.' }),
|
|
295
|
+
registerCommand(
|
|
296
|
+
'oracle.set',
|
|
297
|
+
(args) => {
|
|
298
|
+
const rule = requireString(args, 'rule');
|
|
299
|
+
const severity = requireString(args, 'severity');
|
|
300
|
+
if (severity !== 'finding' && severity !== 'warn' && severity !== 'off') throw new PilotError('oracle.set needs severity finding, warn or off.', { code: 'BAD_ARGS', fixExample: { command: 'oracle.set', args: { rule, severity: 'off' } } });
|
|
301
|
+
rules[rule] = severity;
|
|
302
|
+
return { rule, severity };
|
|
303
|
+
},
|
|
304
|
+
{ scope: 'stage', description: 'One rule’s severity until the app restarts (finding, warn or off).', example: { rule: 'i18n.latinDigits', severity: 'off' } },
|
|
305
|
+
),
|
|
306
|
+
registerCommand(
|
|
307
|
+
'oracle.clear',
|
|
308
|
+
() => {
|
|
309
|
+
const n = seen.size;
|
|
310
|
+
seen.clear();
|
|
311
|
+
return { cleared: n, last: seq };
|
|
312
|
+
},
|
|
313
|
+
{ scope: 'stage', description: 'Forgets what was seen (a flow’s start); the numbering goes on.' },
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
return () => {
|
|
317
|
+
for (const u of undo.reverse()) u();
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The runtime's own reports: uncaught errors, promises nobody caught, console.error. */
|
|
322
|
+
function installRuntimeHooks(consoleTarget?: { error?: (...a: unknown[]) => void }): () => void {
|
|
323
|
+
const g = globalThis as unknown as {
|
|
324
|
+
console?: { error?: (...a: unknown[]) => void };
|
|
325
|
+
addEventListener?: (type: string, fn: (e: unknown) => void) => void;
|
|
326
|
+
removeEventListener?: (type: string, fn: (e: unknown) => void) => void;
|
|
327
|
+
};
|
|
328
|
+
const undo: Array<() => void> = [];
|
|
329
|
+
const firstLine = (v: unknown): string => {
|
|
330
|
+
const s = v instanceof Error ? `${v.name}: ${v.message}` : typeof v === 'string' ? v : (() => {
|
|
331
|
+
try {
|
|
332
|
+
return JSON.stringify(v);
|
|
333
|
+
} catch {
|
|
334
|
+
return String(v);
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
return (s ?? '').split('\n')[0]?.slice(0, 200) ?? '';
|
|
338
|
+
};
|
|
339
|
+
const stackOf = (v: unknown): string | null => (v instanceof Error && v.stack ? v.stack.split('\n').slice(0, 6).join('\n') : null);
|
|
340
|
+
const target = consoleTarget ?? g.console;
|
|
341
|
+
const original = target?.error;
|
|
342
|
+
if (target && original) {
|
|
343
|
+
target.error = (...args: unknown[]) => {
|
|
344
|
+
try {
|
|
345
|
+
const line = args.map(firstLine).join(' ').slice(0, 200);
|
|
346
|
+
reportViolation('console.error', line, `console.error: ${line}`, { stack: stackOf(args.find((a) => a instanceof Error)) });
|
|
347
|
+
} catch {
|
|
348
|
+
// the oracle never breaks the console
|
|
349
|
+
}
|
|
350
|
+
original.apply(target, args);
|
|
351
|
+
};
|
|
352
|
+
undo.push(() => {
|
|
353
|
+
target.error = original;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (typeof g.addEventListener === 'function') {
|
|
357
|
+
const onError = (e: unknown) => {
|
|
358
|
+
const err = (e as { error?: unknown; message?: string })?.error ?? (e as { message?: string })?.message ?? e;
|
|
359
|
+
reportViolation('js.error', firstLine(err), `Uncaught: ${firstLine(err)}`, { stack: stackOf(err) });
|
|
360
|
+
};
|
|
361
|
+
const onRejection = (e: unknown) => {
|
|
362
|
+
const reason = (e as { reason?: unknown })?.reason ?? e;
|
|
363
|
+
reportViolation('js.unhandledRejection', firstLine(reason), `A promise nobody caught: ${firstLine(reason)}`, { stack: stackOf(reason) });
|
|
364
|
+
};
|
|
365
|
+
g.addEventListener('error', onError);
|
|
366
|
+
g.addEventListener('unhandledrejection', onRejection);
|
|
367
|
+
undo.push(() => {
|
|
368
|
+
g.removeEventListener?.('error', onError);
|
|
369
|
+
g.removeEventListener?.('unhandledrejection', onRejection);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return () => {
|
|
373
|
+
for (const u of undo) u();
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function resetOraclesForTests(): void {
|
|
378
|
+
seen.clear();
|
|
379
|
+
screenChecks.clear();
|
|
380
|
+
seq = 0;
|
|
381
|
+
rules = { ...DEFAULT_ORACLE_RULES };
|
|
382
|
+
languageOf = () => null;
|
|
383
|
+
rawKeyTest = looksLikeKey;
|
|
384
|
+
}
|