@epsilon-asi/actors 0.0.1 → 0.0.3
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/package.json +12 -3
- package/.ai/generators/_template.ts +0 -37
- package/.ai/generators/abstract.ts +0 -24
- package/.ai/generators/actor-task-form-filler.ts +0 -140
- package/.ai/generators/actor-task.ts +0 -122
- package/.ai/generators/auth-core.ts +0 -126
- package/.ai/generators/browser-runtime.ts +0 -114
- package/.ai/generators/cli-command.ts +0 -96
- package/.ai/generators/core-framework.ts +0 -80
- package/.ai/generators/docs.ts +0 -92
- package/.ai/generators/error-logging.ts +0 -102
- package/.ai/generators/extraction-helper.ts +0 -96
- package/.ai/generators/interaction-behavior.ts +0 -129
- package/.ai/generators/site-actor.ts +0 -125
- package/.ai/generators/site-login-flow.ts +0 -117
- package/.ai/generators/unit-test.ts +0 -109
- package/.ai/workflows/_template.ts +0 -20
- package/.ai/workflows/starter.ts +0 -20
- package/ai-gen.config.ts +0 -67
- package/src/auth/AuthStateDetector.ts +0 -18
- package/src/auth/CredentialsProvider.ts +0 -48
- package/src/auth/LoginFlow.ts +0 -332
- package/src/auth/LoginFlow.types.ts +0 -141
- package/src/auth/SessionStore.ts +0 -21
- package/src/auth/index.ts +0 -5
- package/src/browser/BrowserFactory.ts +0 -253
- package/src/browser/BrowserSession.ts +0 -50
- package/src/browser/PuppeteerLike.ts +0 -65
- package/src/browser/RuntimeConfig.ts +0 -152
- package/src/browser/index.ts +0 -5
- package/src/browser/profileValidation.ts +0 -73
- package/src/cli/run.ts +0 -112
- package/src/core/Actor.ts +0 -167
- package/src/core/ActorContext.ts +0 -34
- package/src/core/ActorRegistry.ts +0 -26
- package/src/core/ActorRunner.ts +0 -240
- package/src/core/defineActor.ts +0 -5
- package/src/core/index.ts +0 -5
- package/src/errors/AuthError.ts +0 -7
- package/src/errors/AutomationError.ts +0 -26
- package/src/errors/ConfigError.ts +0 -7
- package/src/errors/ExtractionError.ts +0 -7
- package/src/errors/NavigationError.ts +0 -7
- package/src/errors/SelectorError.ts +0 -10
- package/src/errors/index.ts +0 -6
- package/src/extraction/Extractor.ts +0 -65
- package/src/extraction/Pagination.ts +0 -47
- package/src/extraction/index.ts +0 -2
- package/src/index.ts +0 -9
- package/src/interaction/FieldClearer.ts +0 -73
- package/src/interaction/Forms.ts +0 -27
- package/src/interaction/GhostCursorAdapter.ts +0 -79
- package/src/interaction/HumanInteractor.ts +0 -32
- package/src/interaction/HumanTyping.ts +0 -157
- package/src/interaction/NativePuppeteerInteractor.ts +0 -68
- package/src/interaction/Navigation.ts +0 -37
- package/src/interaction/PageAdapter.ts +0 -86
- package/src/interaction/Waits.ts +0 -5
- package/src/interaction/index.ts +0 -9
- package/src/logging/ConsoleLogger.ts +0 -44
- package/src/logging/Logger.ts +0 -15
- package/src/logging/MemoryLogger.ts +0 -34
- package/src/logging/NullLogger.ts +0 -8
- package/src/logging/index.ts +0 -4
- package/src/sites/example/example.actor.ts +0 -53
- package/src/sites/example/example.selectors.ts +0 -17
- package/src/sites/example/example.types.ts +0 -18
- package/src/sites/example/index.ts +0 -3
- package/src/sites/index.ts +0 -3
- package/src/sites/myvistage-com/index.ts +0 -3
- package/src/sites/myvistage-com/login-action-list.json +0 -349
- package/src/sites/myvistage-com/myvistage-com.actor.ts +0 -50
- package/src/sites/myvistage-com/myvistage-com.selectors.ts +0 -14
- package/src/sites/myvistage-com/myvistage-com.types.ts +0 -18
- package/src/sites/myvistage-com/post-comment-action.json +0 -81
- package/src/sites/upwork-com/index.ts +0 -6
- package/src/sites/upwork-com/upwork-com.actor.ts +0 -97
- package/src/sites/upwork-com/upwork-com.runner.ts +0 -17
- package/src/sites/upwork-com/upwork-com.selectors.ts +0 -10
- package/src/sites/upwork-com/upwork-com.types.ts +0 -102
- package/src/sites/upwork-com/upwork-com.util.ts +0 -41
- package/src/utils/delay.ts +0 -4
- package/src/utils/index.ts +0 -5
- package/src/utils/invariant.ts +0 -7
- package/src/utils/redact.ts +0 -53
- package/src/utils/retry.ts +0 -31
- package/src/utils/url.ts +0 -7
- package/tests/fixtures/FakeCredentialsProvider.ts +0 -12
- package/tests/fixtures/FakeCursor.ts +0 -48
- package/tests/fixtures/FakePage.ts +0 -266
- package/tests/fixtures/makeContext.ts +0 -76
- package/tests/unit/auth/AuthStateDetector.test.ts +0 -80
- package/tests/unit/auth/LoginFlow.test.ts +0 -296
- package/tests/unit/browser/BrowserFactory.test.ts +0 -370
- package/tests/unit/core/ActorRunner.test.ts +0 -370
- package/tests/unit/core/defineActor.test.ts +0 -112
- package/tests/unit/extraction/Extractor.test.ts +0 -48
- package/tests/unit/extraction/Pagination.test.ts +0 -54
- package/tests/unit/interaction/FieldClearer.test.ts +0 -29
- package/tests/unit/interaction/Forms.test.ts +0 -35
- package/tests/unit/interaction/GhostCursorAdapter.test.ts +0 -68
- package/tests/unit/interaction/HumanTyping.test.ts +0 -54
- package/tests/unit/interaction/NativePuppeteerInteractor.test.ts +0 -22
- package/tests/unit/interaction/PageAdapter.test.ts +0 -25
- package/tests/unit/logging/redact.test.ts +0 -36
- package/tests/unit/sites/myvistage-com.actor.test.ts +0 -19
- package/tests/unit/sites/myvistage-com.login.test.ts +0 -22
- package/tests/unit/sites/myvistage-com.postComment.test.ts +0 -70
- package/tests/unit/sites/upwork-com.login.test.ts +0 -52
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -12
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import type { LoginFlowDefinition } from '../../../src/auth/LoginFlow.types.js';
|
|
6
|
-
import { BrowserFactory } from '../../../src/browser/BrowserFactory.js';
|
|
7
|
-
import { BrowserSession } from '../../../src/browser/BrowserSession.js';
|
|
8
|
-
import type { RuntimeConfig } from '../../../src/browser/RuntimeConfig.js';
|
|
9
|
-
import { defineActor } from '../../../src/core/defineActor.js';
|
|
10
|
-
import { ActorRunner, ActorSequenceRunner } from '../../../src/core/ActorRunner.js';
|
|
11
|
-
import { defineFormTask } from '../../../src/core/Actor.js';
|
|
12
|
-
import { AutomationError } from '../../../src/errors/AutomationError.js';
|
|
13
|
-
import { PuppeteerPageAdapter } from '../../../src/interaction/PageAdapter.js';
|
|
14
|
-
import { MemoryLogger } from '../../../src/logging/MemoryLogger.js';
|
|
15
|
-
import { FakeHumanInteractor } from '../../fixtures/FakeCursor.js';
|
|
16
|
-
import { FakeBrowser, FakePage } from '../../fixtures/FakePage.js';
|
|
17
|
-
|
|
18
|
-
class StaticBrowserFactory extends BrowserFactory {
|
|
19
|
-
launchCalls = 0;
|
|
20
|
-
|
|
21
|
-
constructor(private readonly session: BrowserSession) {
|
|
22
|
-
super();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
override async launch(): Promise<BrowserSession> {
|
|
26
|
-
this.launchCalls += 1;
|
|
27
|
-
return this.session;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
class FakeLoginFlow {
|
|
32
|
-
ensureAuthenticatedCalls: LoginFlowDefinition[] = [];
|
|
33
|
-
|
|
34
|
-
async ensureAuthenticated(_context: unknown, definition: LoginFlowDefinition): Promise<void> {
|
|
35
|
-
this.ensureAuthenticatedCalls.push(definition);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function createConfig(closeBrowserOnFinish = true): RuntimeConfig {
|
|
40
|
-
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paf-runner-'));
|
|
41
|
-
fs.mkdirSync(path.join(userDataDir, 'Default'));
|
|
42
|
-
return {
|
|
43
|
-
browser: {
|
|
44
|
-
mode: 'existing-profile',
|
|
45
|
-
userDataDir,
|
|
46
|
-
profileDirectory: 'Default',
|
|
47
|
-
headless: false,
|
|
48
|
-
closeBrowserOnFinish
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function createRemoteConfig(options: { closeBrowserOnFinish?: boolean; disconnectOnFinish?: boolean } = {}): RuntimeConfig {
|
|
54
|
-
return {
|
|
55
|
-
browser: {
|
|
56
|
-
mode: 'remote-debugging',
|
|
57
|
-
browserURL: 'http://127.0.0.1:9222',
|
|
58
|
-
...options
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function createRunnerParts(closeBrowserOnFinish = true, overrideConfig?: RuntimeConfig): {
|
|
64
|
-
runner: ActorRunner;
|
|
65
|
-
page: FakePage;
|
|
66
|
-
browser: FakeBrowser;
|
|
67
|
-
browserFactory: StaticBrowserFactory;
|
|
68
|
-
loginFlow: FakeLoginFlow;
|
|
69
|
-
interactor: FakeHumanInteractor;
|
|
70
|
-
} {
|
|
71
|
-
const config = overrideConfig ?? createConfig(closeBrowserOnFinish);
|
|
72
|
-
const page = new FakePage();
|
|
73
|
-
const browser = new FakeBrowser([page]);
|
|
74
|
-
const session = new BrowserSession(browser, browser.context, page, config.browser, config.browser.mode === 'remote-debugging' ? 'connected' : 'launched');
|
|
75
|
-
const browserFactory = new StaticBrowserFactory(session);
|
|
76
|
-
const loginFlow = new FakeLoginFlow();
|
|
77
|
-
const interactor = new FakeHumanInteractor();
|
|
78
|
-
const runner = new ActorRunner({
|
|
79
|
-
config,
|
|
80
|
-
browserFactory,
|
|
81
|
-
loginFlow: loginFlow as never,
|
|
82
|
-
logger: new MemoryLogger(),
|
|
83
|
-
interactorFactory: () => interactor,
|
|
84
|
-
pageAdapterFactory: pageLike => new PuppeteerPageAdapter(pageLike)
|
|
85
|
-
});
|
|
86
|
-
return { runner, page, browser, browserFactory, loginFlow, interactor };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function createSequenceRunnerParts(closeBrowserOnFinish = true, overrideConfig?: RuntimeConfig): {
|
|
90
|
-
runner: ActorSequenceRunner;
|
|
91
|
-
page: FakePage;
|
|
92
|
-
browser: FakeBrowser;
|
|
93
|
-
browserFactory: StaticBrowserFactory;
|
|
94
|
-
loginFlow: FakeLoginFlow;
|
|
95
|
-
} {
|
|
96
|
-
const config = overrideConfig ?? createConfig(closeBrowserOnFinish);
|
|
97
|
-
const page = new FakePage();
|
|
98
|
-
const browser = new FakeBrowser([page]);
|
|
99
|
-
const session = new BrowserSession(browser, browser.context, page, config.browser, config.browser.mode === 'remote-debugging' ? 'connected' : 'launched');
|
|
100
|
-
const browserFactory = new StaticBrowserFactory(session);
|
|
101
|
-
const loginFlow = new FakeLoginFlow();
|
|
102
|
-
const runner = new ActorSequenceRunner({
|
|
103
|
-
config,
|
|
104
|
-
browserFactory,
|
|
105
|
-
loginFlow: loginFlow as never,
|
|
106
|
-
logger: new MemoryLogger(),
|
|
107
|
-
interactorFactory: () => new FakeHumanInteractor(),
|
|
108
|
-
pageAdapterFactory: pageLike => new PuppeteerPageAdapter(pageLike)
|
|
109
|
-
});
|
|
110
|
-
return { runner, page, browser, browserFactory, loginFlow };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
describe('ActorRunner', () => {
|
|
114
|
-
const auth = {
|
|
115
|
-
loginUrl: '/login',
|
|
116
|
-
selectors: {
|
|
117
|
-
username: '#email',
|
|
118
|
-
password: '#password',
|
|
119
|
-
submit: '#submit',
|
|
120
|
-
loggedInSignal: '#account'
|
|
121
|
-
},
|
|
122
|
-
credentials: { id: 'example' }
|
|
123
|
-
} satisfies LoginFlowDefinition;
|
|
124
|
-
|
|
125
|
-
it('creates actor context and runs the selected task', async () => {
|
|
126
|
-
const { runner, browserFactory, browser } = createRunnerParts();
|
|
127
|
-
const actor = defineActor({
|
|
128
|
-
id: 'example',
|
|
129
|
-
baseUrl: 'https://example.com',
|
|
130
|
-
tasks: {
|
|
131
|
-
hello: async context => ({ actorId: context.actor.id, url: context.page.url() })
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const result = await runner.run(actor, 'hello');
|
|
136
|
-
|
|
137
|
-
expect(result).toEqual({ actorId: 'example', url: 'about:blank' });
|
|
138
|
-
expect(browserFactory.launchCalls).toBe(1);
|
|
139
|
-
expect(browser.closeCalls).toHaveLength(1);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('runs auth when the actor defines a login flow', async () => {
|
|
143
|
-
const { runner, loginFlow } = createRunnerParts();
|
|
144
|
-
const actor = defineActor({
|
|
145
|
-
id: 'example',
|
|
146
|
-
baseUrl: 'https://example.com',
|
|
147
|
-
auth,
|
|
148
|
-
tasks: {
|
|
149
|
-
scrape: async () => 'done'
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
await expect(runner.run(actor, 'scrape')).resolves.toBe('done');
|
|
154
|
-
expect(loginFlow.ensureAuthenticatedCalls).toEqual([auth]);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('runs generated form tasks against actor context services', async () => {
|
|
158
|
-
const { runner, page, interactor } = createRunnerParts();
|
|
159
|
-
page
|
|
160
|
-
.setSelector('#name')
|
|
161
|
-
.setSelector('#email')
|
|
162
|
-
.setSelector('#submit');
|
|
163
|
-
|
|
164
|
-
const actor = defineActor({
|
|
165
|
-
id: 'example',
|
|
166
|
-
baseUrl: 'https://example.com',
|
|
167
|
-
tasks: {
|
|
168
|
-
submitContact: defineFormTask<{ name: string; email: string }, { submitted: string }>({
|
|
169
|
-
url: '/contact',
|
|
170
|
-
fields: [
|
|
171
|
-
{ selector: '#name', inputKey: 'name' },
|
|
172
|
-
{ selector: '#email', inputKey: 'email' }
|
|
173
|
-
],
|
|
174
|
-
submit: {
|
|
175
|
-
selector: '#submit',
|
|
176
|
-
waitForNavigation: true
|
|
177
|
-
},
|
|
178
|
-
onComplete: (_context, input) => ({ submitted: input.email })
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
const result = await runner.run(actor, 'submitContact', {
|
|
184
|
-
name: 'Jane Doe',
|
|
185
|
-
email: 'jane@example.com'
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
expect(result).toEqual({ submitted: 'jane@example.com' });
|
|
189
|
-
expect(page.gotos[0]?.url).toBe('https://example.com/contact');
|
|
190
|
-
expect(interactor.typed).toEqual([
|
|
191
|
-
{ selector: '#name', value: 'Jane Doe', options: { required: true } },
|
|
192
|
-
{ selector: '#email', value: 'jane@example.com', options: { required: true } }
|
|
193
|
-
]);
|
|
194
|
-
expect(interactor.clicks).toEqual([{ selector: '#submit' }]);
|
|
195
|
-
expect(page.waitedNavigations).toHaveLength(1);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('disconnects a launched browser without closing when closeBrowserOnFinish is false', async () => {
|
|
199
|
-
const { runner, browser } = createRunnerParts(false);
|
|
200
|
-
const actor = defineActor({
|
|
201
|
-
id: 'example',
|
|
202
|
-
baseUrl: 'https://example.com',
|
|
203
|
-
tasks: {
|
|
204
|
-
noop: async () => undefined
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
await runner.run(actor, 'noop');
|
|
209
|
-
|
|
210
|
-
expect(browser.closeCalls).toHaveLength(0);
|
|
211
|
-
expect(browser.disconnectCalls).toHaveLength(1);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('disconnects from a shared connected browser by default without closing Chrome', async () => {
|
|
215
|
-
const { runner, browser } = createRunnerParts(true, createRemoteConfig());
|
|
216
|
-
const actor = defineActor({
|
|
217
|
-
id: 'example',
|
|
218
|
-
baseUrl: 'https://example.com',
|
|
219
|
-
tasks: {
|
|
220
|
-
noop: async () => undefined
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
await runner.run(actor, 'noop');
|
|
225
|
-
|
|
226
|
-
expect(browser.disconnectCalls).toHaveLength(1);
|
|
227
|
-
expect(browser.closeCalls).toHaveLength(0);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('can close the connected browser when explicitly configured', async () => {
|
|
231
|
-
const { runner, browser } = createRunnerParts(true, createRemoteConfig({ closeBrowserOnFinish: true, disconnectOnFinish: false }));
|
|
232
|
-
const actor = defineActor({
|
|
233
|
-
id: 'example',
|
|
234
|
-
baseUrl: 'https://example.com',
|
|
235
|
-
tasks: {
|
|
236
|
-
noop: async () => undefined
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
await runner.run(actor, 'noop');
|
|
241
|
-
|
|
242
|
-
expect(browser.closeCalls).toHaveLength(1);
|
|
243
|
-
expect(browser.disconnectCalls).toHaveLength(0);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('wraps task failures with actor metadata and still closes the browser', async () => {
|
|
247
|
-
const { runner, browser } = createRunnerParts();
|
|
248
|
-
const actor = defineActor({
|
|
249
|
-
id: 'example',
|
|
250
|
-
baseUrl: 'https://example.com',
|
|
251
|
-
tasks: {
|
|
252
|
-
explode: async () => {
|
|
253
|
-
throw new Error('boom');
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
await expect(runner.run(actor, 'explode')).rejects.toMatchObject({
|
|
259
|
-
name: 'AutomationError',
|
|
260
|
-
actorId: 'example',
|
|
261
|
-
taskName: 'explode'
|
|
262
|
-
} satisfies Partial<AutomationError>);
|
|
263
|
-
expect(browser.closeCalls).toHaveLength(1);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('calls actor hooks around task execution', async () => {
|
|
267
|
-
const calls: string[] = [];
|
|
268
|
-
const { runner } = createRunnerParts();
|
|
269
|
-
const actor = defineActor({
|
|
270
|
-
id: 'example',
|
|
271
|
-
baseUrl: 'https://example.com',
|
|
272
|
-
hooks: {
|
|
273
|
-
beforeRun: () => { calls.push('before'); },
|
|
274
|
-
afterRun: () => { calls.push('after'); }
|
|
275
|
-
},
|
|
276
|
-
tasks: {
|
|
277
|
-
task: async () => 'ok'
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
await runner.run(actor, 'task');
|
|
282
|
-
|
|
283
|
-
expect(calls).toEqual(['before', 'after']);
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
describe('ActorSequenceRunner', () => {
|
|
288
|
-
const auth = {
|
|
289
|
-
loginUrl: '/login',
|
|
290
|
-
selectors: {
|
|
291
|
-
username: '#email',
|
|
292
|
-
password: '#password',
|
|
293
|
-
submit: '#submit',
|
|
294
|
-
loggedInSignal: '#account'
|
|
295
|
-
},
|
|
296
|
-
credentials: { id: 'example' }
|
|
297
|
-
} satisfies LoginFlowDefinition;
|
|
298
|
-
|
|
299
|
-
it('runs a series of tasks in a single browser session', async () => {
|
|
300
|
-
const calls: string[] = [];
|
|
301
|
-
const { runner, browserFactory, browser } = createSequenceRunnerParts();
|
|
302
|
-
const actor = defineActor({
|
|
303
|
-
id: 'example',
|
|
304
|
-
baseUrl: 'https://example.com',
|
|
305
|
-
tasks: {
|
|
306
|
-
first: async () => {
|
|
307
|
-
calls.push('first');
|
|
308
|
-
return 'one';
|
|
309
|
-
},
|
|
310
|
-
second: async () => {
|
|
311
|
-
calls.push('second');
|
|
312
|
-
return 'two';
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
const result = await runner.runSequence(actor, ['first', 'second']);
|
|
318
|
-
|
|
319
|
-
expect(result).toEqual(['one', 'two']);
|
|
320
|
-
expect(calls).toEqual(['first', 'second']);
|
|
321
|
-
expect(browserFactory.launchCalls).toBe(1);
|
|
322
|
-
expect(browser.closeCalls).toHaveLength(1);
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it('runs authentication once before the first sequence step', async () => {
|
|
326
|
-
const { runner, loginFlow } = createSequenceRunnerParts();
|
|
327
|
-
const actor = defineActor({
|
|
328
|
-
id: 'example',
|
|
329
|
-
baseUrl: 'https://example.com',
|
|
330
|
-
auth,
|
|
331
|
-
tasks: {
|
|
332
|
-
a: async () => 'a',
|
|
333
|
-
b: async () => 'b'
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
await runner.runSequence(actor, ['a', 'b']);
|
|
338
|
-
|
|
339
|
-
expect(loginFlow.ensureAuthenticatedCalls).toEqual([auth]);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it('calls hooks for each step and wraps errors with the failing task name', async () => {
|
|
343
|
-
const calls: string[] = [];
|
|
344
|
-
const { runner, browser } = createSequenceRunnerParts();
|
|
345
|
-
const actor = defineActor({
|
|
346
|
-
id: 'example',
|
|
347
|
-
baseUrl: 'https://example.com',
|
|
348
|
-
hooks: {
|
|
349
|
-
beforeRun: (_context, taskName) => { calls.push(`before:${taskName}`); },
|
|
350
|
-
afterRun: (_context, taskName) => { calls.push(`after:${taskName}`); },
|
|
351
|
-
onError: (_context, taskName) => { calls.push(`error:${taskName}`); }
|
|
352
|
-
},
|
|
353
|
-
tasks: {
|
|
354
|
-
first: async () => 'ok',
|
|
355
|
-
second: async () => {
|
|
356
|
-
throw new Error('boom');
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
await expect(runner.runSequence(actor, ['first', 'second'])).rejects.toMatchObject({
|
|
362
|
-
name: 'AutomationError',
|
|
363
|
-
actorId: 'example',
|
|
364
|
-
taskName: 'second'
|
|
365
|
-
} satisfies Partial<AutomationError>);
|
|
366
|
-
|
|
367
|
-
expect(calls).toEqual(['before:first', 'after:first', 'before:second', 'error:second']);
|
|
368
|
-
expect(browser.closeCalls).toHaveLength(1);
|
|
369
|
-
});
|
|
370
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { ActorRegistry } from '../../../src/core/ActorRegistry.js';
|
|
3
|
-
import { defineActor } from '../../../src/core/defineActor.js';
|
|
4
|
-
import { defineFormFillerTask, defineFormTask } from '../../../src/core/Actor.js';
|
|
5
|
-
import { ConfigError } from '../../../src/errors/ConfigError.js';
|
|
6
|
-
import { makeContext } from '../../fixtures/makeContext.js';
|
|
7
|
-
|
|
8
|
-
describe('defineActor and ActorRegistry', () => {
|
|
9
|
-
it('preserves actor definitions and registers them by id', () => {
|
|
10
|
-
const actor = defineActor({
|
|
11
|
-
id: 'example',
|
|
12
|
-
baseUrl: 'https://example.com',
|
|
13
|
-
tasks: {
|
|
14
|
-
test: async () => 'ok'
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const registry = new ActorRegistry().register(actor);
|
|
19
|
-
|
|
20
|
-
expect(registry.get('example')).toBe(actor);
|
|
21
|
-
expect(registry.list()).toEqual([actor]);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('rejects duplicate actor ids', () => {
|
|
25
|
-
const actor = defineActor({
|
|
26
|
-
id: 'example',
|
|
27
|
-
baseUrl: 'https://example.com',
|
|
28
|
-
tasks: {}
|
|
29
|
-
});
|
|
30
|
-
const registry = new ActorRegistry().register(actor);
|
|
31
|
-
|
|
32
|
-
expect(() => registry.register(actor)).toThrow(ConfigError);
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('defineFormTask', () => {
|
|
37
|
-
it('creates a task that fills listed fields and submits the form from input data', async () => {
|
|
38
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
39
|
-
fakePage
|
|
40
|
-
.setSelector('#name')
|
|
41
|
-
.setSelector('#email')
|
|
42
|
-
.setSelector('#submit');
|
|
43
|
-
|
|
44
|
-
const task = defineFormTask<{ name: string; email: string }, { submittedEmail: string }>({
|
|
45
|
-
url: '/contact',
|
|
46
|
-
fields: [
|
|
47
|
-
{ selector: '#name', inputKey: 'name' },
|
|
48
|
-
{ selector: '#email', inputKey: 'email' }
|
|
49
|
-
],
|
|
50
|
-
submit: {
|
|
51
|
-
selector: '#submit',
|
|
52
|
-
waitForNavigation: true
|
|
53
|
-
},
|
|
54
|
-
onComplete: (_ctx, input) => ({ submittedEmail: input.email })
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const result = await task(context, {
|
|
58
|
-
name: 'Jane Doe',
|
|
59
|
-
email: 'jane@example.com'
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
expect(result).toEqual({ submittedEmail: 'jane@example.com' });
|
|
63
|
-
expect(fakePage.gotos[0]?.url).toBe('https://example.com/contact');
|
|
64
|
-
expect(fakeInteractor.typed).toEqual([
|
|
65
|
-
{ selector: '#name', value: 'Jane Doe', options: { required: true } },
|
|
66
|
-
{ selector: '#email', value: 'jane@example.com', options: { required: true } }
|
|
67
|
-
]);
|
|
68
|
-
expect(fakeInteractor.clicks).toEqual([{ selector: '#submit' }]);
|
|
69
|
-
expect(fakePage.waitedNavigations).toHaveLength(1);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('supports literal/resolved field values and the defineFormFillerTask alias', async () => {
|
|
73
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
74
|
-
fakePage
|
|
75
|
-
.setSelector('#first')
|
|
76
|
-
.setSelector('#last')
|
|
77
|
-
.setSelector('#submit');
|
|
78
|
-
|
|
79
|
-
const task = defineFormFillerTask<{ firstName: string; lastName: string }>({
|
|
80
|
-
fields: [
|
|
81
|
-
{ selector: '#first', inputKey: 'firstName' },
|
|
82
|
-
{ selector: '#last', value: input => input.lastName.toUpperCase() }
|
|
83
|
-
],
|
|
84
|
-
submit: { selector: '#submit' }
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await task(context, {
|
|
88
|
-
firstName: 'Jane',
|
|
89
|
-
lastName: 'Doe'
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
expect(fakeInteractor.typed).toEqual([
|
|
93
|
-
{ selector: '#first', value: 'Jane', options: { required: true } },
|
|
94
|
-
{ selector: '#last', value: 'DOE', options: { required: true } }
|
|
95
|
-
]);
|
|
96
|
-
expect(fakeInteractor.clicks).toEqual([{ selector: '#submit' }]);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('throws when a required field has no input value', async () => {
|
|
100
|
-
const { context, fakePage } = makeContext();
|
|
101
|
-
fakePage
|
|
102
|
-
.setSelector('#name')
|
|
103
|
-
.setSelector('#submit');
|
|
104
|
-
|
|
105
|
-
const task = defineFormTask<{ name?: string }>({
|
|
106
|
-
fields: [{ selector: '#name', inputKey: 'name' }],
|
|
107
|
-
submit: { selector: '#submit' }
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
await expect(task(context, {})).rejects.toThrow('missing a value');
|
|
111
|
-
});
|
|
112
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { Extractor } from '../../../src/extraction/Extractor.js';
|
|
3
|
-
import { PuppeteerPageAdapter } from '../../../src/interaction/PageAdapter.js';
|
|
4
|
-
import { FakePage } from '../../fixtures/FakePage.js';
|
|
5
|
-
|
|
6
|
-
describe('Extractor', () => {
|
|
7
|
-
it('extracts single text and text lists', async () => {
|
|
8
|
-
const fakePage = new FakePage()
|
|
9
|
-
.setSelector('#title', { textContent: ' Title ' })
|
|
10
|
-
.setTextList('.row', [' One ', 'Two', ' ']);
|
|
11
|
-
const extractor = new Extractor(new PuppeteerPageAdapter(fakePage));
|
|
12
|
-
|
|
13
|
-
await expect(extractor.text('#title')).resolves.toBe('Title');
|
|
14
|
-
await expect(extractor.textList('.row')).resolves.toEqual(['One', 'Two']);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('extracts attributes and href lists', async () => {
|
|
18
|
-
const fakePage = new FakePage()
|
|
19
|
-
.setSelector('#link', { attributes: { href: '/single' } });
|
|
20
|
-
fakePage.selectorLists.set('a', [
|
|
21
|
-
{ attributes: { href: '/a' } },
|
|
22
|
-
{ attributes: { href: '/b' } },
|
|
23
|
-
{ attributes: {} }
|
|
24
|
-
]);
|
|
25
|
-
const extractor = new Extractor(new PuppeteerPageAdapter(fakePage));
|
|
26
|
-
|
|
27
|
-
await expect(extractor.attr('#link', 'href')).resolves.toBe('/single');
|
|
28
|
-
await expect(extractor.hrefs('a')).resolves.toEqual(['/a', '/b']);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('extracts table rows into objects', async () => {
|
|
32
|
-
const fakePage = new FakePage().setSelector('table', {
|
|
33
|
-
attributes: {
|
|
34
|
-
__headers: 'Name|Amount',
|
|
35
|
-
__rows: 'Alice|10\nBob|20'
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
const extractor = new Extractor(new PuppeteerPageAdapter(fakePage));
|
|
39
|
-
|
|
40
|
-
await expect(extractor.table('table')).resolves.toEqual({
|
|
41
|
-
headers: ['Name', 'Amount'],
|
|
42
|
-
rows: [
|
|
43
|
-
{ Name: 'Alice', Amount: '10' },
|
|
44
|
-
{ Name: 'Bob', Amount: '20' }
|
|
45
|
-
]
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
});
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { Pagination } from '../../../src/extraction/Pagination.js';
|
|
3
|
-
import { PuppeteerPageAdapter } from '../../../src/interaction/PageAdapter.js';
|
|
4
|
-
import { FakeHumanInteractor } from '../../fixtures/FakeCursor.js';
|
|
5
|
-
import { FakePage } from '../../fixtures/FakePage.js';
|
|
6
|
-
|
|
7
|
-
describe('Pagination', () => {
|
|
8
|
-
it('collects pages until the next selector is missing', async () => {
|
|
9
|
-
const fakePage = new FakePage().setSelector('#next');
|
|
10
|
-
const interactor = new FakeHumanInteractor();
|
|
11
|
-
const pagination = new Pagination(new PuppeteerPageAdapter(fakePage), interactor);
|
|
12
|
-
let pageNumber = 0;
|
|
13
|
-
|
|
14
|
-
const result = await pagination.collectPages({
|
|
15
|
-
nextSelector: '#next',
|
|
16
|
-
extractPage: async () => {
|
|
17
|
-
pageNumber += 1;
|
|
18
|
-
if (pageNumber === 2) fakePage.removeSelector('#next');
|
|
19
|
-
return [`page-${pageNumber}`];
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
expect(result).toEqual(['page-1', 'page-2']);
|
|
24
|
-
expect(interactor.clicks.map(click => click.selector)).toEqual(['#next']);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('respects maxPages', async () => {
|
|
28
|
-
const fakePage = new FakePage().setSelector('#next');
|
|
29
|
-
const interactor = new FakeHumanInteractor();
|
|
30
|
-
const pagination = new Pagination(new PuppeteerPageAdapter(fakePage), interactor);
|
|
31
|
-
|
|
32
|
-
const result = await pagination.collectPages({
|
|
33
|
-
nextSelector: '#next',
|
|
34
|
-
maxPages: 2,
|
|
35
|
-
extractPage: async () => ['item']
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
expect(result).toEqual(['item', 'item']);
|
|
39
|
-
expect(interactor.clicks).toHaveLength(1);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('deduplicates collected items when configured', async () => {
|
|
43
|
-
const fakePage = new FakePage();
|
|
44
|
-
const pagination = new Pagination(new PuppeteerPageAdapter(fakePage), new FakeHumanInteractor());
|
|
45
|
-
|
|
46
|
-
const result = await pagination.collectPages({
|
|
47
|
-
nextSelector: '#next',
|
|
48
|
-
extractPage: async () => [{ id: '1' }, { id: '1' }],
|
|
49
|
-
dedupeBy: item => item.id
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
expect(result).toEqual([{ id: '1' }]);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { clearField } from '../../../src/interaction/FieldClearer.js';
|
|
3
|
-
import { FakePage } from '../../fixtures/FakePage.js';
|
|
4
|
-
|
|
5
|
-
describe('clearField', () => {
|
|
6
|
-
it('selects field contents and presses Backspace before typing', async () => {
|
|
7
|
-
const page = new FakePage().setSelector('#email', { value: 'old@example.com' });
|
|
8
|
-
|
|
9
|
-
await clearField(page, '#email');
|
|
10
|
-
|
|
11
|
-
const state = page.selectors.get('#email');
|
|
12
|
-
expect(state?.focused).toBe(true);
|
|
13
|
-
expect(state?.selected).toBe(true);
|
|
14
|
-
expect(page.keyboard.pressed).toEqual([{ key: 'Backspace' }]);
|
|
15
|
-
expect(state?.value).toBe('');
|
|
16
|
-
expect(state?.events).toEqual(expect.arrayContaining(['select', 'input', 'change']));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('supports direct DOM clearing for callers that explicitly request it', async () => {
|
|
20
|
-
const page = new FakePage().setSelector('#email', { value: 'old@example.com' });
|
|
21
|
-
|
|
22
|
-
await clearField(page, '#email', { strategy: 'dom-value' });
|
|
23
|
-
|
|
24
|
-
const state = page.selectors.get('#email');
|
|
25
|
-
expect(page.keyboard.pressed).toEqual([]);
|
|
26
|
-
expect(state?.value).toBe('');
|
|
27
|
-
expect(state?.events).toEqual(expect.arrayContaining(['input', 'change']));
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { SelectorError } from '../../../src/errors/SelectorError.js';
|
|
3
|
-
import { FormFiller } from '../../../src/interaction/Forms.js';
|
|
4
|
-
import { PuppeteerPageAdapter } from '../../../src/interaction/PageAdapter.js';
|
|
5
|
-
import { FakeHumanInteractor } from '../../fixtures/FakeCursor.js';
|
|
6
|
-
import { FakePage } from '../../fixtures/FakePage.js';
|
|
7
|
-
|
|
8
|
-
describe('FormFiller', () => {
|
|
9
|
-
it('fills text through the configured human interactor', async () => {
|
|
10
|
-
const page = new FakePage().setSelector('#email');
|
|
11
|
-
const interactor = new FakeHumanInteractor();
|
|
12
|
-
const form = new FormFiller(new PuppeteerPageAdapter(page), interactor);
|
|
13
|
-
|
|
14
|
-
await form.fillText('#email', 'me@example.com', { delayMs: 10 });
|
|
15
|
-
|
|
16
|
-
expect(interactor.typed).toEqual([{ selector: '#email', value: 'me@example.com', options: { delayMs: 10 } }]);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('throws SelectorError for a missing required field', async () => {
|
|
20
|
-
const page = new FakePage();
|
|
21
|
-
const form = new FormFiller(new PuppeteerPageAdapter(page), new FakeHumanInteractor());
|
|
22
|
-
|
|
23
|
-
await expect(form.fillText('#missing', 'value')).rejects.toBeInstanceOf(SelectorError);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('skips missing optional fields', async () => {
|
|
27
|
-
const page = new FakePage();
|
|
28
|
-
const interactor = new FakeHumanInteractor();
|
|
29
|
-
const form = new FormFiller(new PuppeteerPageAdapter(page), interactor);
|
|
30
|
-
|
|
31
|
-
await form.fillText('#missing', 'value', { required: false });
|
|
32
|
-
|
|
33
|
-
expect(interactor.typed).toHaveLength(0);
|
|
34
|
-
});
|
|
35
|
-
});
|