@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,296 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { defineLoginFlow } from '../../../src/auth/LoginFlow.types.js';
|
|
3
|
-
import { LoginFlow } from '../../../src/auth/LoginFlow.js';
|
|
4
|
-
import { AuthError } from '../../../src/errors/AuthError.js';
|
|
5
|
-
import { FakeCredentialsProvider } from '../../fixtures/FakeCredentialsProvider.js';
|
|
6
|
-
import { makeContext } from '../../fixtures/makeContext.js';
|
|
7
|
-
|
|
8
|
-
describe('LoginFlow', () => {
|
|
9
|
-
const loginDefinition = defineLoginFlow({
|
|
10
|
-
loginUrl: '/login',
|
|
11
|
-
selectors: {
|
|
12
|
-
username: '#email',
|
|
13
|
-
password: '#password',
|
|
14
|
-
submit: '#submit',
|
|
15
|
-
loggedInSignal: '#account',
|
|
16
|
-
errorMessage: '#login-error'
|
|
17
|
-
},
|
|
18
|
-
credentials: { id: 'example' },
|
|
19
|
-
behavior: {
|
|
20
|
-
submitCausesNavigation: true,
|
|
21
|
-
errorTimeoutMs: 25
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('navigates, fills credentials, clicks submit, and verifies login', async () => {
|
|
26
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
27
|
-
fakePage
|
|
28
|
-
.setSelector('#email')
|
|
29
|
-
.setSelector('#password')
|
|
30
|
-
.setSelector('#submit')
|
|
31
|
-
.setSelector('#account');
|
|
32
|
-
const credentialsProvider = new FakeCredentialsProvider({ username: 'me@example.com', password: 'passw0rd' });
|
|
33
|
-
const flow = new LoginFlow(credentialsProvider);
|
|
34
|
-
|
|
35
|
-
await flow.login(context, loginDefinition);
|
|
36
|
-
|
|
37
|
-
expect(fakePage.gotos[0]?.url).toBe('https://example.com/login');
|
|
38
|
-
expect(fakeInteractor.typed).toEqual([
|
|
39
|
-
{ selector: '#email', value: 'me@example.com', options: { required: true, clear: true, clearMethod: 'select-delete' } },
|
|
40
|
-
{ selector: '#password', value: 'passw0rd', options: { required: true, clear: true, clearMethod: 'select-delete' } }
|
|
41
|
-
]);
|
|
42
|
-
expect(fakeInteractor.clicks.map(click => click.selector)).toContain('#submit');
|
|
43
|
-
expect(fakePage.waitedNavigations).toHaveLength(1);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
it('passes clearFieldBeforeTyping=false through the standardized login flow', async () => {
|
|
48
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
49
|
-
fakePage
|
|
50
|
-
.setSelector('#email')
|
|
51
|
-
.setSelector('#password')
|
|
52
|
-
.setSelector('#submit')
|
|
53
|
-
.setSelector('#account');
|
|
54
|
-
const flow = new LoginFlow(new FakeCredentialsProvider({ username: 'me@example.com', password: 'passw0rd' }));
|
|
55
|
-
|
|
56
|
-
await flow.login(context, defineLoginFlow({
|
|
57
|
-
...loginDefinition,
|
|
58
|
-
behavior: {
|
|
59
|
-
...loginDefinition.behavior,
|
|
60
|
-
clearFieldBeforeTyping: false,
|
|
61
|
-
typing: { targetWordsPerMinute: 70, intervalJitterMs: 6 }
|
|
62
|
-
}
|
|
63
|
-
}));
|
|
64
|
-
|
|
65
|
-
expect(fakeInteractor.typed).toEqual([
|
|
66
|
-
{
|
|
67
|
-
selector: '#email',
|
|
68
|
-
value: 'me@example.com',
|
|
69
|
-
options: {
|
|
70
|
-
required: true,
|
|
71
|
-
clear: false,
|
|
72
|
-
clearMethod: 'select-delete',
|
|
73
|
-
typing: { targetWordsPerMinute: 70, intervalJitterMs: 6 }
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
selector: '#password',
|
|
78
|
-
value: 'passw0rd',
|
|
79
|
-
options: {
|
|
80
|
-
required: true,
|
|
81
|
-
clear: false,
|
|
82
|
-
clearMethod: 'select-delete',
|
|
83
|
-
typing: { targetWordsPerMinute: 70, intervalJitterMs: 6 }
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
it('executes an ordered multi-step login flow with username, continue, password, and submit', async () => {
|
|
91
|
-
const calls: string[] = [];
|
|
92
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
93
|
-
fakePage
|
|
94
|
-
.setSelector('#username')
|
|
95
|
-
.setSelector('#continue')
|
|
96
|
-
.setSelector('#password')
|
|
97
|
-
.setSelector('#password-submit')
|
|
98
|
-
.setSelector('#account');
|
|
99
|
-
const flow = new LoginFlow(new FakeCredentialsProvider({ username: 'me@example.com', password: 'passw0rd' }));
|
|
100
|
-
|
|
101
|
-
await flow.login(context, defineLoginFlow({
|
|
102
|
-
loginUrl: '/login',
|
|
103
|
-
selectors: {
|
|
104
|
-
loggedInSignal: '#account',
|
|
105
|
-
errorMessage: '#login-error'
|
|
106
|
-
},
|
|
107
|
-
credentials: { id: 'example' },
|
|
108
|
-
behavior: {
|
|
109
|
-
errorTimeoutMs: 25,
|
|
110
|
-
typing: { targetWordsPerMinute: 65, intervalJitterMs: 8 }
|
|
111
|
-
},
|
|
112
|
-
hooks: {
|
|
113
|
-
beforeSubmit: () => { calls.push('beforeSubmit'); },
|
|
114
|
-
afterSubmit: () => { calls.push('afterSubmit'); }
|
|
115
|
-
},
|
|
116
|
-
steps: [
|
|
117
|
-
{ type: 'fill', name: 'username', selector: '#username', credential: 'username' },
|
|
118
|
-
{ type: 'click', name: 'continue', selector: '#continue', waitForSelector: '#password', waitForSelectorTimeoutMs: 500 },
|
|
119
|
-
{
|
|
120
|
-
type: 'fill',
|
|
121
|
-
name: 'password',
|
|
122
|
-
selector: '#password',
|
|
123
|
-
credential: 'password',
|
|
124
|
-
typing: { intervalJitterMs: 4 }
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
type: 'click',
|
|
128
|
-
name: 'submit password',
|
|
129
|
-
selector: '#password-submit',
|
|
130
|
-
submit: true,
|
|
131
|
-
waitForNavigation: true,
|
|
132
|
-
checkForError: false
|
|
133
|
-
}
|
|
134
|
-
]
|
|
135
|
-
}));
|
|
136
|
-
|
|
137
|
-
expect(fakePage.gotos[0]?.url).toBe('https://example.com/login');
|
|
138
|
-
expect(fakeInteractor.typed).toEqual([
|
|
139
|
-
{
|
|
140
|
-
selector: '#username',
|
|
141
|
-
value: 'me@example.com',
|
|
142
|
-
options: {
|
|
143
|
-
required: true,
|
|
144
|
-
clear: true,
|
|
145
|
-
clearMethod: 'select-delete',
|
|
146
|
-
typing: { targetWordsPerMinute: 65, intervalJitterMs: 8 }
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
selector: '#password',
|
|
151
|
-
value: 'passw0rd',
|
|
152
|
-
options: {
|
|
153
|
-
required: true,
|
|
154
|
-
clear: true,
|
|
155
|
-
clearMethod: 'select-delete',
|
|
156
|
-
typing: { targetWordsPerMinute: 65, intervalJitterMs: 4 }
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
]);
|
|
160
|
-
expect(fakeInteractor.clicks.map(click => click.selector)).toEqual(['#continue', '#password-submit']);
|
|
161
|
-
expect(fakePage.waitedSelectors.map(wait => wait.selector)).toContain('#password');
|
|
162
|
-
expect(fakePage.waitedNavigations).toHaveLength(1);
|
|
163
|
-
expect(calls).toEqual(['beforeSubmit', 'afterSubmit']);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('lets each multi-step fill override clearFieldBeforeTyping', async () => {
|
|
167
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
168
|
-
fakePage
|
|
169
|
-
.setSelector('#username')
|
|
170
|
-
.setSelector('#continue')
|
|
171
|
-
.setSelector('#password')
|
|
172
|
-
.setSelector('#submit')
|
|
173
|
-
.setSelector('#account');
|
|
174
|
-
const flow = new LoginFlow(new FakeCredentialsProvider({ username: 'me@example.com', password: 'passw0rd' }));
|
|
175
|
-
|
|
176
|
-
await flow.login(context, defineLoginFlow({
|
|
177
|
-
loginUrl: '/login',
|
|
178
|
-
selectors: { loggedInSignal: '#account' },
|
|
179
|
-
credentials: { id: 'example' },
|
|
180
|
-
behavior: { clearFieldBeforeTyping: true },
|
|
181
|
-
steps: [
|
|
182
|
-
{ type: 'fill', selector: '#username', credential: 'username' },
|
|
183
|
-
{ type: 'click', selector: '#continue', waitForSelector: '#password' },
|
|
184
|
-
{ type: 'fill', selector: '#password', credential: 'password', clearFieldBeforeTyping: false },
|
|
185
|
-
{ type: 'click', selector: '#submit', submit: true }
|
|
186
|
-
]
|
|
187
|
-
}));
|
|
188
|
-
|
|
189
|
-
expect(fakeInteractor.typed).toEqual([
|
|
190
|
-
{ selector: '#username', value: 'me@example.com', options: { required: true, clear: true, clearMethod: 'select-delete' } },
|
|
191
|
-
{ selector: '#password', value: 'passw0rd', options: { required: true, clear: false, clearMethod: 'select-delete' } }
|
|
192
|
-
]);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('surfaces an intermediate multi-step login error before waiting for the next field', async () => {
|
|
196
|
-
const { context, fakePage, fakeInteractor } = makeContext();
|
|
197
|
-
fakePage
|
|
198
|
-
.setSelector('#username')
|
|
199
|
-
.setSelector('#continue')
|
|
200
|
-
.setSelector('#login-error', { textContent: 'Unknown Apple ID' });
|
|
201
|
-
const flow = new LoginFlow(new FakeCredentialsProvider({ username: 'me@example.com', password: 'passw0rd' }));
|
|
202
|
-
|
|
203
|
-
await expect(flow.login(context, defineLoginFlow({
|
|
204
|
-
loginUrl: '/login',
|
|
205
|
-
selectors: {
|
|
206
|
-
loggedInSignal: '#account',
|
|
207
|
-
errorMessage: '#login-error'
|
|
208
|
-
},
|
|
209
|
-
credentials: { id: 'example' },
|
|
210
|
-
behavior: { errorTimeoutMs: 25 },
|
|
211
|
-
steps: [
|
|
212
|
-
{ type: 'fill', name: 'username', selector: '#username', credential: 'username' },
|
|
213
|
-
{ type: 'click', name: 'continue', selector: '#continue', waitForSelector: '#password' },
|
|
214
|
-
{ type: 'fill', name: 'password', selector: '#password', credential: 'password' }
|
|
215
|
-
]
|
|
216
|
-
}))).rejects.toThrow('Login failed during step "continue": Unknown Apple ID');
|
|
217
|
-
|
|
218
|
-
expect(fakeInteractor.typed).toEqual([
|
|
219
|
-
{ selector: '#username', value: 'me@example.com', options: { required: true, clear: true, clearMethod: 'select-delete' } }
|
|
220
|
-
]);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('requires steps when standard username/password/submit selectors are omitted', async () => {
|
|
224
|
-
const { context } = makeContext();
|
|
225
|
-
const flow = new LoginFlow(new FakeCredentialsProvider());
|
|
226
|
-
|
|
227
|
-
await expect(flow.login(context, defineLoginFlow({
|
|
228
|
-
loginUrl: '/login',
|
|
229
|
-
selectors: { loggedInSignal: '#account' },
|
|
230
|
-
credentials: { id: 'example' }
|
|
231
|
-
}))).rejects.toThrow('either selectors.username/password/submit');
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('skips login when the existing profile is already authenticated', async () => {
|
|
235
|
-
const { context, fakePage } = makeContext();
|
|
236
|
-
fakePage.setSelector('#account');
|
|
237
|
-
const credentialsProvider = new FakeCredentialsProvider();
|
|
238
|
-
const flow = new LoginFlow(credentialsProvider);
|
|
239
|
-
|
|
240
|
-
await flow.ensureAuthenticated(context, loginDefinition);
|
|
241
|
-
|
|
242
|
-
expect(credentialsProvider.requested).toHaveLength(0);
|
|
243
|
-
expect(fakePage.gotos).toHaveLength(0);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('throws an AuthError when an error message appears after submit', async () => {
|
|
247
|
-
const { context, fakePage } = makeContext();
|
|
248
|
-
fakePage
|
|
249
|
-
.setSelector('#email')
|
|
250
|
-
.setSelector('#password')
|
|
251
|
-
.setSelector('#submit')
|
|
252
|
-
.setSelector('#login-error', { textContent: 'Bad credentials' });
|
|
253
|
-
const flow = new LoginFlow(new FakeCredentialsProvider());
|
|
254
|
-
|
|
255
|
-
await expect(flow.login(context, loginDefinition)).rejects.toBeInstanceOf(AuthError);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('calls hooks in order', async () => {
|
|
259
|
-
const calls: string[] = [];
|
|
260
|
-
const { context, fakePage } = makeContext();
|
|
261
|
-
fakePage
|
|
262
|
-
.setSelector('#email')
|
|
263
|
-
.setSelector('#password')
|
|
264
|
-
.setSelector('#submit')
|
|
265
|
-
.setSelector('#account');
|
|
266
|
-
const flow = new LoginFlow(new FakeCredentialsProvider());
|
|
267
|
-
|
|
268
|
-
await flow.login(context, defineLoginFlow({
|
|
269
|
-
...loginDefinition,
|
|
270
|
-
hooks: {
|
|
271
|
-
beforeLogin: () => { calls.push('beforeLogin'); },
|
|
272
|
-
afterUsername: () => { calls.push('afterUsername'); },
|
|
273
|
-
afterPassword: () => { calls.push('afterPassword'); },
|
|
274
|
-
beforeSubmit: () => { calls.push('beforeSubmit'); },
|
|
275
|
-
afterSubmit: () => { calls.push('afterSubmit'); }
|
|
276
|
-
}
|
|
277
|
-
}));
|
|
278
|
-
|
|
279
|
-
expect(calls).toEqual(['beforeLogin', 'afterUsername', 'afterPassword', 'beforeSubmit', 'afterSubmit']);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it('does not write raw passwords to the logger metadata', async () => {
|
|
283
|
-
const { context, fakePage, logger } = makeContext();
|
|
284
|
-
fakePage
|
|
285
|
-
.setSelector('#email')
|
|
286
|
-
.setSelector('#password')
|
|
287
|
-
.setSelector('#submit')
|
|
288
|
-
.setSelector('#account');
|
|
289
|
-
const flow = new LoginFlow(new FakeCredentialsProvider({ username: 'me@example.com', password: 'super-secret' }));
|
|
290
|
-
|
|
291
|
-
await flow.login(context, loginDefinition);
|
|
292
|
-
|
|
293
|
-
expect(JSON.stringify(logger.records)).not.toContain('super-secret');
|
|
294
|
-
expect(JSON.stringify(logger.records)).toContain('[REDACTED]');
|
|
295
|
-
});
|
|
296
|
-
});
|
|
@@ -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 { BrowserFactory } from '../../../src/browser/BrowserFactory.js';
|
|
6
|
-
import type { PuppeteerConnectOptions, PuppeteerLauncher, PuppeteerLaunchOptions } from '../../../src/browser/PuppeteerLike.js';
|
|
7
|
-
import type { RuntimeConfig } from '../../../src/browser/RuntimeConfig.js';
|
|
8
|
-
import { ConfigError } from '../../../src/errors/ConfigError.js';
|
|
9
|
-
import { FakeBrowser, FakePage } from '../../fixtures/FakePage.js';
|
|
10
|
-
|
|
11
|
-
class ThrowingLauncher implements PuppeteerLauncher {
|
|
12
|
-
async launch(): Promise<FakeBrowser> {
|
|
13
|
-
throw new Error('DevToolsActivePort file does not exist; user data directory is already in use');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async connect(): Promise<FakeBrowser> {
|
|
17
|
-
throw new Error('connect was not expected');
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class ThrowingLaunchConnectingLauncher implements PuppeteerLauncher {
|
|
23
|
-
launchOptions?: PuppeteerLaunchOptions;
|
|
24
|
-
connectOptions?: PuppeteerConnectOptions;
|
|
25
|
-
|
|
26
|
-
constructor(readonly browser: FakeBrowser, private readonly connectError?: Error) {}
|
|
27
|
-
|
|
28
|
-
async launch(options: PuppeteerLaunchOptions): Promise<FakeBrowser> {
|
|
29
|
-
this.launchOptions = options;
|
|
30
|
-
throw new Error('DevToolsActivePort file does not exist; user data directory is already in use');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async connect(options: PuppeteerConnectOptions): Promise<FakeBrowser> {
|
|
34
|
-
this.connectOptions = options;
|
|
35
|
-
if (this.connectError !== undefined) throw this.connectError;
|
|
36
|
-
return this.browser;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
class ThrowingConnector implements PuppeteerLauncher {
|
|
41
|
-
async launch(): Promise<FakeBrowser> {
|
|
42
|
-
throw new Error('launch was not expected');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async connect(): Promise<FakeBrowser> {
|
|
46
|
-
throw new Error('connect ECONNREFUSED 127.0.0.1:9222');
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
class FakeLauncher implements PuppeteerLauncher {
|
|
51
|
-
launchOptions?: PuppeteerLaunchOptions;
|
|
52
|
-
connectOptions?: PuppeteerConnectOptions;
|
|
53
|
-
|
|
54
|
-
constructor(readonly browser: FakeBrowser) {}
|
|
55
|
-
|
|
56
|
-
async launch(options: PuppeteerLaunchOptions): Promise<FakeBrowser> {
|
|
57
|
-
this.launchOptions = options;
|
|
58
|
-
return this.browser;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async connect(options: PuppeteerConnectOptions): Promise<FakeBrowser> {
|
|
62
|
-
this.connectOptions = options;
|
|
63
|
-
return this.browser;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function createProfileDirs(profileDirectory = 'Profile 1'): { userDataDir: string; profileDirectory: string } {
|
|
68
|
-
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paf-user-data-'));
|
|
69
|
-
fs.mkdirSync(path.join(userDataDir, profileDirectory));
|
|
70
|
-
return { userDataDir, profileDirectory };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
describe('BrowserFactory', () => {
|
|
74
|
-
it('launches installed Chrome with an existing userDataDir and profile directory', async () => {
|
|
75
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
76
|
-
const fakePage = new FakePage();
|
|
77
|
-
const fakeBrowser = new FakeBrowser([fakePage]);
|
|
78
|
-
const launcher = new FakeLauncher(fakeBrowser);
|
|
79
|
-
const factory = new BrowserFactory(launcher);
|
|
80
|
-
|
|
81
|
-
const config: RuntimeConfig = {
|
|
82
|
-
browser: {
|
|
83
|
-
mode: 'existing-profile',
|
|
84
|
-
userDataDir,
|
|
85
|
-
profileDirectory,
|
|
86
|
-
channel: 'chrome',
|
|
87
|
-
headless: false
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const session = await factory.launch(config);
|
|
92
|
-
|
|
93
|
-
expect(launcher.launchOptions).toMatchObject({
|
|
94
|
-
browser: 'chrome',
|
|
95
|
-
channel: 'chrome',
|
|
96
|
-
headless: false,
|
|
97
|
-
userDataDir,
|
|
98
|
-
args: [`--profile-directory=${profileDirectory}`]
|
|
99
|
-
});
|
|
100
|
-
expect(launcher.connectOptions).toBeUndefined();
|
|
101
|
-
expect(session.context).toBe(fakeBrowser.context);
|
|
102
|
-
expect(session.page).toBe(fakePage);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('connects to an existing Chrome remote debugging endpoint', async () => {
|
|
106
|
-
const fakeBrowser = new FakeBrowser([new FakePage()]);
|
|
107
|
-
const launcher = new FakeLauncher(fakeBrowser);
|
|
108
|
-
const factory = new BrowserFactory(launcher);
|
|
109
|
-
|
|
110
|
-
const session = await factory.launch({
|
|
111
|
-
browser: {
|
|
112
|
-
mode: 'remote-debugging',
|
|
113
|
-
browserURL: 'http://127.0.0.1:9222'
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
expect(launcher.launchOptions).toBeUndefined();
|
|
118
|
-
expect(launcher.connectOptions).toMatchObject({ browserURL: 'http://127.0.0.1:9222' });
|
|
119
|
-
expect(session.profile).toMatchObject({
|
|
120
|
-
mode: 'remote-debugging',
|
|
121
|
-
browserURL: 'http://127.0.0.1:9222',
|
|
122
|
-
closeBrowserOnFinish: false,
|
|
123
|
-
disconnectOnFinish: true
|
|
124
|
-
});
|
|
125
|
-
expect(fakeBrowser.newPageCalls).toBe(1);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('connects using browserWSEndpoint when configured', async () => {
|
|
129
|
-
const fakeBrowser = new FakeBrowser([new FakePage()]);
|
|
130
|
-
const launcher = new FakeLauncher(fakeBrowser);
|
|
131
|
-
const factory = new BrowserFactory(launcher);
|
|
132
|
-
|
|
133
|
-
await factory.launch({
|
|
134
|
-
browser: {
|
|
135
|
-
mode: 'remote-debugging',
|
|
136
|
-
browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test'
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
expect(launcher.connectOptions).toMatchObject({
|
|
141
|
-
browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test'
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('defaults remote-debugging mode to http://127.0.0.1:9222', async () => {
|
|
146
|
-
const launcher = new FakeLauncher(new FakeBrowser([new FakePage()]));
|
|
147
|
-
const factory = new BrowserFactory(launcher);
|
|
148
|
-
|
|
149
|
-
await factory.launch({ browser: { mode: 'remote-debugging' } });
|
|
150
|
-
|
|
151
|
-
expect(launcher.connectOptions).toMatchObject({ browserURL: 'http://127.0.0.1:9222' });
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('rejects remote-debugging configs that set both browserURL and browserWSEndpoint', async () => {
|
|
155
|
-
const factory = new BrowserFactory(new FakeLauncher(new FakeBrowser()));
|
|
156
|
-
|
|
157
|
-
await expect(factory.launch({
|
|
158
|
-
browser: {
|
|
159
|
-
mode: 'remote-debugging',
|
|
160
|
-
browserURL: 'http://127.0.0.1:9222',
|
|
161
|
-
browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test'
|
|
162
|
-
}
|
|
163
|
-
})).rejects.toBeInstanceOf(ConfigError);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('uses browser.defaultBrowserContext and never creates an isolated context', async () => {
|
|
167
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
168
|
-
const fakeBrowser = new FakeBrowser([new FakePage()]);
|
|
169
|
-
const factory = new BrowserFactory(new FakeLauncher(fakeBrowser));
|
|
170
|
-
|
|
171
|
-
await factory.launch({
|
|
172
|
-
browser: {
|
|
173
|
-
mode: 'existing-profile',
|
|
174
|
-
userDataDir,
|
|
175
|
-
profileDirectory,
|
|
176
|
-
headless: false,
|
|
177
|
-
runningInstance: { enabled: false }
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
expect(fakeBrowser.defaultBrowserContextCalls).toBe(1);
|
|
182
|
-
expect(fakeBrowser.createBrowserContextCalls).toBe(0);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('defaults to channel chrome and headless false', () => {
|
|
186
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
187
|
-
const factory = new BrowserFactory(new FakeLauncher(new FakeBrowser()));
|
|
188
|
-
|
|
189
|
-
const options = factory.buildLaunchOptions({
|
|
190
|
-
mode: 'existing-profile',
|
|
191
|
-
userDataDir,
|
|
192
|
-
profileDirectory
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
expect(options.channel).toBe('chrome');
|
|
196
|
-
expect(options.headless).toBe(false);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('throws when userDataDir does not exist by default', async () => {
|
|
200
|
-
const missingDir = path.join(os.tmpdir(), `missing-${Date.now()}`);
|
|
201
|
-
const factory = new BrowserFactory(new FakeLauncher(new FakeBrowser()));
|
|
202
|
-
|
|
203
|
-
await expect(factory.launch({
|
|
204
|
-
browser: {
|
|
205
|
-
mode: 'existing-profile',
|
|
206
|
-
userDataDir: missingDir,
|
|
207
|
-
headless: false
|
|
208
|
-
}
|
|
209
|
-
})).rejects.toBeInstanceOf(ConfigError);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('throws when a configured profileDirectory is missing by default', async () => {
|
|
213
|
-
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paf-user-data-'));
|
|
214
|
-
const factory = new BrowserFactory(new FakeLauncher(new FakeBrowser()));
|
|
215
|
-
|
|
216
|
-
await expect(factory.launch({
|
|
217
|
-
browser: {
|
|
218
|
-
mode: 'existing-profile',
|
|
219
|
-
userDataDir,
|
|
220
|
-
profileDirectory: 'Missing Profile',
|
|
221
|
-
headless: false
|
|
222
|
-
}
|
|
223
|
-
})).rejects.toBeInstanceOf(ConfigError);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
it('automatically tries the default local debugging endpoint when an existing profile is already running', async () => {
|
|
228
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
229
|
-
const fakeBrowser = new FakeBrowser([new FakePage()]);
|
|
230
|
-
const launcher = new ThrowingLaunchConnectingLauncher(fakeBrowser);
|
|
231
|
-
const factory = new BrowserFactory(launcher);
|
|
232
|
-
|
|
233
|
-
const session = await factory.launch({
|
|
234
|
-
browser: {
|
|
235
|
-
mode: 'existing-profile',
|
|
236
|
-
userDataDir,
|
|
237
|
-
profileDirectory,
|
|
238
|
-
headless: false
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
expect(launcher.launchOptions).toMatchObject({ userDataDir });
|
|
243
|
-
expect(launcher.connectOptions).toMatchObject({ browserURL: 'http://127.0.0.1:9222' });
|
|
244
|
-
expect(session.connectionMode).toBe('connected');
|
|
245
|
-
expect(session.profile).toMatchObject({
|
|
246
|
-
mode: 'remote-debugging',
|
|
247
|
-
remoteDebuggingHost: '127.0.0.1',
|
|
248
|
-
remoteDebuggingPort: 9222,
|
|
249
|
-
closeBrowserOnFinish: false,
|
|
250
|
-
disconnectOnFinish: true
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
it('falls back to a local debugging endpoint when an existing profile is already running and fallback is enabled', async () => {
|
|
256
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
257
|
-
const fakeBrowser = new FakeBrowser([new FakePage()]);
|
|
258
|
-
const launcher = new ThrowingLaunchConnectingLauncher(fakeBrowser);
|
|
259
|
-
const factory = new BrowserFactory(launcher);
|
|
260
|
-
|
|
261
|
-
const session = await factory.launch({
|
|
262
|
-
browser: {
|
|
263
|
-
mode: 'existing-profile',
|
|
264
|
-
userDataDir,
|
|
265
|
-
profileDirectory
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
expect(launcher.launchOptions).toMatchObject({ userDataDir });
|
|
270
|
-
expect(launcher.connectOptions).toMatchObject({ browserURL: 'http://127.0.0.1:9222' });
|
|
271
|
-
expect(session.connectionMode).toBe('connected');
|
|
272
|
-
expect(session.profile).toMatchObject({
|
|
273
|
-
mode: 'remote-debugging',
|
|
274
|
-
remoteDebuggingHost: '127.0.0.1',
|
|
275
|
-
remoteDebuggingPort: 9222,
|
|
276
|
-
closeBrowserOnFinish: false,
|
|
277
|
-
disconnectOnFinish: true
|
|
278
|
-
});
|
|
279
|
-
expect(fakeBrowser.newPageCalls).toBe(1);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it('uses the configured running-profile WebSocket endpoint when fallback is enabled', async () => {
|
|
283
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
284
|
-
const launcher = new ThrowingLaunchConnectingLauncher(new FakeBrowser([new FakePage()]));
|
|
285
|
-
const factory = new BrowserFactory(launcher);
|
|
286
|
-
|
|
287
|
-
await factory.launch({
|
|
288
|
-
browser: {
|
|
289
|
-
mode: 'existing-profile',
|
|
290
|
-
userDataDir,
|
|
291
|
-
profileDirectory,
|
|
292
|
-
runningInstance: {
|
|
293
|
-
enabled: true,
|
|
294
|
-
browserWSEndpoint: 'ws://127.0.0.1:9223/devtools/browser/test',
|
|
295
|
-
reuseExistingPage: true
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
expect(launcher.connectOptions).toMatchObject({
|
|
301
|
-
browserWSEndpoint: 'ws://127.0.0.1:9223/devtools/browser/test'
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it('throws a fallback-specific error when the running profile debugging endpoint is unavailable', async () => {
|
|
306
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
307
|
-
const launcher = new ThrowingLaunchConnectingLauncher(new FakeBrowser(), new Error('connect ECONNREFUSED 127.0.0.1:9222'));
|
|
308
|
-
const factory = new BrowserFactory(launcher);
|
|
309
|
-
|
|
310
|
-
await expect(factory.launch({
|
|
311
|
-
browser: {
|
|
312
|
-
mode: 'existing-profile',
|
|
313
|
-
userDataDir,
|
|
314
|
-
profileDirectory,
|
|
315
|
-
runningInstance: { enabled: true }
|
|
316
|
-
}
|
|
317
|
-
})).rejects.toMatchObject({
|
|
318
|
-
name: 'ConfigError',
|
|
319
|
-
message: expect.stringContaining('local DevTools endpoint could not be reached'),
|
|
320
|
-
details: expect.objectContaining({
|
|
321
|
-
browserURL: 'http://127.0.0.1:9222'
|
|
322
|
-
})
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it('explains likely profile lock launch failures', async () => {
|
|
327
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
328
|
-
const factory = new BrowserFactory(new ThrowingLauncher());
|
|
329
|
-
|
|
330
|
-
await expect(factory.launch({
|
|
331
|
-
browser: {
|
|
332
|
-
mode: 'existing-profile',
|
|
333
|
-
userDataDir,
|
|
334
|
-
profileDirectory,
|
|
335
|
-
headless: false,
|
|
336
|
-
runningInstance: { enabled: false }
|
|
337
|
-
}
|
|
338
|
-
})).rejects.toThrow(/local remote debugging endpoint|already be running/);
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it('explains remote debugging connection failures', async () => {
|
|
342
|
-
const factory = new BrowserFactory(new ThrowingConnector());
|
|
343
|
-
|
|
344
|
-
await expect(factory.launch({
|
|
345
|
-
browser: {
|
|
346
|
-
mode: 'remote-debugging',
|
|
347
|
-
browserURL: 'http://127.0.0.1:9222'
|
|
348
|
-
}
|
|
349
|
-
})).rejects.toThrow(/debugging endpoint/);
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('creates a new page when reuseExistingPage is false', async () => {
|
|
353
|
-
const { userDataDir, profileDirectory } = createProfileDirs();
|
|
354
|
-
const fakeBrowser = new FakeBrowser([new FakePage()]);
|
|
355
|
-
const factory = new BrowserFactory(new FakeLauncher(fakeBrowser));
|
|
356
|
-
|
|
357
|
-
const session = await factory.launch({
|
|
358
|
-
browser: {
|
|
359
|
-
mode: 'existing-profile',
|
|
360
|
-
userDataDir,
|
|
361
|
-
profileDirectory,
|
|
362
|
-
reuseExistingPage: false,
|
|
363
|
-
headless: false
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
expect(fakeBrowser.newPageCalls).toBe(1);
|
|
368
|
-
expect(session.page).not.toBe((await fakeBrowser.pages())[0]);
|
|
369
|
-
});
|
|
370
|
-
});
|