browser-commander 0.29.0 → 0.30.1

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,181 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import vm from 'node:vm';
4
+ import { installUserActionRecorderInPage } from '../../../src/traces/user-actions.js';
5
+ import { observeCommanderActions } from '../../../src/traces/commander-actions.js';
6
+ import { interactionTarget } from '../../../src/traces/observers.js';
7
+
8
+ function documentFixture(options = {}) {
9
+ const listeners = new Map();
10
+ const window = {
11
+ CSS: { escape: (id) => id },
12
+ getComputedStyle: () => ({ visibility: 'visible' }),
13
+ };
14
+ window.top = window;
15
+ const root = { getElementById: () => null };
16
+ const target = {
17
+ nodeType: 1,
18
+ localName: options.tag ?? 'button',
19
+ id: 'delete',
20
+ type: 'text',
21
+ labels: [],
22
+ value: 'PRIVATE_TYPED_TEXT',
23
+ childNodes: [],
24
+ getRootNode: () => root,
25
+ getClientRects: () => [{}],
26
+ getAttribute: (name) => (name === 'aria-label' ? 'Delete position' : null),
27
+ closest: (selector) =>
28
+ selector === '[data-private]'
29
+ ? options.secret
30
+ ? target
31
+ : null
32
+ : selector === '#other'
33
+ ? null
34
+ : selector === target.localName || selector.includes('button')
35
+ ? target
36
+ : null,
37
+ querySelector: () => null,
38
+ };
39
+ target.childNodes.push({
40
+ nodeType: 3,
41
+ nodeValue: options.text ?? 'Delete',
42
+ parentElement: target,
43
+ getRootNode: () => root,
44
+ });
45
+ const context = vm.createContext({
46
+ window,
47
+ location: { href: 'https://example.test/' },
48
+ document: {
49
+ activeElement: target,
50
+ addEventListener: (name, listener) => listeners.set(name, listener),
51
+ removeEventListener: (name) => listeners.delete(name),
52
+ },
53
+ });
54
+ vm.runInContext(
55
+ `(${installUserActionRecorderInPage.toString()})(${JSON.stringify({
56
+ globalName: 'recorder',
57
+ binding: null,
58
+ maxQueued: 2,
59
+ redactSelectors: ['[data-private]'],
60
+ ignoreSelectors: [],
61
+ redacted: '[redacted]',
62
+ })})`,
63
+ context
64
+ );
65
+ const fire = (type, extras = {}) =>
66
+ listeners.get(type)?.({
67
+ type,
68
+ isTrusted: true,
69
+ target,
70
+ composedPath: () => [extras.target ?? target],
71
+ ...extras,
72
+ });
73
+ const evaluate = (fn, argument, element = null) => {
74
+ context.argument = argument;
75
+ context.element = element;
76
+ return vm.runInContext(
77
+ `(${fn.toString()})(...${element ? '[element, argument]' : '[argument]'})`,
78
+ context
79
+ );
80
+ };
81
+ return { window, target, listeners, fire, evaluate, state: window.recorder };
82
+ }
83
+
84
+ function observeActions(commander, evaluate, name) {
85
+ const recorded = [];
86
+ const detach = observeCommanderActions({
87
+ commander,
88
+ page: { evaluate },
89
+ names: [name],
90
+ targetOf: interactionTarget,
91
+ now: () => 0,
92
+ record: async (kind, event) => recorded.push({ kind, ...event }),
93
+ });
94
+ return { recorded, detach };
95
+ }
96
+
97
+ it('records only trusted actions and non-text keys, and stops listeners', () => {
98
+ const { fire, state, listeners } = documentFixture();
99
+ fire('click', { isTrusted: false });
100
+ fire('keydown', { key: 'a' });
101
+ fire('keydown', { key: 'Enter', isComposing: true });
102
+ assert.equal(state.queue.length, 0);
103
+ fire('click');
104
+ fire('keydown', { key: 'Escape' });
105
+ assert.equal(state.queue.length, 2);
106
+ assert.equal(state.queue[0].source, 'user');
107
+ assert.equal(state.queue[0].target.name, 'Delete position');
108
+ assert.equal(state.queue[1].key, 'Escape');
109
+ assert.doesNotMatch(JSON.stringify(state.queue), /PRIVATE_TYPED_TEXT/);
110
+ state.stop();
111
+ assert.equal(listeners.size, 0);
112
+ });
113
+
114
+ it('bounds the action queue and redacts private targets before delivery', () => {
115
+ const { fire, state } = documentFixture({
116
+ secret: true,
117
+ text: 'PRIVATE_TARGET',
118
+ });
119
+ for (let i = 0; i < 5; i += 1) {
120
+ fire('click');
121
+ }
122
+ assert.equal(state.queue.length, 2);
123
+ assert.equal(state.dropped, 3);
124
+ assert.doesNotMatch(JSON.stringify(state.queue), /PRIVATE_TARGET/);
125
+ assert.equal(state.queue[0].target.name, '[redacted]');
126
+ });
127
+
128
+ it('never describes typed textarea text and keeps unrelated manual actions during automation', () => {
129
+ const { fire, state, window } = documentFixture({
130
+ tag: 'textarea',
131
+ text: 'PRIVATE_TEXTAREA',
132
+ });
133
+ window.__browserCommanderActions__ = [
134
+ { selector: '#other', types: ['click'] },
135
+ ];
136
+ fire('click');
137
+ assert.equal(state.queue.length, 1);
138
+ assert.equal(state.queue[0].target.text, '');
139
+ assert.doesNotMatch(JSON.stringify(state.queue), /PRIVATE_TEXTAREA/);
140
+ });
141
+
142
+ it('attributes element-handle input to the commander and removes its marker', async () => {
143
+ const { fire, state, evaluate, target, window } = documentFixture();
144
+ const element = {
145
+ evaluate: (fn, argument) => evaluate(fn, argument, target),
146
+ };
147
+ const commander = { clickElement: async () => fire('click') };
148
+ const { recorded, detach } = observeActions(
149
+ commander,
150
+ evaluate,
151
+ 'clickElement'
152
+ );
153
+ try {
154
+ await commander.clickElement({ locatorOrElement: element });
155
+ assert.equal(state.queue.length, 0);
156
+ assert.equal(recorded[0].source, 'commander');
157
+ assert.equal(window.__browserCommanderActions__.length, 0);
158
+ } finally {
159
+ detach();
160
+ }
161
+ });
162
+
163
+ it('attributes keyboard activation clicks to the commander without suppressing other controls', async () => {
164
+ const { fire, state, evaluate, target } = documentFixture();
165
+ target.contains = (node) => node === target;
166
+ const commander = {
167
+ pressKey: async () => {
168
+ fire('keydown', { key: 'Enter' });
169
+ fire('click', { detail: 0 });
170
+ fire('click', { target: { ...target, closest: () => null } });
171
+ },
172
+ };
173
+ const { recorded, detach } = observeActions(commander, evaluate, 'pressKey');
174
+ try {
175
+ await commander.pressKey({ key: 'Enter' });
176
+ assert.equal(state.queue.length, 1);
177
+ assert.equal(recorded[0].source, 'commander');
178
+ } finally {
179
+ detach();
180
+ }
181
+ });
@@ -45,6 +45,9 @@ describe('trace recorder (issue #87)', () => {
45
45
  commander,
46
46
  page,
47
47
  output: path.join(directory.path, 'run'),
48
+ mode: TRACE_MODE.CHECKPOINTS,
49
+ network: false,
50
+ links: false,
48
51
  ...traceOptions,
49
52
  });
50
53
 
@@ -332,7 +335,7 @@ describe('trace recorder (issue #87)', () => {
332
335
  // before a freshly navigated document's own scripts do.
333
336
  assert.deepStrictEqual(
334
337
  page.state.initScripts.map((script) => script.name),
335
- ['installMutationRecorderInPage']
338
+ ['installMutationRecorderInPage', 'installUserActionRecorderInPage']
336
339
  );
337
340
  });
338
341
 
@@ -1,14 +1,18 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
+ import * as fs from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
3
6
  import {
4
7
  CommandError,
5
8
  runCommand,
6
9
  startProcess,
7
10
  } from '../../../src/utilities/subprocess.js';
11
+ import { repoPath } from '../../helpers/repo.js';
8
12
 
9
13
  const node = process.execPath;
10
14
 
11
- describe('runCommand', () => {
15
+ describe('runCommand', { timeout: 10000 }, () => {
12
16
  it('passes arguments without a shell and captures output', async () => {
13
17
  const result = await runCommand(node, [
14
18
  '-e',
@@ -46,9 +50,25 @@ describe('runCommand', () => {
46
50
  const result = await runCommand(node, args, { check: false });
47
51
  assert.equal(result.code, 3);
48
52
  });
53
+
54
+ it('reports a missing executable as exit 127 with ENOENT diagnostics', async () => {
55
+ const file = path.join(tmpdir(), 'bc-missing-executable-164', 'missing');
56
+ await assert.rejects(
57
+ () => runCommand(file),
58
+ (error) => {
59
+ assert.ok(error instanceof CommandError);
60
+ assert.equal(error.code, 127);
61
+ assert.match(error.stderr, /ENOENT/);
62
+ return true;
63
+ }
64
+ );
65
+ const result = await runCommand(file, [], { check: false });
66
+ assert.equal(result.code, 127);
67
+ assert.match(result.stderr, /ENOENT/);
68
+ });
49
69
  });
50
70
 
51
- describe('startProcess', () => {
71
+ describe('startProcess', { timeout: 10000 }, () => {
52
72
  it('streams output and reports the exit code', async () => {
53
73
  const child = startProcess(node, [
54
74
  '-e',
@@ -78,3 +98,61 @@ describe('startProcess', () => {
78
98
  assert.notEqual(exitCode, 0);
79
99
  });
80
100
  });
101
+
102
+ describe(
103
+ 'startDetachedProcess',
104
+ {
105
+ timeout: 10000,
106
+ // Both the original 1.4.0 and 2.0.2 runner disable Windows detachment.
107
+ skip:
108
+ process.platform === 'win32' &&
109
+ 'Command Stream only creates detached process groups on Unix',
110
+ },
111
+ () => {
112
+ it('lets the launcher exit while the detached child survives', async (t) => {
113
+ const directory = await fs.mkdtemp(
114
+ path.join(tmpdir(), 'bc-detached-164-')
115
+ );
116
+ const marker = path.join(directory, 'survived');
117
+ let pid;
118
+ const launcher = startProcess(node, [
119
+ repoPath('experiments', 'issue-164', 'detached-launcher.mjs'),
120
+ marker,
121
+ ]);
122
+ t.after(async () => {
123
+ launcher.kill();
124
+ if (pid) {
125
+ try {
126
+ process.kill(pid, 'SIGKILL');
127
+ } catch (error) {
128
+ if (error.code !== 'ESRCH') {
129
+ throw error;
130
+ }
131
+ }
132
+ }
133
+ await fs.rm(directory, { recursive: true, force: true });
134
+ });
135
+ let output = '';
136
+ launcher.stdout.on('data', (chunk) => {
137
+ output += chunk;
138
+ pid = Number(output.trim());
139
+ });
140
+ assert.equal(await launcher.exited, 0, launcher.stderrTail);
141
+ assert.ok(pid > 0);
142
+ await assert.rejects(fs.readFile(marker), { code: 'ENOENT' });
143
+ const deadline = Date.now() + 5000;
144
+ while (Date.now() < deadline) {
145
+ try {
146
+ assert.equal(await fs.readFile(marker, 'utf8'), 'survived');
147
+ return;
148
+ } catch (error) {
149
+ if (error.code !== 'ENOENT') {
150
+ throw error;
151
+ }
152
+ await new Promise((resolve) => setTimeout(resolve, 50));
153
+ }
154
+ }
155
+ assert.fail('detached child did not survive the launcher exit');
156
+ });
157
+ }
158
+ );