browser-commander 0.2.0

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.
Files changed (82) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +296 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.jscpd.json +20 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +32 -0
  9. package/LICENSE +24 -0
  10. package/README.md +320 -0
  11. package/bunfig.toml +3 -0
  12. package/deno.json +7 -0
  13. package/eslint.config.js +125 -0
  14. package/examples/react-test-app/index.html +25 -0
  15. package/examples/react-test-app/package.json +19 -0
  16. package/examples/react-test-app/src/App.jsx +473 -0
  17. package/examples/react-test-app/src/main.jsx +10 -0
  18. package/examples/react-test-app/src/styles.css +323 -0
  19. package/examples/react-test-app/vite.config.js +9 -0
  20. package/package.json +89 -0
  21. package/scripts/changeset-version.mjs +38 -0
  22. package/scripts/create-github-release.mjs +93 -0
  23. package/scripts/create-manual-changeset.mjs +86 -0
  24. package/scripts/format-github-release.mjs +83 -0
  25. package/scripts/format-release-notes.mjs +216 -0
  26. package/scripts/instant-version-bump.mjs +121 -0
  27. package/scripts/merge-changesets.mjs +260 -0
  28. package/scripts/publish-to-npm.mjs +126 -0
  29. package/scripts/setup-npm.mjs +37 -0
  30. package/scripts/validate-changeset.mjs +262 -0
  31. package/scripts/version-and-commit.mjs +237 -0
  32. package/src/ARCHITECTURE.md +270 -0
  33. package/src/README.md +517 -0
  34. package/src/bindings.js +298 -0
  35. package/src/browser/launcher.js +93 -0
  36. package/src/browser/navigation.js +513 -0
  37. package/src/core/constants.js +24 -0
  38. package/src/core/engine-adapter.js +466 -0
  39. package/src/core/engine-detection.js +49 -0
  40. package/src/core/logger.js +21 -0
  41. package/src/core/navigation-manager.js +503 -0
  42. package/src/core/navigation-safety.js +160 -0
  43. package/src/core/network-tracker.js +373 -0
  44. package/src/core/page-session.js +299 -0
  45. package/src/core/page-trigger-manager.js +564 -0
  46. package/src/core/preferences.js +46 -0
  47. package/src/elements/content.js +197 -0
  48. package/src/elements/locators.js +243 -0
  49. package/src/elements/selectors.js +360 -0
  50. package/src/elements/visibility.js +166 -0
  51. package/src/exports.js +121 -0
  52. package/src/factory.js +192 -0
  53. package/src/high-level/universal-logic.js +206 -0
  54. package/src/index.js +17 -0
  55. package/src/interactions/click.js +684 -0
  56. package/src/interactions/fill.js +383 -0
  57. package/src/interactions/scroll.js +341 -0
  58. package/src/utilities/url.js +33 -0
  59. package/src/utilities/wait.js +135 -0
  60. package/tests/e2e/playwright.e2e.test.js +442 -0
  61. package/tests/e2e/puppeteer.e2e.test.js +408 -0
  62. package/tests/helpers/mocks.js +542 -0
  63. package/tests/unit/bindings.test.js +218 -0
  64. package/tests/unit/browser/navigation.test.js +345 -0
  65. package/tests/unit/core/constants.test.js +72 -0
  66. package/tests/unit/core/engine-adapter.test.js +170 -0
  67. package/tests/unit/core/engine-detection.test.js +81 -0
  68. package/tests/unit/core/logger.test.js +80 -0
  69. package/tests/unit/core/navigation-safety.test.js +202 -0
  70. package/tests/unit/core/network-tracker.test.js +198 -0
  71. package/tests/unit/core/page-trigger-manager.test.js +358 -0
  72. package/tests/unit/elements/content.test.js +318 -0
  73. package/tests/unit/elements/locators.test.js +236 -0
  74. package/tests/unit/elements/selectors.test.js +302 -0
  75. package/tests/unit/elements/visibility.test.js +234 -0
  76. package/tests/unit/factory.test.js +174 -0
  77. package/tests/unit/high-level/universal-logic.test.js +299 -0
  78. package/tests/unit/interactions/click.test.js +340 -0
  79. package/tests/unit/interactions/fill.test.js +378 -0
  80. package/tests/unit/interactions/scroll.test.js +330 -0
  81. package/tests/unit/utilities/url.test.js +63 -0
  82. package/tests/unit/utilities/wait.test.js +207 -0
@@ -0,0 +1,198 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createNetworkTracker } from '../../../src/core/network-tracker.js';
4
+ import {
5
+ createMockPlaywrightPage,
6
+ createMockLogger,
7
+ } from '../../helpers/mocks.js';
8
+
9
+ describe('network-tracker', () => {
10
+ let page;
11
+ let log;
12
+
13
+ beforeEach(() => {
14
+ page = createMockPlaywrightPage();
15
+ log = createMockLogger();
16
+ });
17
+
18
+ describe('createNetworkTracker', () => {
19
+ it('should throw when page is not provided', () => {
20
+ assert.throws(
21
+ () => createNetworkTracker({ log, engine: 'playwright' }),
22
+ /page is required/
23
+ );
24
+ });
25
+
26
+ it('should create network tracker', () => {
27
+ const tracker = createNetworkTracker({
28
+ page,
29
+ engine: 'playwright',
30
+ log,
31
+ });
32
+ assert.ok(tracker);
33
+ assert.ok(typeof tracker.startTracking === 'function');
34
+ assert.ok(typeof tracker.stopTracking === 'function');
35
+ assert.ok(typeof tracker.waitForNetworkIdle === 'function');
36
+ assert.ok(typeof tracker.getPendingCount === 'function');
37
+ assert.ok(typeof tracker.getPendingUrls === 'function');
38
+ assert.ok(typeof tracker.reset === 'function');
39
+ assert.ok(typeof tracker.on === 'function');
40
+ assert.ok(typeof tracker.off === 'function');
41
+ });
42
+
43
+ it('should start tracking without error', () => {
44
+ const tracker = createNetworkTracker({
45
+ page,
46
+ engine: 'playwright',
47
+ log,
48
+ });
49
+ tracker.startTracking();
50
+ // Should not throw
51
+ });
52
+
53
+ it('should stop tracking without error', () => {
54
+ const tracker = createNetworkTracker({
55
+ page,
56
+ engine: 'playwright',
57
+ log,
58
+ });
59
+ tracker.startTracking();
60
+ tracker.stopTracking();
61
+ // Should not throw
62
+ });
63
+
64
+ it('should not start tracking twice', () => {
65
+ const tracker = createNetworkTracker({
66
+ page,
67
+ engine: 'playwright',
68
+ log,
69
+ });
70
+ tracker.startTracking();
71
+ tracker.startTracking(); // Should not throw or double-register
72
+ });
73
+
74
+ it('should not stop tracking if not started', () => {
75
+ const tracker = createNetworkTracker({
76
+ page,
77
+ engine: 'playwright',
78
+ log,
79
+ });
80
+ tracker.stopTracking(); // Should not throw
81
+ });
82
+
83
+ it('should return 0 pending count initially', () => {
84
+ const tracker = createNetworkTracker({
85
+ page,
86
+ engine: 'playwright',
87
+ log,
88
+ });
89
+ assert.strictEqual(tracker.getPendingCount(), 0);
90
+ });
91
+
92
+ it('should return empty pending URLs initially', () => {
93
+ const tracker = createNetworkTracker({
94
+ page,
95
+ engine: 'playwright',
96
+ log,
97
+ });
98
+ const urls = tracker.getPendingUrls();
99
+ assert.ok(Array.isArray(urls));
100
+ assert.strictEqual(urls.length, 0);
101
+ });
102
+
103
+ it('should reset without error', () => {
104
+ const tracker = createNetworkTracker({
105
+ page,
106
+ engine: 'playwright',
107
+ log,
108
+ });
109
+ tracker.reset();
110
+ // Should not throw
111
+ });
112
+
113
+ it('should add event listener', () => {
114
+ const tracker = createNetworkTracker({
115
+ page,
116
+ engine: 'playwright',
117
+ log,
118
+ });
119
+ const callback = () => {};
120
+ tracker.on('onRequestStart', callback);
121
+ // Should not throw
122
+ });
123
+
124
+ it('should remove event listener', () => {
125
+ const tracker = createNetworkTracker({
126
+ page,
127
+ engine: 'playwright',
128
+ log,
129
+ });
130
+ const callback = () => {};
131
+ tracker.on('onRequestStart', callback);
132
+ tracker.off('onRequestStart', callback);
133
+ // Should not throw
134
+ });
135
+
136
+ it('should handle invalid event names gracefully', () => {
137
+ const tracker = createNetworkTracker({
138
+ page,
139
+ engine: 'playwright',
140
+ log,
141
+ });
142
+ const callback = () => {};
143
+ tracker.on('invalidEvent', callback);
144
+ tracker.off('invalidEvent', callback);
145
+ // Should not throw
146
+ });
147
+
148
+ it('should accept custom idle timeout', () => {
149
+ const tracker = createNetworkTracker({
150
+ page,
151
+ engine: 'playwright',
152
+ log,
153
+ idleTimeout: 1000,
154
+ });
155
+ assert.ok(tracker);
156
+ });
157
+
158
+ it('should accept custom request timeout', () => {
159
+ const tracker = createNetworkTracker({
160
+ page,
161
+ engine: 'playwright',
162
+ log,
163
+ requestTimeout: 60000,
164
+ });
165
+ assert.ok(tracker);
166
+ });
167
+ });
168
+
169
+ describe('waitForNetworkIdle', () => {
170
+ it('should resolve immediately when no pending requests', async () => {
171
+ const tracker = createNetworkTracker({
172
+ page,
173
+ engine: 'playwright',
174
+ log,
175
+ idleTimeout: 10, // Very short for testing
176
+ });
177
+
178
+ const result = await tracker.waitForNetworkIdle({
179
+ timeout: 100,
180
+ idleTime: 10,
181
+ });
182
+ assert.ok(result === true || result === false); // May timeout in fast test
183
+ });
184
+
185
+ it('should accept timeout option', async () => {
186
+ const tracker = createNetworkTracker({
187
+ page,
188
+ engine: 'playwright',
189
+ log,
190
+ idleTimeout: 10,
191
+ });
192
+
193
+ const result = await tracker.waitForNetworkIdle({ timeout: 50 });
194
+ // Should return within timeout
195
+ assert.ok(typeof result === 'boolean');
196
+ });
197
+ });
198
+ });
@@ -0,0 +1,358 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {
4
+ ActionStoppedError,
5
+ isActionStoppedError,
6
+ makeUrlCondition,
7
+ allConditions,
8
+ anyCondition,
9
+ notCondition,
10
+ createPageTriggerManager,
11
+ } from '../../../src/core/page-trigger-manager.js';
12
+ import {
13
+ createMockNavigationManager,
14
+ createMockLogger,
15
+ } from '../../helpers/mocks.js';
16
+
17
+ describe('page-trigger-manager', () => {
18
+ describe('ActionStoppedError', () => {
19
+ it('should create error with default message', () => {
20
+ const error = new ActionStoppedError();
21
+ assert.strictEqual(error.name, 'ActionStoppedError');
22
+ assert.strictEqual(error.message, 'Action stopped due to navigation');
23
+ assert.strictEqual(error.isActionStopped, true);
24
+ });
25
+
26
+ it('should create error with custom message', () => {
27
+ const error = new ActionStoppedError('Custom message');
28
+ assert.strictEqual(error.message, 'Custom message');
29
+ assert.strictEqual(error.isActionStopped, true);
30
+ });
31
+
32
+ it('should be instance of Error', () => {
33
+ const error = new ActionStoppedError();
34
+ assert.ok(error instanceof Error);
35
+ });
36
+ });
37
+
38
+ describe('isActionStoppedError', () => {
39
+ it('should return true for ActionStoppedError', () => {
40
+ const error = new ActionStoppedError();
41
+ assert.strictEqual(isActionStoppedError(error), true);
42
+ });
43
+
44
+ it('should return true for error with isActionStopped flag', () => {
45
+ const error = new Error('Some error');
46
+ error.isActionStopped = true;
47
+ assert.strictEqual(isActionStoppedError(error), true);
48
+ });
49
+
50
+ it('should return true for error with ActionStoppedError name', () => {
51
+ const error = new Error('Some error');
52
+ error.name = 'ActionStoppedError';
53
+ assert.strictEqual(isActionStoppedError(error), true);
54
+ });
55
+
56
+ it('should return false for regular error', () => {
57
+ const error = new Error('Regular error');
58
+ assert.strictEqual(isActionStoppedError(error), false);
59
+ });
60
+
61
+ it('should return falsy for null', () => {
62
+ assert.ok(!isActionStoppedError(null));
63
+ });
64
+
65
+ it('should return falsy for undefined', () => {
66
+ assert.ok(!isActionStoppedError(undefined));
67
+ });
68
+ });
69
+
70
+ describe('makeUrlCondition', () => {
71
+ describe('function patterns', () => {
72
+ it('should wrap function pattern', () => {
73
+ const condition = makeUrlCondition((url) => url.includes('test'));
74
+ assert.strictEqual(condition({ url: 'https://test.com' }), true);
75
+ assert.strictEqual(condition({ url: 'https://example.com' }), false);
76
+ });
77
+
78
+ it('should pass context to function', () => {
79
+ const condition = makeUrlCondition(
80
+ (url, ctx) => ctx.someValue === true
81
+ );
82
+ assert.strictEqual(
83
+ condition({ url: 'https://test.com', someValue: true }),
84
+ true
85
+ );
86
+ assert.strictEqual(
87
+ condition({ url: 'https://test.com', someValue: false }),
88
+ false
89
+ );
90
+ });
91
+ });
92
+
93
+ describe('RegExp patterns', () => {
94
+ it('should match RegExp pattern', () => {
95
+ const condition = makeUrlCondition(/\/product\/\d+/);
96
+ assert.strictEqual(
97
+ condition({ url: 'https://example.com/product/123' }),
98
+ true
99
+ );
100
+ assert.strictEqual(
101
+ condition({ url: 'https://example.com/product/' }),
102
+ false
103
+ );
104
+ });
105
+ });
106
+
107
+ describe('string patterns', () => {
108
+ it('should match exact URL', () => {
109
+ const condition = makeUrlCondition('https://example.com/page');
110
+ assert.strictEqual(
111
+ condition({ url: 'https://example.com/page' }),
112
+ true
113
+ );
114
+ assert.strictEqual(
115
+ condition({ url: 'https://example.com/page?foo=bar' }),
116
+ true
117
+ );
118
+ assert.strictEqual(
119
+ condition({ url: 'https://example.com/other' }),
120
+ false
121
+ );
122
+ });
123
+
124
+ it('should match *substring* pattern (contains)', () => {
125
+ const condition = makeUrlCondition('*checkout*');
126
+ assert.strictEqual(
127
+ condition({ url: 'https://example.com/checkout/step1' }),
128
+ true
129
+ );
130
+ assert.strictEqual(
131
+ condition({ url: 'https://checkout.example.com' }),
132
+ true
133
+ );
134
+ assert.strictEqual(
135
+ condition({ url: 'https://example.com/cart' }),
136
+ false
137
+ );
138
+ });
139
+
140
+ it('should match *suffix pattern (ends with)', () => {
141
+ const condition = makeUrlCondition('*.json');
142
+ assert.strictEqual(
143
+ condition({ url: 'https://api.example.com/data.json' }),
144
+ true
145
+ );
146
+ assert.strictEqual(
147
+ condition({ url: 'https://api.example.com/data.xml' }),
148
+ false
149
+ );
150
+ });
151
+
152
+ it('should match prefix* pattern (starts with)', () => {
153
+ const condition = makeUrlCondition('/api/*');
154
+ assert.strictEqual(condition({ url: '/api/users' }), true);
155
+ assert.strictEqual(condition({ url: '/api/products' }), true);
156
+ assert.strictEqual(condition({ url: '/web/page' }), false);
157
+ });
158
+
159
+ it('should match express-style :param patterns', () => {
160
+ const condition = makeUrlCondition('/vacancy/:id');
161
+ assert.strictEqual(condition({ url: '/vacancy/123' }), true);
162
+ assert.strictEqual(condition({ url: '/vacancy/abc' }), true);
163
+ assert.strictEqual(condition({ url: '/vacancy/' }), false);
164
+ });
165
+
166
+ it('should match express-style patterns with multiple params', () => {
167
+ const condition = makeUrlCondition('/user/:userId/profile');
168
+ assert.strictEqual(condition({ url: '/user/123/profile' }), true);
169
+ assert.strictEqual(condition({ url: '/user/abc/profile' }), true); // Params match any non-slash chars
170
+ assert.strictEqual(condition({ url: '/user//profile' }), false); // Empty param doesn't match
171
+ });
172
+
173
+ it('should match URL containing path (no wildcards, no params)', () => {
174
+ const condition = makeUrlCondition('/admin');
175
+ assert.strictEqual(
176
+ condition({ url: 'https://example.com/admin/dashboard' }),
177
+ true
178
+ );
179
+ assert.strictEqual(
180
+ condition({ url: 'https://example.com/user' }),
181
+ false
182
+ );
183
+ });
184
+ });
185
+
186
+ describe('invalid patterns', () => {
187
+ it('should throw for invalid pattern type', () => {
188
+ assert.throws(() => makeUrlCondition(123), /Invalid URL pattern type/);
189
+ });
190
+
191
+ it('should throw for null pattern', () => {
192
+ assert.throws(() => makeUrlCondition(null), /Invalid URL pattern type/);
193
+ });
194
+ });
195
+ });
196
+
197
+ describe('condition combinators', () => {
198
+ describe('allConditions', () => {
199
+ it('should return true when all conditions match', () => {
200
+ const condition = allConditions(
201
+ makeUrlCondition('*example.com*'),
202
+ makeUrlCondition('*checkout*')
203
+ );
204
+ assert.strictEqual(
205
+ condition({ url: 'https://example.com/checkout' }),
206
+ true
207
+ );
208
+ });
209
+
210
+ it('should return false when any condition fails', () => {
211
+ const condition = allConditions(
212
+ makeUrlCondition('*example.com*'),
213
+ makeUrlCondition('*checkout*')
214
+ );
215
+ assert.strictEqual(
216
+ condition({ url: 'https://example.com/cart' }),
217
+ false
218
+ );
219
+ });
220
+ });
221
+
222
+ describe('anyCondition', () => {
223
+ it('should return true when any condition matches', () => {
224
+ const condition = anyCondition(
225
+ makeUrlCondition('*cart*'),
226
+ makeUrlCondition('*checkout*')
227
+ );
228
+ assert.strictEqual(
229
+ condition({ url: 'https://example.com/cart' }),
230
+ true
231
+ );
232
+ assert.strictEqual(
233
+ condition({ url: 'https://example.com/checkout' }),
234
+ true
235
+ );
236
+ });
237
+
238
+ it('should return false when no conditions match', () => {
239
+ const condition = anyCondition(
240
+ makeUrlCondition('*cart*'),
241
+ makeUrlCondition('*checkout*')
242
+ );
243
+ assert.strictEqual(
244
+ condition({ url: 'https://example.com/home' }),
245
+ false
246
+ );
247
+ });
248
+ });
249
+
250
+ describe('notCondition', () => {
251
+ it('should negate condition', () => {
252
+ const condition = notCondition(makeUrlCondition('*admin*'));
253
+ assert.strictEqual(
254
+ condition({ url: 'https://example.com/user' }),
255
+ true
256
+ );
257
+ assert.strictEqual(
258
+ condition({ url: 'https://example.com/admin' }),
259
+ false
260
+ );
261
+ });
262
+ });
263
+ });
264
+
265
+ describe('createPageTriggerManager', () => {
266
+ let navigationManager;
267
+ let log;
268
+
269
+ beforeEach(() => {
270
+ navigationManager = createMockNavigationManager();
271
+ log = createMockLogger();
272
+ });
273
+
274
+ it('should throw error when navigationManager is not provided', () => {
275
+ assert.throws(
276
+ () => createPageTriggerManager({ log }),
277
+ /navigationManager is required/
278
+ );
279
+ });
280
+
281
+ it('should create page trigger manager', () => {
282
+ const manager = createPageTriggerManager({ navigationManager, log });
283
+ assert.ok(manager);
284
+ assert.ok(typeof manager.pageTrigger === 'function');
285
+ assert.ok(typeof manager.stopCurrentAction === 'function');
286
+ assert.ok(typeof manager.isRunning === 'function');
287
+ assert.ok(typeof manager.destroy === 'function');
288
+ });
289
+
290
+ it('should register a trigger', () => {
291
+ const manager = createPageTriggerManager({ navigationManager, log });
292
+
293
+ const unregister = manager.pageTrigger({
294
+ name: 'test-trigger',
295
+ condition: () => true,
296
+ action: async () => {},
297
+ });
298
+
299
+ assert.ok(typeof unregister === 'function');
300
+ });
301
+
302
+ it('should throw when condition is not a function', () => {
303
+ const manager = createPageTriggerManager({ navigationManager, log });
304
+
305
+ assert.throws(
306
+ () =>
307
+ manager.pageTrigger({
308
+ name: 'test',
309
+ condition: 'not-a-function',
310
+ action: async () => {},
311
+ }),
312
+ /condition must be a function/
313
+ );
314
+ });
315
+
316
+ it('should throw when action is not a function', () => {
317
+ const manager = createPageTriggerManager({ navigationManager, log });
318
+
319
+ assert.throws(
320
+ () =>
321
+ manager.pageTrigger({
322
+ name: 'test',
323
+ condition: () => true,
324
+ action: 'not-a-function',
325
+ }),
326
+ /action must be a function/
327
+ );
328
+ });
329
+
330
+ it('should unregister trigger', () => {
331
+ const manager = createPageTriggerManager({ navigationManager, log });
332
+
333
+ const unregister = manager.pageTrigger({
334
+ name: 'test-trigger',
335
+ condition: () => true,
336
+ action: async () => {},
337
+ });
338
+
339
+ // Should not throw
340
+ unregister();
341
+ });
342
+
343
+ it('should report not running initially', () => {
344
+ const manager = createPageTriggerManager({ navigationManager, log });
345
+ assert.strictEqual(manager.isRunning(), false);
346
+ });
347
+
348
+ it('should return null for getCurrentTriggerName when no action running', () => {
349
+ const manager = createPageTriggerManager({ navigationManager, log });
350
+ assert.strictEqual(manager.getCurrentTriggerName(), null);
351
+ });
352
+
353
+ it('should destroy without error', async () => {
354
+ const manager = createPageTriggerManager({ navigationManager, log });
355
+ await manager.destroy();
356
+ });
357
+ });
358
+ });