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.
- package/CHANGELOG.md +56 -0
- package/bridge-server/background-runs.js +91 -4
- package/bridge-server/cli-run.js +2 -0
- package/bridge-server/limits.js +27 -1
- package/bridge-server/mcp-server.js +25 -9
- package/bridge-server/panel.js +14 -3
- package/bridge-server/resolvers.js +287 -0
- package/bridge-server/tools.js +43 -4
- package/bridge-server/workflows.js +298 -11
- package/extension/background.js +1 -1
- package/extension/engine.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/sidepanel.css +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/bridge-server/tools.js
CHANGED
|
@@ -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,
|
|
205
|
-
'
|
|
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)
|
|
387
|
-
'
|
|
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'],
|