browser-commander 0.6.0 → 0.7.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.
@@ -0,0 +1,91 @@
1
+ import { createEngineAdapter } from '../core/engine-adapter.js';
2
+
3
+ /**
4
+ * Press a key at the page level (e.g. 'Escape', 'Enter', 'Tab').
5
+ *
6
+ * Supported key names follow the Playwright/Puppeteer convention:
7
+ * https://playwright.dev/docs/api/class-keyboard#keyboard-press
8
+ *
9
+ * @param {Object} options - Configuration options
10
+ * @param {Object} options.page - Browser page object
11
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
12
+ * @param {string} options.key - Key to press (e.g. 'Escape', 'Enter', 'Tab', 'ArrowDown')
13
+ * @param {Object} [options.adapter] - Engine adapter (optional, created if not provided)
14
+ * @returns {Promise<void>}
15
+ */
16
+ export async function pressKey(options = {}) {
17
+ const { page, engine, key, adapter: providedAdapter } = options;
18
+
19
+ if (!key) {
20
+ throw new Error('pressKey: key is required in options');
21
+ }
22
+
23
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
24
+ await adapter.keyboardPress(key);
25
+ }
26
+
27
+ /**
28
+ * Type text at the page level (dispatches key events for each character).
29
+ * Unlike element-level fill/type, this sends keyboard events to whatever
30
+ * element is currently focused on the page.
31
+ *
32
+ * @param {Object} options - Configuration options
33
+ * @param {Object} options.page - Browser page object
34
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
35
+ * @param {string} options.text - Text to type
36
+ * @param {Object} [options.adapter] - Engine adapter (optional, created if not provided)
37
+ * @returns {Promise<void>}
38
+ */
39
+ export async function typeText(options = {}) {
40
+ const { page, engine, text, adapter: providedAdapter } = options;
41
+
42
+ if (!text) {
43
+ throw new Error('typeText: text is required in options');
44
+ }
45
+
46
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
47
+ await adapter.keyboardType(text);
48
+ }
49
+
50
+ /**
51
+ * Hold a key down at the page level.
52
+ * Must be paired with keyUp() to release the key.
53
+ *
54
+ * @param {Object} options - Configuration options
55
+ * @param {Object} options.page - Browser page object
56
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
57
+ * @param {string} options.key - Key to hold down
58
+ * @param {Object} [options.adapter] - Engine adapter (optional, created if not provided)
59
+ * @returns {Promise<void>}
60
+ */
61
+ export async function keyDown(options = {}) {
62
+ const { page, engine, key, adapter: providedAdapter } = options;
63
+
64
+ if (!key) {
65
+ throw new Error('keyDown: key is required in options');
66
+ }
67
+
68
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
69
+ await adapter.keyboardDown(key);
70
+ }
71
+
72
+ /**
73
+ * Release a held key at the page level.
74
+ *
75
+ * @param {Object} options - Configuration options
76
+ * @param {Object} options.page - Browser page object
77
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
78
+ * @param {string} options.key - Key to release
79
+ * @param {Object} [options.adapter] - Engine adapter (optional, created if not provided)
80
+ * @returns {Promise<void>}
81
+ */
82
+ export async function keyUp(options = {}) {
83
+ const { page, engine, key, adapter: providedAdapter } = options;
84
+
85
+ if (!key) {
86
+ throw new Error('keyUp: key is required in options');
87
+ }
88
+
89
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
90
+ await adapter.keyboardUp(key);
91
+ }
@@ -438,5 +438,89 @@ describe(
438
438
  assert.ok(elements.length >= 4);
439
439
  });
440
440
  });
441
+
442
+ describe('Keyboard Interactions', () => {
443
+ it('should press Escape key to close modal', async () => {
444
+ if (!commander) {
445
+ return;
446
+ }
447
+
448
+ // Open modal
449
+ await commander.click({ selector: '[data-testid="btn-open-modal"]' });
450
+ await commander.wait({ ms: 100 });
451
+
452
+ // Verify modal is open
453
+ const modalOpen = await commander.isVisible({
454
+ selector: '[data-testid="modal"]',
455
+ });
456
+ assert.strictEqual(modalOpen, true);
457
+
458
+ // Close modal with Escape key
459
+ await commander.keyboard.press('Escape');
460
+ await commander.wait({ ms: 150 });
461
+
462
+ // Modal should be closed
463
+ const modalClosed = await commander.isVisible({
464
+ selector: '[data-testid="modal"]',
465
+ });
466
+ assert.strictEqual(modalClosed, false);
467
+ });
468
+
469
+ it('should type text at page level via keyboard.type', async () => {
470
+ if (!commander) {
471
+ return;
472
+ }
473
+
474
+ // Focus the name input
475
+ await commander.click({ selector: '[data-testid="input-name"]' });
476
+ await commander.wait({ ms: 50 });
477
+
478
+ // Clear and type via page-level keyboard
479
+ await page.fill('[data-testid="input-name"]', '');
480
+ await commander.keyboard.type('Keyboard Test');
481
+ await commander.wait({ ms: 50 });
482
+
483
+ const value = await commander.inputValue({
484
+ selector: '[data-testid="input-name"]',
485
+ });
486
+ assert.ok(value.includes('Keyboard Test'));
487
+ });
488
+
489
+ it('should support keyboard.press via pressKey flat function', async () => {
490
+ if (!commander) {
491
+ return;
492
+ }
493
+
494
+ // Open modal
495
+ await commander.click({ selector: '[data-testid="btn-open-modal"]' });
496
+ await commander.wait({ ms: 100 });
497
+
498
+ const modalOpen = await commander.isVisible({
499
+ selector: '[data-testid="modal"]',
500
+ });
501
+ assert.strictEqual(modalOpen, true);
502
+
503
+ // Close via flat pressKey
504
+ await commander.pressKey({ key: 'Escape' });
505
+ await commander.wait({ ms: 150 });
506
+
507
+ const modalClosed = await commander.isVisible({
508
+ selector: '[data-testid="modal"]',
509
+ });
510
+ assert.strictEqual(modalClosed, false);
511
+ });
512
+
513
+ it('should submit form via Enter key', async () => {
514
+ if (!commander) {
515
+ return;
516
+ }
517
+
518
+ // Focus submit button and press Enter
519
+ await page.focus('[data-testid="btn-increment"]');
520
+ await commander.keyboard.press('Enter');
521
+ await commander.wait({ ms: 100 });
522
+ // Test passes if no error is thrown
523
+ });
524
+ });
441
525
  }
442
526
  );
@@ -405,4 +405,75 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
405
405
  assert.ok(elapsed >= 90, `Expected at least 90ms, got ${elapsed}`);
406
406
  });
407
407
  });
408
+
409
+ describe('Keyboard Interactions', () => {
410
+ it('should press Escape key to close modal', async () => {
411
+ if (!commander) {
412
+ return;
413
+ }
414
+
415
+ // Open modal
416
+ await commander.click({ selector: '[data-testid="btn-open-modal"]' });
417
+ await commander.wait({ ms: 100 });
418
+
419
+ // Verify modal is open
420
+ const modalOpen = await commander.isVisible({
421
+ selector: '[data-testid="modal"]',
422
+ });
423
+ assert.strictEqual(modalOpen, true);
424
+
425
+ // Close modal with Escape key
426
+ await commander.keyboard.press('Escape');
427
+ await commander.wait({ ms: 150 });
428
+
429
+ // Modal should be closed
430
+ const modalClosed = await commander.isVisible({
431
+ selector: '[data-testid="modal"]',
432
+ });
433
+ assert.strictEqual(modalClosed, false);
434
+ });
435
+
436
+ it('should type text at page level via keyboard.type', async () => {
437
+ if (!commander) {
438
+ return;
439
+ }
440
+
441
+ // Focus the name input
442
+ await commander.click({ selector: '[data-testid="input-name"]' });
443
+ await commander.wait({ ms: 50 });
444
+
445
+ // Type via page-level keyboard
446
+ await commander.keyboard.type('Keyboard Test');
447
+ await commander.wait({ ms: 50 });
448
+
449
+ const value = await commander.inputValue({
450
+ selector: '[data-testid="input-name"]',
451
+ });
452
+ assert.ok(value.includes('Keyboard Test'));
453
+ });
454
+
455
+ it('should support pressKey flat function', async () => {
456
+ if (!commander) {
457
+ return;
458
+ }
459
+
460
+ // Open modal
461
+ await commander.click({ selector: '[data-testid="btn-open-modal"]' });
462
+ await commander.wait({ ms: 100 });
463
+
464
+ const modalOpen = await commander.isVisible({
465
+ selector: '[data-testid="modal"]',
466
+ });
467
+ assert.strictEqual(modalOpen, true);
468
+
469
+ // Close via flat pressKey
470
+ await commander.pressKey({ key: 'Escape' });
471
+ await commander.wait({ ms: 150 });
472
+
473
+ const modalClosed = await commander.isVisible({
474
+ selector: '[data-testid="modal"]',
475
+ });
476
+ assert.strictEqual(modalClosed, false);
477
+ });
478
+ });
408
479
  });
@@ -147,7 +147,10 @@ export function createMockPlaywrightPage(options = {}) {
147
147
  click: async (sel, opts = {}) => {},
148
148
  type: async (sel, text, opts = {}) => {},
149
149
  keyboard: {
150
+ press: async (key) => {},
150
151
  type: async (text) => {},
152
+ down: async (key) => {},
153
+ up: async (key) => {},
151
154
  },
152
155
  };
153
156
  }
@@ -304,7 +307,10 @@ export function createMockPuppeteerPage(options = {}) {
304
307
  click: async (sel, opts = {}) => {},
305
308
  type: async (sel, text, opts = {}) => {},
306
309
  keyboard: {
310
+ press: async (key) => {},
307
311
  type: async (text) => {},
312
+ down: async (key) => {},
313
+ up: async (key) => {},
308
314
  },
309
315
  };
310
316
 
@@ -139,6 +139,46 @@ describe('bindings', () => {
139
139
  typeof bindings.evaluate === 'function',
140
140
  'evaluate should be a function'
141
141
  );
142
+
143
+ // Keyboard - flat functions
144
+ assert.ok(
145
+ typeof bindings.pressKey === 'function',
146
+ 'pressKey should be a function'
147
+ );
148
+ assert.ok(
149
+ typeof bindings.typeText === 'function',
150
+ 'typeText should be a function'
151
+ );
152
+ assert.ok(
153
+ typeof bindings.keyDown === 'function',
154
+ 'keyDown should be a function'
155
+ );
156
+ assert.ok(
157
+ typeof bindings.keyUp === 'function',
158
+ 'keyUp should be a function'
159
+ );
160
+
161
+ // Keyboard - object API
162
+ assert.ok(
163
+ bindings.keyboard && typeof bindings.keyboard === 'object',
164
+ 'keyboard should be an object'
165
+ );
166
+ assert.ok(
167
+ typeof bindings.keyboard.press === 'function',
168
+ 'keyboard.press should be a function'
169
+ );
170
+ assert.ok(
171
+ typeof bindings.keyboard.type === 'function',
172
+ 'keyboard.type should be a function'
173
+ );
174
+ assert.ok(
175
+ typeof bindings.keyboard.down === 'function',
176
+ 'keyboard.down should be a function'
177
+ );
178
+ assert.ok(
179
+ typeof bindings.keyboard.up === 'function',
180
+ 'keyboard.up should be a function'
181
+ );
142
182
  });
143
183
 
144
184
  it('should pre-bind page and engine to functions', async () => {
@@ -200,6 +240,38 @@ describe('bindings', () => {
200
240
  assert.ok(bindings);
201
241
  });
202
242
 
243
+ it('should invoke keyboard.press via bound keyboard object', async () => {
244
+ const pressedKeys = [];
245
+ const page = createMockPlaywrightPage();
246
+ page.keyboard.press = async (key) => pressedKeys.push(key);
247
+ const log = createMockLogger();
248
+
249
+ const bindings = createBoundFunctions({
250
+ page,
251
+ engine: 'playwright',
252
+ log,
253
+ });
254
+
255
+ await bindings.keyboard.press('Escape');
256
+ assert.deepStrictEqual(pressedKeys, ['Escape']);
257
+ });
258
+
259
+ it('should invoke keyboard.type via bound keyboard object', async () => {
260
+ const typedTexts = [];
261
+ const page = createMockPlaywrightPage();
262
+ page.keyboard.type = async (text) => typedTexts.push(text);
263
+ const log = createMockLogger();
264
+
265
+ const bindings = createBoundFunctions({
266
+ page,
267
+ engine: 'playwright',
268
+ log,
269
+ });
270
+
271
+ await bindings.keyboard.type('hello world');
272
+ assert.deepStrictEqual(typedTexts, ['hello world']);
273
+ });
274
+
203
275
  it('should integrate with networkTracker when provided', () => {
204
276
  const page = createMockPlaywrightPage();
205
277
  const log = createMockLogger();
@@ -0,0 +1,176 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { emulateMedia } from '../../../src/browser/media.js';
4
+
5
+ describe('emulateMedia', () => {
6
+ describe('input validation', () => {
7
+ it('should throw if page is not provided', async () => {
8
+ await assert.rejects(
9
+ () => emulateMedia({ engine: 'playwright', colorScheme: 'dark' }),
10
+ /page is required/
11
+ );
12
+ });
13
+
14
+ it('should throw if engine is not provided', async () => {
15
+ const mockPage = {};
16
+ await assert.rejects(
17
+ () => emulateMedia({ page: mockPage, colorScheme: 'dark' }),
18
+ /engine is required/
19
+ );
20
+ });
21
+
22
+ it('should throw for invalid colorScheme', async () => {
23
+ const mockPage = {};
24
+ await assert.rejects(
25
+ () =>
26
+ emulateMedia({
27
+ page: mockPage,
28
+ engine: 'playwright',
29
+ colorScheme: 'invalid',
30
+ }),
31
+ /Invalid colorScheme/
32
+ );
33
+ });
34
+
35
+ it('should throw for unsupported engine', async () => {
36
+ const mockPage = {};
37
+ await assert.rejects(
38
+ () =>
39
+ emulateMedia({
40
+ page: mockPage,
41
+ engine: 'selenium',
42
+ colorScheme: 'dark',
43
+ }),
44
+ /Unsupported engine/
45
+ );
46
+ });
47
+ });
48
+
49
+ describe('Playwright engine', () => {
50
+ let capturedMediaOptions;
51
+ let mockPage;
52
+
53
+ beforeEach(() => {
54
+ capturedMediaOptions = null;
55
+ mockPage = {
56
+ emulateMedia: async (options) => {
57
+ capturedMediaOptions = options;
58
+ },
59
+ };
60
+ });
61
+
62
+ it('should call page.emulateMedia with colorScheme: dark', async () => {
63
+ await emulateMedia({
64
+ page: mockPage,
65
+ engine: 'playwright',
66
+ colorScheme: 'dark',
67
+ });
68
+ assert.deepStrictEqual(capturedMediaOptions, { colorScheme: 'dark' });
69
+ });
70
+
71
+ it('should call page.emulateMedia with colorScheme: light', async () => {
72
+ await emulateMedia({
73
+ page: mockPage,
74
+ engine: 'playwright',
75
+ colorScheme: 'light',
76
+ });
77
+ assert.deepStrictEqual(capturedMediaOptions, { colorScheme: 'light' });
78
+ });
79
+
80
+ it('should call page.emulateMedia with colorScheme: no-preference', async () => {
81
+ await emulateMedia({
82
+ page: mockPage,
83
+ engine: 'playwright',
84
+ colorScheme: 'no-preference',
85
+ });
86
+ assert.deepStrictEqual(capturedMediaOptions, {
87
+ colorScheme: 'no-preference',
88
+ });
89
+ });
90
+
91
+ it('should call page.emulateMedia with colorScheme: null to reset', async () => {
92
+ await emulateMedia({
93
+ page: mockPage,
94
+ engine: 'playwright',
95
+ colorScheme: null,
96
+ });
97
+ assert.deepStrictEqual(capturedMediaOptions, { colorScheme: null });
98
+ });
99
+
100
+ it('should call page.emulateMedia with empty options when no colorScheme given', async () => {
101
+ await emulateMedia({
102
+ page: mockPage,
103
+ engine: 'playwright',
104
+ });
105
+ assert.deepStrictEqual(capturedMediaOptions, {});
106
+ });
107
+ });
108
+
109
+ describe('Puppeteer engine', () => {
110
+ let capturedFeatures;
111
+ let mockPage;
112
+
113
+ beforeEach(() => {
114
+ capturedFeatures = null;
115
+ mockPage = {
116
+ emulateMediaFeatures: async (features) => {
117
+ capturedFeatures = features;
118
+ },
119
+ };
120
+ });
121
+
122
+ it('should call page.emulateMediaFeatures with dark colorScheme', async () => {
123
+ await emulateMedia({
124
+ page: mockPage,
125
+ engine: 'puppeteer',
126
+ colorScheme: 'dark',
127
+ });
128
+ assert.deepStrictEqual(capturedFeatures, [
129
+ { name: 'prefers-color-scheme', value: 'dark' },
130
+ ]);
131
+ });
132
+
133
+ it('should call page.emulateMediaFeatures with light colorScheme', async () => {
134
+ await emulateMedia({
135
+ page: mockPage,
136
+ engine: 'puppeteer',
137
+ colorScheme: 'light',
138
+ });
139
+ assert.deepStrictEqual(capturedFeatures, [
140
+ { name: 'prefers-color-scheme', value: 'light' },
141
+ ]);
142
+ });
143
+
144
+ it('should call page.emulateMediaFeatures with no-preference colorScheme', async () => {
145
+ await emulateMedia({
146
+ page: mockPage,
147
+ engine: 'puppeteer',
148
+ colorScheme: 'no-preference',
149
+ });
150
+ assert.deepStrictEqual(capturedFeatures, [
151
+ { name: 'prefers-color-scheme', value: 'no-preference' },
152
+ ]);
153
+ });
154
+
155
+ it('should reset by passing empty string when colorScheme is null', async () => {
156
+ await emulateMedia({
157
+ page: mockPage,
158
+ engine: 'puppeteer',
159
+ colorScheme: null,
160
+ });
161
+ assert.deepStrictEqual(capturedFeatures, [
162
+ { name: 'prefers-color-scheme', value: '' },
163
+ ]);
164
+ });
165
+
166
+ it('should reset by passing empty string when colorScheme is undefined', async () => {
167
+ await emulateMedia({
168
+ page: mockPage,
169
+ engine: 'puppeteer',
170
+ });
171
+ assert.deepStrictEqual(capturedFeatures, [
172
+ { name: 'prefers-color-scheme', value: '' },
173
+ ]);
174
+ });
175
+ });
176
+ });