browserzap 0.5.5 → 0.6.1

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,287 @@
1
+ 'use strict';
2
+
3
+ const { LIMITS } = require('./limits');
4
+
5
+ /**
6
+ * Resolver engine — the registry (0.6.0, D-37).
7
+ *
8
+ * Pure: no I/O, no page access, no timers, no clocks. Given a failed (or
9
+ * ineffective) workflow step and the envelope the extension returned, say what
10
+ * the step is blocked on and what would clear it. The runner then waits for
11
+ * that condition as an ordinary `wait_for_state` call and retries the same
12
+ * step — so every wait and every retry still goes through the normal tool path
13
+ * and extension policy.
14
+ *
15
+ * Design: docs/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
16
+ * the wait kinds it relies on: docs/ADR/ADR-007-waiting-and-timeouts.md.
17
+ *
18
+ * Adding a resolver is one entry here plus a fixture that fails without it.
19
+ * The validation rules at the bottom are checked by tests/resolvers.test.mjs.
20
+ */
21
+
22
+ /** A change here that alters behaviour resets learned demotions (step profile). */
23
+ const REGISTRY_VERSION = 1;
24
+
25
+ /**
26
+ * Codes that are never auto-resolved: a person, a policy, or a unique target is
27
+ * needed. A resolver that claimed one of these would be guessing.
28
+ */
29
+ const FORBIDDEN_CODES = Object.freeze([
30
+ 'USER_CONFIRMATION_REQUIRED',
31
+ 'USER_ACTION_REQUIRED',
32
+ 'POLICY_BLOCKED',
33
+ 'TARGET_AMBIGUOUS',
34
+ ]);
35
+
36
+ /** Risks that must never be repeated in place (audit AU-08). */
37
+ const CONSEQUENTIAL_RISKS = new Set(['external_side_effect', 'destructive', 'financial']);
38
+
39
+ /** Tools a resolver may retry in place. A read-only tool is never a workflow step. */
40
+ const RETRYABLE_TOOLS = new Set([
41
+ 'browser_navigate', 'element_click', 'field_fill', 'select_option', 'wait_for_state', 'browser_press_key',
42
+ ]);
43
+
44
+ /**
45
+ * Failures that prove the action never ran: the target was missing, unusable or
46
+ * a stale ref. Only these may be retried for a click or a key press; anything
47
+ * else may already have submitted something (AU-08).
48
+ */
49
+ const ACTION_DID_NOT_RUN_CODES = new Set(['TARGET_NOT_FOUND', 'TARGET_NOT_INTERACTABLE', 'STALE_SNAPSHOT']);
50
+
51
+ /**
52
+ * Failures a resolver may act on: the page has not got there yet. Anything else
53
+ * — a bad argument, a Pro gate, a missing workflow, a cancelled run — is
54
+ * deterministic, and waiting for the page cannot change it (ADR-007: only
55
+ * failures that can clear up are waited on). A successful call that changed
56
+ * nothing has no code at all and is classified by its evidence.
57
+ */
58
+ const TRANSIENT_CODES = Object.freeze([
59
+ 'TIMEOUT', 'TARGET_NOT_FOUND', 'TARGET_NOT_INTERACTABLE', 'STALE_SNAPSHOT',
60
+ 'NAVIGATION_FAILED', 'BRIDGE_DISCONNECTED', 'POSTCONDITION_NOT_MET', 'SITE_ERROR',
61
+ ]);
62
+
63
+ /** The wait kinds the extension understands (registered in doWaitFor). */
64
+ const WAIT_KINDS = Object.freeze(['urlContains', 'text', 'target', 'targetGone', 'actionable', 'indicatorGone', 'networkQuiet', 'requestDone']);
65
+
66
+ /**
67
+ * Ordered: the more specific answer wins. The condition hierarchy follows the
68
+ * design — the step's own expectation, then actionability, then a specific
69
+ * request, then a UI signal, then global network quiet (as a fallback only).
70
+ */
71
+ const RESOLVERS = Object.freeze([
72
+ {
73
+ id: 'expectation_unmet',
74
+ safety: 'retry_reversible',
75
+ then: 'retry_step',
76
+ // About what the step produced, so it can never be a pre-wait: an
77
+ // expectation cannot be there before the step that produces it runs.
78
+ promotable: false,
79
+ budgetMs: { floor: 500, default: 5000, ceiling: 30000 },
80
+ matches: ({ evidence, step }) => Boolean(step.expect) && evidence.expectationMet === false,
81
+ wait: ({ step }) => waitForRecordedExpectation(step.expect),
82
+ },
83
+ {
84
+ id: 'click_no_effect',
85
+ safety: 'retry_reversible',
86
+ then: 'retry_step',
87
+ budgetMs: { floor: 500, default: 3000, ceiling: 10000 },
88
+ // The narrow AU-08 exception: reversible, nothing visible changed, and no
89
+ // request went out. A click that started a request is waited on instead.
90
+ matches: ({ evidence, step }) => step.tool === 'element_click'
91
+ && evidence.noObservableChange === true
92
+ && evidence.risk === 'reversible'
93
+ && Number(evidence.requestsStarted ?? 0) === 0,
94
+ wait: ({ step }) => ({ actionable: step.args.target, networkQuiet: true }),
95
+ },
96
+ {
97
+ id: 'target_not_ready',
98
+ safety: 'retry_reversible',
99
+ then: 'retry_step',
100
+ budgetMs: { floor: 500, default: 5000, ceiling: 20000 },
101
+ matches: ({ code }) => code === 'TARGET_NOT_INTERACTABLE',
102
+ wait: ({ step }) => ({ actionable: step.args.target }),
103
+ },
104
+ {
105
+ id: 'target_late',
106
+ safety: 'retry_reversible',
107
+ then: 'retry_step',
108
+ budgetMs: { floor: 500, default: 8000, ceiling: 30000 },
109
+ matches: ({ code, evidence }) => code === 'TARGET_NOT_FOUND'
110
+ && (Number(evidence.pendingRequests ?? 0) > 0 || Boolean(evidence.busyIndicator)),
111
+ wait: ({ step }) => ({ target: step.args.target }),
112
+ },
113
+ {
114
+ id: 'api_pending',
115
+ safety: 'retry_reversible',
116
+ then: 'retry_step',
117
+ budgetMs: { floor: 1000, default: 15000, ceiling: 45000 },
118
+ matches: ({ evidence }) => evidence.likelyCause === 'server_slow'
119
+ || Number(evidence.pendingRequests ?? 0) > 0
120
+ || Boolean(evidence.slowestRequest),
121
+ // The step's own recorded request is the strongest signal; global quiet is
122
+ // only the capped fallback.
123
+ wait: ({ step }) => (step.expect && step.expect.request
124
+ ? { requestDone: step.expect.request }
125
+ : { networkQuiet: true }),
126
+ },
127
+ {
128
+ id: 'still_rendering',
129
+ safety: 'retry_reversible',
130
+ then: 'retry_step',
131
+ budgetMs: { floor: 500, default: 10000, ceiling: 30000 },
132
+ matches: ({ evidence }) => Boolean(evidence.busyIndicator) || evidence.likelyCause === 'still_rendering',
133
+ wait: () => ({ indicatorGone: true }),
134
+ },
135
+ {
136
+ id: 'site_error',
137
+ safety: 'group',
138
+ then: 'retry_group',
139
+ budgetMs: { floor: 0, default: 0, ceiling: 0 },
140
+ matches: ({ code, evidence }) => code === 'SITE_ERROR' || evidence.likelyCause === 'site_error',
141
+ wait: () => null,
142
+ },
143
+ {
144
+ id: 'context_lost',
145
+ safety: 'group',
146
+ then: 'retry_group',
147
+ budgetMs: { floor: 0, default: 0, ceiling: 0 },
148
+ matches: ({ evidence }) => evidence.contextLost === true,
149
+ wait: () => null,
150
+ },
151
+ ]);
152
+
153
+ /** The wait args for a recorded expectation (`appears` and `request` can combine). */
154
+ function waitForRecordedExpectation(expect) {
155
+ if (!expect) return null;
156
+ const args = {};
157
+ if (expect.appears) args.target = expect.appears;
158
+ if (expect.request && expect.request.pathContains) args.requestDone = expect.request;
159
+ return Object.keys(args).length > 0 ? args : null;
160
+ }
161
+
162
+ /**
163
+ * How long the recorded-expectation wait may look for its evidence: the
164
+ * person's `maxWaitMs` when set, otherwise the settle the recorder measured
165
+ * (the learned pace for that step), otherwise the default expectation timeout.
166
+ * It is the budget of a bounded poll that ends the moment the evidence is
167
+ * there — never a pause (A6-10). Clamped to the shared limits.
168
+ */
169
+ function expectBudgetMs(expect, maxWaitMs = null) {
170
+ const asked = Number.isFinite(maxWaitMs) && maxWaitMs > 0 ? maxWaitMs : null;
171
+ const recorded = Number.isFinite(expect?.settleMs) && expect.settleMs > 0 ? expect.settleMs : null;
172
+ const ms = asked ?? recorded ?? LIMITS.DEFAULT_EXPECT_TIMEOUT_MS;
173
+ return Math.max(LIMITS.SETTLE_QUIET_MS, Math.min(ms, LIMITS.MAX_WAIT_MS));
174
+ }
175
+
176
+ /** The failure a classifier works from, normalised from either envelope shape. */
177
+ function normalise(env) {
178
+ return {
179
+ code: env?.error?.code ?? env?.code ?? null,
180
+ evidence: env?.evidence ?? {},
181
+ state: env?.state ?? (env?.ok === false ? 'failed' : 'completed'),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * What is this step blocked on? Returns `{ id, safety, then, wait, budgetMs }`,
187
+ * or null when nothing safe could clear it (a person, a policy, ambiguity, or
188
+ * an unknown failure). A resolver whose `wait` is null only selects the
189
+ * existing group-recovery path.
190
+ */
191
+ function classify(env, step, options = {}) {
192
+ const failure = normalise(env);
193
+ if (!step || !RETRYABLE_TOOLS.has(step.tool)) return null;
194
+ if (failure.code && FORBIDDEN_CODES.includes(failure.code)) return null;
195
+ if (failure.code && !TRANSIENT_CODES.includes(failure.code)) return null;
196
+ const demoted = Array.isArray(options.demoted) ? options.demoted : [];
197
+ for (const resolver of RESOLVERS) {
198
+ if (demoted.includes(resolver.id)) continue;
199
+ if (!resolver.matches({ ...failure, step })) continue;
200
+ const wait = resolver.wait({ step, evidence: failure.evidence });
201
+ if (resolver.then === 'retry_step' && !wait) return null;
202
+ return {
203
+ id: resolver.id,
204
+ safety: resolver.safety,
205
+ then: resolver.then,
206
+ wait,
207
+ budgetMs: resolveBudget(resolver, options),
208
+ };
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /** One resolver by id, for a step profile that learned to pre-wait. */
214
+ function resolverById(id) {
215
+ return RESOLVERS.find((resolver) => resolver.id === id && resolver.then === 'retry_step') ?? null;
216
+ }
217
+
218
+ /** The wait args for a resolver id applied to this step (promotion from the step profile). */
219
+ function waitArgsFor(id, step) {
220
+ const resolver = resolverById(id);
221
+ return resolver ? resolver.wait({ step, evidence: {} }) : null;
222
+ }
223
+
224
+ /**
225
+ * May a step profile wait for this blocker *before* acting? Only a blocker about
226
+ * what the step is waiting on — never one about what it produces.
227
+ */
228
+ function promotable(id) {
229
+ const resolver = resolverById(id);
230
+ return Boolean(resolver) && resolver.promotable !== false;
231
+ }
232
+
233
+ /**
234
+ * How long to wait for this resolver, in this order: the resolver's own default,
235
+ * the step's learned p95 × 1.5 (once it has ≥ 3 runs), the person's patience,
236
+ * an explicit cap, then the resolver's floor/ceiling.
237
+ */
238
+ function resolveBudget(resolver, { learnedMs = null, patience = 'normal', maxWaitMs = null } = {}) {
239
+ const { floor, default: fallback, ceiling } = resolver.budgetMs;
240
+ let ms = fallback;
241
+ if (Number.isFinite(learnedMs) && learnedMs > 0) ms = Math.round(learnedMs * 1.5);
242
+ const factor = patience === 'fast' ? 0.5 : patience === 'patient' ? 3 : 1;
243
+ ms = Math.round(ms * factor);
244
+ if (Number.isFinite(maxWaitMs) && maxWaitMs > 0) ms = Math.min(ms, maxWaitMs);
245
+ return Math.max(floor, Math.min(ms, ceiling));
246
+ }
247
+
248
+ /**
249
+ * May this step be retried in place under AU-08?
250
+ *
251
+ * - the resolver must actually retry the step;
252
+ * - its tool must be one a workflow may repeat;
253
+ * - a consequential step is never repeated, whatever the failure code;
254
+ * - a click or key press is repeated only when the action provably did not run,
255
+ * or under the click_no_effect resolver's own narrow rule;
256
+ * - everything else (fills, selects, navigations, waits) is safe to repeat.
257
+ */
258
+ function mayRetryInPlace(resolver, env, step) {
259
+ if (!resolver || resolver.then !== 'retry_step') return false;
260
+ if (!step || !RETRYABLE_TOOLS.has(step.tool)) return false;
261
+ const { code, evidence } = normalise(env);
262
+ if (CONSEQUENTIAL_RISKS.has(evidence.risk)) return false;
263
+ if (resolver.id === 'click_no_effect') return true;
264
+ if (step.tool === 'element_click' || step.tool === 'browser_press_key') {
265
+ return ACTION_DID_NOT_RUN_CODES.has(code);
266
+ }
267
+ return true;
268
+ }
269
+
270
+ module.exports = {
271
+ REGISTRY_VERSION,
272
+ FORBIDDEN_CODES,
273
+ CONSEQUENTIAL_RISKS,
274
+ RETRYABLE_TOOLS,
275
+ ACTION_DID_NOT_RUN_CODES,
276
+ TRANSIENT_CODES,
277
+ WAIT_KINDS,
278
+ RESOLVERS,
279
+ classify,
280
+ resolveBudget,
281
+ mayRetryInPlace,
282
+ resolverById,
283
+ promotable,
284
+ waitArgsFor,
285
+ waitForRecordedExpectation,
286
+ expectBudgetMs,
287
+ };
@@ -48,6 +48,18 @@ const EXPECT = {
48
48
 
49
49
  const TIMEOUT = { type: 'integer', minimum: 100, maximum: LIMITS.MAX_WAIT_MS, description: `Milliseconds (max ${LIMITS.MAX_WAIT_MS}).` };
50
50
 
51
+ /** A request to wait for: method and a fragment of its path. Query strings are never matched. */
52
+ const REQUEST_PATTERN = {
53
+ type: 'object',
54
+ additionalProperties: false,
55
+ required: ['pathContains'],
56
+ description: 'A request to wait for, by method and a fragment of its path (no query strings).',
57
+ properties: {
58
+ method: { type: 'string', maxLength: 16, description: 'HTTP method, e.g. GET. Any method when omitted.' },
59
+ pathContains: { type: 'string', minLength: 1, maxLength: 200, description: 'A fragment of the request path, e.g. /api/accounts.' },
60
+ },
61
+ };
62
+
51
63
  /** A saved step's own pacing: a deliberate pause the person set, never added by BrowserZap. */
52
64
  const PACING = {
53
65
  type: 'integer', minimum: 0, maximum: LIMITS.MAX_STEP_PACING_MS,
@@ -201,8 +213,10 @@ const MCP_TOOLS = [
201
213
  {
202
214
  name: 'wait_for_state',
203
215
  description:
204
- 'Wait (bounded) until the URL contains a string, some text appears, or an element appears/disappears. ' +
205
- 'Use instead of fixed sleeps.',
216
+ 'Wait (bounded, polled) until the page reaches a state: the URL contains a string, text appears, an element appears ' +
217
+ 'or disappears, an element becomes actionable (visible, enabled, uncovered and stable), a spinner goes away, ' +
218
+ 'the network goes quiet, or a specific request answers. Use instead of fixed sleeps. With several conditions, ' +
219
+ 'all must hold at once.',
206
220
  inputSchema: {
207
221
  type: 'object',
208
222
  additionalProperties: false,
@@ -212,6 +226,10 @@ const MCP_TOOLS = [
212
226
  text: { type: 'string', maxLength: 500 },
213
227
  target: TARGET,
214
228
  targetGone: TARGET,
229
+ actionable: { ...TARGET, description: 'An element that must be visible, enabled, uncovered and not moving.' },
230
+ indicatorGone: { type: 'boolean', description: 'Wait until no visible spinner, progress bar or "Loading…" indicator remains.' },
231
+ networkQuiet: { type: 'boolean', description: 'Wait until no tracked request is in flight and the page has been quiet for a moment.' },
232
+ requestDone: REQUEST_PATTERN,
215
233
  timeoutMs: TIMEOUT,
216
234
  },
217
235
  },
@@ -383,8 +401,9 @@ const MCP_TOOLS = [
383
401
  'Save a workflow from explicit steps. Each step is { tool, args } with tool one of browser_navigate, element_click, ' +
384
402
  'field_fill, select_option, wait_for_state, browser_press_key, browser_scroll and args exactly as that tool takes them ' +
385
403
  '(no refs, no approvalId). Use "{{param}}" inside strings for values given at run time. A step may also carry ' +
386
- 'waitBeforeMs (give a slow site that long before this step) and retry (repeat a group of steps on failure); ' +
387
- 'pace.waitBeforeMs is the workflow-wide default. Pacing is only ever what the person set — never added automatically.',
404
+ 'waitBeforeMs (give a slow site that long before this step), retry (repeat a group of steps on failure), ' +
405
+ 'patience or maxWaitMs (how long to wait for a blocker), and pace.waitBeforeMs is the workflow-wide default. ' +
406
+ 'Pacing is only ever what the person set — never added automatically.',
388
407
  inputSchema: {
389
408
  type: 'object',
390
409
  additionalProperties: false,
@@ -414,6 +433,26 @@ const MCP_TOOLS = [
414
433
  note: { type: 'string', maxLength: 200 },
415
434
  waitBeforeMs: PACING,
416
435
  retry: RETRY,
436
+ patience: {
437
+ type: 'string',
438
+ enum: ['fast', 'normal', 'patient'],
439
+ description: 'How long this step gives a blocker it waits on: half, the default, or three times the learned budget. Never a fixed sleep.',
440
+ },
441
+ maxWaitMs: {
442
+ type: 'integer', minimum: 100, maximum: LIMITS.MAX_WAIT_MS,
443
+ description: `An explicit cap on one resolver wait for this step (100–${LIMITS.MAX_WAIT_MS}).`,
444
+ },
445
+ expect: {
446
+ type: 'object',
447
+ additionalProperties: false,
448
+ minProperties: 1,
449
+ description: 'What this step produces. Replay waits for it before calling the step done; no values.',
450
+ properties: {
451
+ appears: { ...TARGET, description: 'An element (usually a dialog) that appears after this step.' },
452
+ request: REQUEST_PATTERN,
453
+ settleMs: { type: 'integer', minimum: 0, maximum: LIMITS.MAX_STEP_PACING_MS },
454
+ },
455
+ },
417
456
  onFail: {
418
457
  type: 'string',
419
458
  enum: ['stop', 'skip'],