explorbot 0.2.3 → 0.2.4
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/README.md +1 -1
- package/bin/explorbot-cli.ts +7 -1
- package/boat/api-tester/src/config.ts +4 -2
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
- package/boat/doc-collector/src/ai/documentarian.ts +61 -31
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +0 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +115 -86
- package/boat/prima/src/envelope.ts +80 -52
- package/boat/prima/src/prima.ts +547 -128
- package/boat/prima/src/pw-parser.ts +11 -1
- package/boat/prima/src/pw-registry.ts +4 -5
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +7 -1
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +4 -2
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +97 -77
- package/dist/boat/prima/src/envelope.js +70 -44
- package/dist/boat/prima/src/prima.js +515 -119
- package/dist/boat/prima/src/pw-parser.js +13 -1
- package/dist/boat/prima/src/pw-registry.js +4 -5
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/package.json +3 -2
- package/dist/rules/navigator/verification-actions.md +20 -0
- package/dist/src/action-result.d.ts +7 -0
- package/dist/src/action-result.js +4 -0
- package/dist/src/action.d.ts +2 -0
- package/dist/src/action.js +36 -1
- package/dist/src/ai/captain/web-mode.js +6 -3
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +7 -0
- package/dist/src/ai/navigator.js +10 -6
- package/dist/src/ai/pilot.d.ts +4 -0
- package/dist/src/ai/pilot.js +48 -2
- package/dist/src/ai/provider.js +2 -2
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +33 -33
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +113 -36
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +13 -4
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +2 -1
- package/dist/src/explorbot.js +3 -1
- package/dist/src/explorer.js +2 -3
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +54 -0
- package/dist/src/remote.js +229 -0
- package/dist/src/reporter.d.ts +1 -0
- package/dist/src/reporter.js +7 -1
- package/dist/src/state-manager.d.ts +2 -1
- package/dist/src/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +2 -0
- package/dist/src/test-plan.js +7 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +6 -0
- package/dist/src/utils/logger.js +24 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/url-matcher.js +3 -0
- package/dist/src/utils/web-element.d.ts +2 -0
- package/dist/src/utils/web-element.js +8 -0
- package/dist/src/utils/web-sandbox.d.ts +1 -1
- package/dist/src/utils/web-sandbox.js +2 -3
- package/docs/api-testing/basics.md +90 -0
- package/docs/api-testing/planning.md +57 -0
- package/docs/api-testing/running-tests.md +55 -0
- package/docs/assets/cloud-report.png +0 -0
- package/docs/assets/html-report.png +0 -0
- package/docs/assets/langfuse-trace.png +0 -0
- package/docs/assets/successful-explore-run.png +0 -0
- package/docs/basics/getting-started.md +140 -0
- package/docs/basics/prerequisites.md +63 -0
- package/docs/basics/providers.md +362 -0
- package/docs/basics/running.md +78 -0
- package/docs/contributing/ai-integration-tests.md +57 -0
- package/docs/contributing/contributing.md +90 -0
- package/docs/contributing/demo-videos.md +36 -0
- package/docs/contributing/npm-package.md +138 -0
- package/docs/contributing/observability.md +227 -0
- package/docs/contributing/regression-tests.md +103 -0
- package/docs/contributing/testing.md +95 -0
- package/docs/doc-collection/basics.md +128 -0
- package/docs/doc-collection/crawling.md +67 -0
- package/docs/doc-collection/interactive-mode.md +99 -0
- package/docs/index.json +86 -0
- package/docs/reference/commands.md +994 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -0
- package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
- package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
- package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
- package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
- package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
- package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
- package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
- package/docs/web-testing/agents.md +158 -0
- package/docs/web-testing/automated-tests.md +134 -0
- package/docs/web-testing/basics.md +91 -0
- package/docs/web-testing/customization.md +131 -0
- package/docs/web-testing/hooks.md +238 -0
- package/docs/web-testing/page-interaction.md +84 -0
- package/docs/web-testing/planner.md +122 -0
- package/docs/web-testing/rerun.md +164 -0
- package/docs/web-testing/researcher.md +380 -0
- package/docs/workflow/agentic-usage.md +233 -0
- package/docs/workflow/application-spec.md +73 -0
- package/docs/workflow/ci.md +202 -0
- package/docs/workflow/knowledge.md +310 -0
- package/docs/workflow/planning-styles.md +67 -0
- package/docs/workflow/reporting.md +133 -0
- package/docs/workflow/test-plans.md +90 -0
- package/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +38 -2
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +14 -8
- package/src/ai/pilot.ts +53 -2
- package/src/ai/provider.ts +2 -4
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +17 -10
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +32 -33
- package/src/ai/tools.ts +132 -36
- package/src/config.ts +15 -4
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +4 -2
- package/src/explorer.ts +2 -2
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +238 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +2 -1
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +9 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +24 -1
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/url-matcher.ts +3 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
package/src/remote.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
3
|
+
import { type ActivityEntry, addActivityListener } from './activity.ts';
|
|
4
|
+
import { executionController } from './execution-controller.ts';
|
|
5
|
+
import { type LogDestination, type TaggedLogEntry, addDestination } from './utils/logger.ts';
|
|
6
|
+
|
|
7
|
+
const QUEUE_CAP = 1000;
|
|
8
|
+
const CONTENT_CAP = 8000;
|
|
9
|
+
const RECONNECT_BASE_MS = 500;
|
|
10
|
+
const RECONNECT_MAX_MS = 10_000;
|
|
11
|
+
const ASK_TIMEOUT_MS = 15 * 60_000;
|
|
12
|
+
const FLUSH_TIMEOUT_MS = 3000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Streams a run to a remote UI over one WebSocket, dialling out so a child
|
|
16
|
+
* process and a CI bot are the same case.
|
|
17
|
+
*
|
|
18
|
+
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
19
|
+
* side — and it answers asks by installing itself as the execution
|
|
20
|
+
* controller's input callback. Nothing else in explorbot knows it exists.
|
|
21
|
+
*/
|
|
22
|
+
export class Remote implements LogDestination {
|
|
23
|
+
private url: string | null = null;
|
|
24
|
+
private socket: WebSocket | null = null;
|
|
25
|
+
private queue: Frame[] = [];
|
|
26
|
+
private reconnectDelay = RECONNECT_BASE_MS;
|
|
27
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
28
|
+
private asks = new Map<string, (value: string | null) => void>();
|
|
29
|
+
private askCounter = 0;
|
|
30
|
+
private lastActivity: string | null = null;
|
|
31
|
+
|
|
32
|
+
registerOption(program: Command): void {
|
|
33
|
+
program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
|
|
34
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
35
|
+
const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
|
|
36
|
+
if (!url) return;
|
|
37
|
+
this.attach(String(url), this.commandPath(actionCommand));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
attach(url: string, command: string): void {
|
|
42
|
+
if (this.url) return;
|
|
43
|
+
this.url = url;
|
|
44
|
+
this.connect();
|
|
45
|
+
|
|
46
|
+
this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
|
|
47
|
+
addDestination(this);
|
|
48
|
+
executionController.setInputCallback((prompt) => this.ask(prompt));
|
|
49
|
+
addActivityListener((activity) => this.reportActivity(activity));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isAttached(): boolean {
|
|
53
|
+
return !!this.url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
send(type: string, data: Record<string, unknown> = {}): void {
|
|
57
|
+
if (!this.url) return;
|
|
58
|
+
const frame: Frame = { type, ts: Date.now(), ...data };
|
|
59
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
60
|
+
this.push(frame);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.queue.push(frame);
|
|
64
|
+
if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ask(prompt: string): Promise<string | null> {
|
|
68
|
+
if (!this.url) return Promise.resolve(null);
|
|
69
|
+
this.askCounter++;
|
|
70
|
+
const askId = `ask-${this.askCounter}`;
|
|
71
|
+
this.send('ask', { askId, prompt });
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
this.asks.delete(askId);
|
|
76
|
+
resolve(null);
|
|
77
|
+
}, ASK_TIMEOUT_MS);
|
|
78
|
+
this.asks.set(askId, (value) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
resolve(value);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async close(exitCode: number): Promise<void> {
|
|
86
|
+
if (!this.url) return;
|
|
87
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
88
|
+
await this.flush();
|
|
89
|
+
|
|
90
|
+
this.url = null;
|
|
91
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
92
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
93
|
+
// route them into a closed socket and park them until the ask times out.
|
|
94
|
+
executionController.clearInputCallback();
|
|
95
|
+
for (const resolve of this.asks.values()) resolve(null);
|
|
96
|
+
this.asks.clear();
|
|
97
|
+
this.queue = [];
|
|
98
|
+
this.socket?.close();
|
|
99
|
+
this.socket = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
isEnabled(): boolean {
|
|
103
|
+
return this.isAttached();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The entry as the logger already built it, so the `LogType` reaches the UI
|
|
108
|
+
* as the frame's level and each kind is styled there instead of being scraped
|
|
109
|
+
* back out of flattened text.
|
|
110
|
+
*/
|
|
111
|
+
write(entry: TaggedLogEntry): void {
|
|
112
|
+
if (entry.type === 'html') return;
|
|
113
|
+
let content = stripAnsi(entry.content ?? '');
|
|
114
|
+
if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
115
|
+
this.send('log', {
|
|
116
|
+
level: entry.type,
|
|
117
|
+
content,
|
|
118
|
+
namespace: entry.namespace,
|
|
119
|
+
error: this.errorOf(entry.originalArgs),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private connect(): void {
|
|
124
|
+
if (!this.url) return;
|
|
125
|
+
|
|
126
|
+
let opening: WebSocket;
|
|
127
|
+
try {
|
|
128
|
+
opening = new WebSocket(this.url);
|
|
129
|
+
} catch {
|
|
130
|
+
this.reconnect();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.socket = opening;
|
|
134
|
+
|
|
135
|
+
opening.addEventListener('open', () => {
|
|
136
|
+
this.reconnectDelay = RECONNECT_BASE_MS;
|
|
137
|
+
this.drain();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data)));
|
|
141
|
+
|
|
142
|
+
opening.addEventListener('close', () => {
|
|
143
|
+
if (this.socket !== opening) return;
|
|
144
|
+
this.socket = null;
|
|
145
|
+
this.reconnect();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// A failed connection always closes too, which is where the retry is scheduled.
|
|
149
|
+
opening.addEventListener('error', () => {});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private reconnect(): void {
|
|
153
|
+
if (!this.url || this.reconnectTimer) return;
|
|
154
|
+
const delay = this.reconnectDelay;
|
|
155
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
156
|
+
this.reconnectTimer = setTimeout(() => {
|
|
157
|
+
this.reconnectTimer = null;
|
|
158
|
+
this.connect();
|
|
159
|
+
}, delay);
|
|
160
|
+
this.reconnectTimer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async flush(): Promise<void> {
|
|
164
|
+
const deadline = Date.now() + FLUSH_TIMEOUT_MS;
|
|
165
|
+
while (Date.now() < deadline) {
|
|
166
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
167
|
+
this.drain();
|
|
168
|
+
if (!this.queue.length && !this.socket.bufferedAmount) return;
|
|
169
|
+
}
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private drain(): void {
|
|
175
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
176
|
+
const pending = this.queue;
|
|
177
|
+
this.queue = [];
|
|
178
|
+
for (const frame of pending) this.push(frame);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private push(frame: Frame): void {
|
|
182
|
+
try {
|
|
183
|
+
this.socket?.send(JSON.stringify(frame));
|
|
184
|
+
} catch {
|
|
185
|
+
this.queue.push(frame);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private receive(raw: string): void {
|
|
190
|
+
let frame: Frame;
|
|
191
|
+
try {
|
|
192
|
+
frame = JSON.parse(raw);
|
|
193
|
+
} catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (frame.type === 'interrupt') {
|
|
197
|
+
executionController.interrupt();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const resolve = this.asks.get(String(frame.askId));
|
|
201
|
+
if (!resolve) return;
|
|
202
|
+
this.asks.delete(String(frame.askId));
|
|
203
|
+
resolve((frame.value as string | null) ?? null);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A new listener is primed with the current activity, which at attach time is
|
|
207
|
+
* nothing — so skip repeats, the priming null included. */
|
|
208
|
+
private reportActivity(activity: ActivityEntry | null): void {
|
|
209
|
+
const message = activity?.message ?? null;
|
|
210
|
+
if (message === this.lastActivity) return;
|
|
211
|
+
this.lastActivity = message;
|
|
212
|
+
this.send('activity', { message, kind: activity?.type });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private errorOf(args: any[] | undefined): string | undefined {
|
|
216
|
+
const failure = args?.[1] ?? args?.[0]?.error;
|
|
217
|
+
if (!failure) return undefined;
|
|
218
|
+
if (typeof failure === 'string') return failure;
|
|
219
|
+
if (typeof failure.message === 'string') return failure.message;
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private commandPath(command: Command): string {
|
|
224
|
+
const parts: string[] = [];
|
|
225
|
+
let node: Command | null = command;
|
|
226
|
+
while (node) {
|
|
227
|
+
parts.unshift(node.name());
|
|
228
|
+
node = node.parent;
|
|
229
|
+
}
|
|
230
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const remote = new Remote();
|
|
235
|
+
|
|
236
|
+
/** Whatever the run wants to say. Not a schema — the UI renders what it knows
|
|
237
|
+
* and ignores the rest, so either side can start sending more at any time. */
|
|
238
|
+
type Frame = { type: string; ts: number; [key: string]: unknown };
|
package/src/reporter.ts
CHANGED
|
@@ -283,13 +283,19 @@ export class Reporter {
|
|
|
283
283
|
|
|
284
284
|
debugLog(testData);
|
|
285
285
|
|
|
286
|
-
await this.
|
|
286
|
+
await this.reportTestData(status, testData);
|
|
287
287
|
debugLog(`Test reported: ${test.scenario} - ${status}`);
|
|
288
288
|
} catch (error) {
|
|
289
289
|
debugLog('Failed to report test:', error);
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
async reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void> {
|
|
294
|
+
await this.startRun();
|
|
295
|
+
if (!this.isRunStarted) return;
|
|
296
|
+
await this.client.addTestRun(status, testData);
|
|
297
|
+
}
|
|
298
|
+
|
|
293
299
|
async finishRun(): Promise<void> {
|
|
294
300
|
if (!this.isRunStarted) {
|
|
295
301
|
return;
|
package/src/state-manager.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ActionResult } from './action-result.js';
|
|
1
|
+
import { type FocusedElement, ActionResult } from './action-result.js';
|
|
2
2
|
import type { ExperienceTracker } from './experience-tracker.js';
|
|
3
3
|
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
|
|
4
4
|
import { detectFocusArea } from './utils/aria.js';
|
|
@@ -46,6 +46,7 @@ export interface WebPageState {
|
|
|
46
46
|
h4?: string;
|
|
47
47
|
ariaSnapshot?: string | null;
|
|
48
48
|
ariaSnapshotFile?: string;
|
|
49
|
+
focusedElement?: FocusedElement | null;
|
|
49
50
|
links?: Link[];
|
|
50
51
|
verifications?: Record<string, boolean>;
|
|
51
52
|
}
|
package/src/stats.ts
CHANGED
|
@@ -17,6 +17,7 @@ export class Stats {
|
|
|
17
17
|
static plans = 0;
|
|
18
18
|
static mode?: ExplorbotMode;
|
|
19
19
|
static focus?: string;
|
|
20
|
+
static visionDisabled = false;
|
|
20
21
|
static models: Record<string, TokenUsage> = {};
|
|
21
22
|
|
|
22
23
|
static recordTokens(_agent: string, model: string, usage: TokenUsage): void {
|
package/src/test-plan.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface Note {
|
|
|
27
27
|
endTime: number;
|
|
28
28
|
screenshot?: string;
|
|
29
29
|
log?: string;
|
|
30
|
+
observation?: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export class ActiveNote {
|
|
@@ -131,6 +132,14 @@ export class Task {
|
|
|
131
132
|
this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
addObservation(message: string): void {
|
|
136
|
+
const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
|
|
137
|
+
if (isDuplicate) return;
|
|
138
|
+
|
|
139
|
+
const now = performance.now();
|
|
140
|
+
this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
134
143
|
addUrlNote(state: UrlNoteState, prevState?: { title?: string; h1?: string; h2?: string }): void {
|
|
135
144
|
const fullUrl = state.fullUrl || state.url;
|
|
136
145
|
if (!fullUrl) return;
|
package/src/utils/aria.ts
CHANGED
|
@@ -85,8 +85,7 @@ const parseLabel = (label: string): { role: string; name?: string; attributes: R
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
const attributes: Record<string, string | boolean | null> = {};
|
|
88
|
-
const attrMatch
|
|
89
|
-
if (attrMatch) {
|
|
88
|
+
for (const attrMatch of rest.matchAll(/\[([^\]]*)\]/g)) {
|
|
90
89
|
for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
|
|
91
90
|
const eq = tok.indexOf('=');
|
|
92
91
|
if (eq === -1) {
|
|
@@ -192,7 +191,9 @@ const dropEmpty = (nodes: AriaNode[], opts: { keepNamed?: boolean } = {}): AriaN
|
|
|
192
191
|
// ─────────────────────────────────────────────────────────────────
|
|
193
192
|
|
|
194
193
|
// One-line representation of a node. Stable attr order so diff comparisons are deterministic.
|
|
195
|
-
const
|
|
194
|
+
const MAX_INLINE_VALUE = 400;
|
|
195
|
+
|
|
196
|
+
const formatNode = (node: AriaNode, offload?: ValueOffload): string => {
|
|
196
197
|
let line = node.role;
|
|
197
198
|
if (node.name?.trim()) line += ` "${node.name.trim()}"`;
|
|
198
199
|
const attrStr = Object.keys(node.attributes)
|
|
@@ -208,11 +209,19 @@ const formatNode = (node: AriaNode): string => {
|
|
|
208
209
|
if (attrStr) line += ` [${attrStr}]`;
|
|
209
210
|
if (node.value !== undefined && node.value !== null) {
|
|
210
211
|
const text = String(node.value).trim();
|
|
211
|
-
if (text) line += `: ${text}`;
|
|
212
|
+
if (text && text.length <= MAX_INLINE_VALUE) line += `: ${text}`;
|
|
213
|
+
if (text && text.length > MAX_INLINE_VALUE) line += `: ${offloadValue(text, offload)}`;
|
|
212
214
|
}
|
|
213
215
|
return line;
|
|
214
216
|
};
|
|
215
217
|
|
|
218
|
+
const offloadValue = (text: string, offload?: ValueOffload): string => {
|
|
219
|
+
const head = `${text.slice(0, MAX_INLINE_VALUE)}…`;
|
|
220
|
+
const reference = offload?.(text);
|
|
221
|
+
if (!reference) return `${head} (${text.length} chars, truncated)`;
|
|
222
|
+
return `${head} (${text.length} chars) [Full Text: ${reference}]`;
|
|
223
|
+
};
|
|
224
|
+
|
|
216
225
|
// Group consecutive same-role siblings. [a,a,b,a,a,a] → [[a,a],[b],[a,a,a]]
|
|
217
226
|
const groupByConsecutiveRole = (nodes: AriaNode[]): AriaNode[][] =>
|
|
218
227
|
nodes.reduce<AriaNode[][]>((groups, node) => {
|
|
@@ -239,15 +248,15 @@ const collapseGroup = (group: AriaNode[], depth: number): RenderEntry[] => {
|
|
|
239
248
|
const collapseSiblingGroups = (nodes: AriaNode[], depth: number): RenderEntry[] => groupByConsecutiveRole(nodes).flatMap((group) => collapseGroup(group, depth));
|
|
240
249
|
|
|
241
250
|
// Tree → indented YAML text.
|
|
242
|
-
const renderTree = (nodes: AriaNode[], depth = 0): string =>
|
|
251
|
+
const renderTree = (nodes: AriaNode[], depth = 0, offload?: ValueOffload): string =>
|
|
243
252
|
collapseSiblingGroups(nodes, depth)
|
|
244
253
|
.map((entry) => {
|
|
245
254
|
if ('placeholder' in entry) return entry.placeholder;
|
|
246
255
|
const { node } = entry;
|
|
247
256
|
const indent = ' '.repeat(depth);
|
|
248
|
-
const head = `${indent}- ${formatNode(node)}`;
|
|
257
|
+
const head = `${indent}- ${formatNode(node, offload)}`;
|
|
249
258
|
if (node.children.length === 0) return head;
|
|
250
|
-
return `${head}:\n${renderTree(node.children, depth + 1)}`;
|
|
259
|
+
return `${head}:\n${renderTree(node.children, depth + 1, offload)}`;
|
|
251
260
|
})
|
|
252
261
|
.join('\n');
|
|
253
262
|
|
|
@@ -382,6 +391,40 @@ const detectToggles = (prev: FlatEntry[], curr: FlatEntry[]): { toggled: string[
|
|
|
382
391
|
return { toggled, togglePaths };
|
|
383
392
|
};
|
|
384
393
|
|
|
394
|
+
const detectValueChanges = (prev: FlatEntry[], curr: FlatEntry[]): { typed: string[]; typedPaths: Set<string> } => {
|
|
395
|
+
const typed: string[] = [];
|
|
396
|
+
const typedPaths = new Set<string>();
|
|
397
|
+
const currByPath = new Map(curr.map((e) => [e.path, e]));
|
|
398
|
+
|
|
399
|
+
for (const before of prev) {
|
|
400
|
+
const after = currByPath.get(before.path);
|
|
401
|
+
if (!after) continue;
|
|
402
|
+
if (before.entry.role !== after.entry.role) continue;
|
|
403
|
+
if (before.entry.name !== after.entry.name) continue;
|
|
404
|
+
|
|
405
|
+
const was = valueWord(before.entry.value);
|
|
406
|
+
const now = valueWord(after.entry.value);
|
|
407
|
+
if (was === now) continue;
|
|
408
|
+
|
|
409
|
+
typedPaths.add(before.path);
|
|
410
|
+
let label = String(after.entry.role);
|
|
411
|
+
const name = after.entry.name;
|
|
412
|
+
if (typeof name === 'string' && name.trim()) label += ` "${name.trim()}"`;
|
|
413
|
+
typed.push(`${label}: ${was} -> ${now}`);
|
|
414
|
+
}
|
|
415
|
+
return { typed, typedPaths };
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const VALUE_EXCERPT = 60;
|
|
419
|
+
|
|
420
|
+
const valueWord = (value: unknown): string => {
|
|
421
|
+
if (value === undefined || value === null) return 'empty';
|
|
422
|
+
const text = String(value).trim();
|
|
423
|
+
if (!text) return 'empty';
|
|
424
|
+
if (text.length <= VALUE_EXCERPT) return JSON.stringify(text);
|
|
425
|
+
return `${JSON.stringify(text.slice(0, VALUE_EXCERPT))}… (${text.length} chars)`;
|
|
426
|
+
};
|
|
427
|
+
|
|
385
428
|
const TOP_DIFF_ITEMS = 10;
|
|
386
429
|
|
|
387
430
|
const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
@@ -406,9 +449,13 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
|
406
449
|
return lines;
|
|
407
450
|
};
|
|
408
451
|
|
|
409
|
-
const formatDiff = (added: string[], removed: string[], toggled: string[]): string | null => {
|
|
410
|
-
if (added.length === 0 && removed.length === 0 && toggled.length === 0) return null;
|
|
452
|
+
const formatDiff = (added: string[], removed: string[], toggled: string[], typed: string[] = []): string | null => {
|
|
453
|
+
if (added.length === 0 && removed.length === 0 && toggled.length === 0 && typed.length === 0) return null;
|
|
411
454
|
const sections = ['ariaDiff:'];
|
|
455
|
+
if (typed.length > 0) {
|
|
456
|
+
sections.push(' typed:');
|
|
457
|
+
for (const line of typed) sections.push(` - ${line}`);
|
|
458
|
+
}
|
|
412
459
|
if (toggled.length > 0) {
|
|
413
460
|
sections.push(' toggled:');
|
|
414
461
|
for (const line of toggled) sections.push(` - ${line}`);
|
|
@@ -474,13 +521,15 @@ const findDialogOrModal = (nodes: AriaNode[]): FocusAreaResult | null => {
|
|
|
474
521
|
// Public API — pipelines composed visibly, top-to-bottom
|
|
475
522
|
// ─────────────────────────────────────────────────────────────────
|
|
476
523
|
|
|
477
|
-
export
|
|
524
|
+
export type ValueOffload = (value: string) => string | undefined;
|
|
525
|
+
|
|
526
|
+
export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false, offload?: ValueOffload): string => {
|
|
478
527
|
if (!snapshot) return '';
|
|
479
528
|
let tree = parseSnapshot(snapshot);
|
|
480
529
|
tree = unwrapIgnored(tree);
|
|
481
530
|
tree = nameIconButtons(tree);
|
|
482
531
|
tree = dropEmpty(tree, { keepNamed });
|
|
483
|
-
return renderTree(tree);
|
|
532
|
+
return renderTree(tree, 0, offload);
|
|
484
533
|
};
|
|
485
534
|
|
|
486
535
|
export const diffAriaSnapshots = (previous: string | null, current: string | null): AriaDiff => {
|
|
@@ -494,15 +543,17 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
|
|
|
494
543
|
const prevAll = flat(previous);
|
|
495
544
|
const currAll = flat(current);
|
|
496
545
|
const { toggled, togglePaths } = detectToggles(prevAll, currAll);
|
|
497
|
-
const
|
|
498
|
-
const
|
|
546
|
+
const { typed, typedPaths } = detectValueChanges(prevAll, currAll);
|
|
547
|
+
const skip = (entry: FlatEntry) => togglePaths.has(entry.path) || typedPaths.has(entry.path);
|
|
548
|
+
const prev = prevAll.filter((e) => !skip(e));
|
|
549
|
+
const curr = currAll.filter((e) => !skip(e));
|
|
499
550
|
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
500
551
|
const currTotals = countBy(curr.map((e) => e.summary));
|
|
501
552
|
const byCount = diffByCount(prevTotals, currTotals);
|
|
502
553
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
503
554
|
const added = [...byCount.added, ...renames.added];
|
|
504
555
|
const removed = [...byCount.removed, ...renames.removed];
|
|
505
|
-
return { text: formatDiff(added, removed, toggled), count: added.length + removed.length + toggled.length };
|
|
556
|
+
return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
|
|
506
557
|
};
|
|
507
558
|
|
|
508
559
|
export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
|
|
@@ -531,37 +582,6 @@ export const collectInteractiveNodes = (snapshot: string | null): Array<Record<s
|
|
|
531
582
|
// Standalone helpers (regex on raw strings — not part of the pipeline)
|
|
532
583
|
// ─────────────────────────────────────────────────────────────────
|
|
533
584
|
|
|
534
|
-
export interface FocusedElementInfo {
|
|
535
|
-
role: string;
|
|
536
|
-
name: string;
|
|
537
|
-
value?: string;
|
|
538
|
-
attributes?: string[];
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
export function extractFocusedElement(ariaSnapshot: string | null): FocusedElementInfo | null {
|
|
542
|
-
if (!ariaSnapshot) return null;
|
|
543
|
-
|
|
544
|
-
const focusedMatch = ariaSnapshot.match(/-\s*(\w+)\s+"([^"]*)"([^:\n]*)\[focused\](?::\s*(.*))?/);
|
|
545
|
-
if (!focusedMatch) return null;
|
|
546
|
-
|
|
547
|
-
const [, role, name, attributesStr, value] = focusedMatch;
|
|
548
|
-
|
|
549
|
-
const attributes: string[] = [];
|
|
550
|
-
if (attributesStr) {
|
|
551
|
-
const attrMatches = attributesStr.matchAll(/\[([^\]]+)\]/g);
|
|
552
|
-
for (const match of attrMatches) {
|
|
553
|
-
if (match[1] !== 'focused') {
|
|
554
|
-
attributes.push(match[1]);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const result: FocusedElementInfo = { role, name };
|
|
560
|
-
if (value) result.value = value.trim();
|
|
561
|
-
if (attributes.length > 0) result.attributes = attributes;
|
|
562
|
-
return result;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
585
|
export function parseAriaLocator(ariaStr: string): { role: string; text: string } | null {
|
|
566
586
|
const trimmed = ariaStr.trim();
|
|
567
587
|
if (trimmed === '-' || trimmed === '' || trimmed === '"-"') return null;
|
package/src/utils/html.ts
CHANGED
|
@@ -1518,6 +1518,7 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
|
|
|
1518
1518
|
attr.value = attr.value
|
|
1519
1519
|
.split(/\s+/)
|
|
1520
1520
|
.filter((className) => !/\d/.test(className))
|
|
1521
|
+
.filter((className) => !className.includes(':'))
|
|
1521
1522
|
.filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
|
|
1522
1523
|
.join(' ');
|
|
1523
1524
|
|
package/src/utils/logger.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface TaggedLogEntry {
|
|
|
24
24
|
|
|
25
25
|
type LogEntry = TaggedLogEntry;
|
|
26
26
|
|
|
27
|
-
interface LogDestination {
|
|
27
|
+
export interface LogDestination {
|
|
28
28
|
isEnabled(): boolean;
|
|
29
29
|
write(entry: TaggedLogEntry): void;
|
|
30
30
|
}
|
|
@@ -79,9 +79,11 @@ const debugFilter = new DebugFilter();
|
|
|
79
79
|
class ConsoleDestination implements LogDestination {
|
|
80
80
|
private verboseMode = false;
|
|
81
81
|
private forceEnabled = false;
|
|
82
|
+
private quiet = false;
|
|
82
83
|
private recentSteps = new RecentStepFilter();
|
|
83
84
|
|
|
84
85
|
isEnabled(): boolean {
|
|
86
|
+
if (this.quiet) return false;
|
|
85
87
|
return this.forceEnabled || !process.env.INK_RUNNING;
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -89,6 +91,10 @@ class ConsoleDestination implements LogDestination {
|
|
|
89
91
|
this.forceEnabled = enabled;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
setQuiet(enabled: boolean): void {
|
|
95
|
+
this.quiet = enabled;
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
setVerboseMode(enabled: boolean): void {
|
|
93
99
|
this.verboseMode = enabled;
|
|
94
100
|
}
|
|
@@ -136,6 +142,8 @@ class DebugDestination implements LogDestination {
|
|
|
136
142
|
|
|
137
143
|
setVerboseMode(enabled: boolean): void {
|
|
138
144
|
this.verboseMode = enabled;
|
|
145
|
+
// the debug package reads DEBUG when it loads, so a namespace turned on later needs enabling by hand
|
|
146
|
+
if (enabled) debug.enable(process.env.DEBUG || 'explorbot:*');
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
write(namespace: string, ...args: any[]): void {
|
|
@@ -332,6 +340,7 @@ class Logger {
|
|
|
332
340
|
private span = new SpanDestination();
|
|
333
341
|
public react = new ReactDestination();
|
|
334
342
|
public captain = new CaptainDestination();
|
|
343
|
+
private extra: LogDestination[] = [];
|
|
335
344
|
private truncateTags: string[] = ['page_html'];
|
|
336
345
|
|
|
337
346
|
private constructor() {}
|
|
@@ -360,10 +369,19 @@ class Logger {
|
|
|
360
369
|
this.console.forceEnable(enabled);
|
|
361
370
|
}
|
|
362
371
|
|
|
372
|
+
setQuietMode(enabled: boolean): void {
|
|
373
|
+
this.console.setQuiet(enabled);
|
|
374
|
+
}
|
|
375
|
+
|
|
363
376
|
isVerboseMode(): boolean {
|
|
364
377
|
return this.debugDestination.isEnabled();
|
|
365
378
|
}
|
|
366
379
|
|
|
380
|
+
addDestination(destination: LogDestination): void {
|
|
381
|
+
if (this.extra.includes(destination)) return;
|
|
382
|
+
this.extra.push(destination);
|
|
383
|
+
}
|
|
384
|
+
|
|
367
385
|
registerLogPane(addLog: (entry: LogEntry) => void): void {
|
|
368
386
|
this.react.registerLogPane(addLog);
|
|
369
387
|
}
|
|
@@ -456,6 +474,9 @@ class Logger {
|
|
|
456
474
|
if (this.file.isEnabled()) this.file.write(entry);
|
|
457
475
|
if (this.span.isEnabled()) this.span.write(entry);
|
|
458
476
|
if (this.captain.isEnabled()) this.captain.write(entry);
|
|
477
|
+
for (const destination of this.extra) {
|
|
478
|
+
if (destination.isEnabled()) destination.write(entry);
|
|
479
|
+
}
|
|
459
480
|
if (process.env.INK_RUNNING) {
|
|
460
481
|
this.react.write(entry);
|
|
461
482
|
} else if (this.console.isEnabled()) {
|
|
@@ -537,10 +558,12 @@ export const startLogCapture = () => logger.captain.startCapture();
|
|
|
537
558
|
export const stopLogCapture = () => logger.captain.stopCapture();
|
|
538
559
|
export const setVerboseMode = (enabled: boolean) => logger.setVerboseMode(enabled);
|
|
539
560
|
export const setPreserveConsoleLogs = (enabled: boolean) => logger.setPreserveConsoleLogs(enabled);
|
|
561
|
+
export const setQuietMode = (enabled: boolean) => logger.setQuietMode(enabled);
|
|
540
562
|
export const isVerboseMode = () => logger.isVerboseMode();
|
|
541
563
|
export const setDebugMode = (enabled: boolean) => logger.setDebugMode(enabled);
|
|
542
564
|
export const isDebugMode = () => logger.isDebugMode();
|
|
543
565
|
|
|
566
|
+
export const addDestination = (destination: LogDestination) => logger.addDestination(destination);
|
|
544
567
|
export const registerLogPane = (addLog: (entry: LogEntry) => void) => logger.registerLogPane(addLog);
|
|
545
568
|
export const unregisterLogPane = (addLog: (entry: LogEntry) => void) => logger.unregisterLogPane(addLog);
|
|
546
569
|
export const addTruncateTag = (tagName: string) => logger.addTruncateTag(tagName);
|
|
@@ -4,10 +4,33 @@ export async function waitForPageReadiness(page: any, options: PageReadinessOpti
|
|
|
4
4
|
const timeout = options.timeout ?? 6000;
|
|
5
5
|
await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => {});
|
|
6
6
|
|
|
7
|
-
await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
|
|
7
|
+
await Promise.race([waitForNetworkIdle(page, timeout), waitForDomQuiet(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
|
|
8
8
|
await waitForPageBodyContent(page, timeout);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const DOM_QUIET_MS = 350;
|
|
12
|
+
|
|
13
|
+
function waitForDomQuiet(page: any, timeout: number): Promise<void> {
|
|
14
|
+
if (!page?.waitForFunction) return new Promise(() => {});
|
|
15
|
+
|
|
16
|
+
return page
|
|
17
|
+
.waitForFunction(
|
|
18
|
+
(quiet: number) => {
|
|
19
|
+
const store = window as any;
|
|
20
|
+
if (!store.__explorbotDomQuiet) {
|
|
21
|
+
store.__explorbotDomQuiet = { last: Date.now() };
|
|
22
|
+
new MutationObserver(() => {
|
|
23
|
+
store.__explorbotDomQuiet.last = Date.now();
|
|
24
|
+
}).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
25
|
+
}
|
|
26
|
+
return Date.now() - store.__explorbotDomQuiet.last >= quiet;
|
|
27
|
+
},
|
|
28
|
+
DOM_QUIET_MS,
|
|
29
|
+
{ timeout, polling: 100 }
|
|
30
|
+
)
|
|
31
|
+
.catch(() => {});
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
function waitForNetworkIdle(page: any, timeout: number): Promise<void> {
|
|
12
35
|
if (!page?.waitForLoadState) return Promise.resolve();
|
|
13
36
|
return page.waitForLoadState('networkidle', { timeout }).catch(() => {});
|
package/src/utils/url-matcher.ts
CHANGED
|
@@ -100,6 +100,9 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
|
|
|
100
100
|
if (!expectedPath.includes('#')) {
|
|
101
101
|
currentPath = currentPath.split('#')[0];
|
|
102
102
|
}
|
|
103
|
+
if (!expectedPath.includes('?')) {
|
|
104
|
+
currentPath = currentPath.split('?')[0];
|
|
105
|
+
}
|
|
103
106
|
const normalize = (value: string) => value.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
104
107
|
return normalize(expectedPath) === normalize(currentPath);
|
|
105
108
|
}
|
package/src/utils/web-element.ts
CHANGED
|
@@ -125,6 +125,15 @@ export class WebElement {
|
|
|
125
125
|
return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
static isAriaRef(ref: string): boolean {
|
|
129
|
+
return /^(f\d+)?e\d+$/i.test(ref);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
static async fromAriaRef(page: any, ref: string): Promise<WebElement | null> {
|
|
133
|
+
if (!WebElement.isAriaRef(ref)) return null;
|
|
134
|
+
return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
static async fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]> {
|
|
129
138
|
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
|
|
130
139
|
if (validEidxList.length === 0) return [];
|
package/src/utils/web-sandbox.ts
CHANGED
|
@@ -28,13 +28,12 @@ export function playwrightSandbox(page: any, code: string): Promise<any> {
|
|
|
28
28
|
return run(page);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function codeceptJSSandbox(actor: any, codeOrFn: string | ((...args: any[]) => void)):
|
|
31
|
+
export function codeceptJSSandbox(actor: any, codeOrFn: string | ((...args: any[]) => void)): any {
|
|
32
32
|
if (typeof codeOrFn === 'function') {
|
|
33
|
-
codeOrFn(actor, tryTo, retryTo, within, hopeThat, step, faker);
|
|
34
|
-
return;
|
|
33
|
+
return codeOrFn(actor, tryTo, retryTo, within, hopeThat, step, faker);
|
|
35
34
|
}
|
|
36
35
|
const run = createSandbox(CODECEPT_ARG_NAMES, codeOrFn);
|
|
37
|
-
run(actor, tryTo, retryTo, within, hopeThat, step, faker);
|
|
36
|
+
return run(actor, tryTo, retryTo, within, hopeThat, step, faker);
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
function createSandbox(argNames: string[], body: string): (...args: any[]) => any {
|