browser-commander 0.31.1 → 0.32.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 (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +26 -0
  3. package/package.json +1 -1
  4. package/src/browser/browser-handoff.js +304 -0
  5. package/src/browser/cdp-browser-endpoint.js +55 -0
  6. package/src/browser/connection-stage.js +23 -0
  7. package/src/browser/connector.js +29 -15
  8. package/src/browser/launch-diagnostics.js +4 -1
  9. package/src/browser/launcher.js +8 -0
  10. package/src/browser/persistent-session.js +5 -56
  11. package/src/browser/real-browser.js +56 -5
  12. package/src/browser/session-persistence.js +9 -5
  13. package/src/browser/webdriver.js +8 -1
  14. package/src/core/engine-adapter.js +0 -7
  15. package/src/elements/locators.js +1 -10
  16. package/src/elements/selectors.js +10 -155
  17. package/src/elements/text-selector.js +23 -0
  18. package/src/exports.js +6 -0
  19. package/src/utilities/subprocess.js +227 -38
  20. package/tests/e2e/browser-handoff.e2e.test.js +94 -0
  21. package/tests/e2e/selector-identity.e2e.test.js +104 -0
  22. package/tests/e2e/startup-readiness.e2e.test.js +95 -0
  23. package/tests/helpers/fake-cdp-endpoint.js +64 -0
  24. package/tests/helpers/temp-directory.js +25 -0
  25. package/tests/unit/browser/browser-handoff.test.js +406 -0
  26. package/tests/unit/browser/connection-stage.test.js +74 -0
  27. package/tests/unit/browser/launch-diagnostics.test.js +23 -1
  28. package/tests/unit/browser/launcher.test.js +23 -0
  29. package/tests/unit/browser/real-browser.test.js +1 -1
  30. package/tests/unit/browser/startup-readiness.test.js +129 -0
  31. package/tests/unit/elements/selector-identity.test.js +50 -0
  32. package/tests/unit/elements/selectors.test.js +12 -26
  33. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  34. package/tests/unit/utilities/interruption.test.js +182 -0
  35. package/tests/unit/utilities/subprocess.test.js +39 -13
@@ -0,0 +1,129 @@
1
+ import assert from 'node:assert/strict';
2
+ import { PassThrough } from 'node:stream';
3
+ import * as fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { it } from 'node:test';
7
+ import {
8
+ launchAndConnectRealBrowserWithDependencies,
9
+ waitForCdpEndpoint,
10
+ } from '../../../src/browser/real-browser.js';
11
+
12
+ it(
13
+ 'waits for HTTP readiness after DevTools is announced, despite incidental warnings',
14
+ { timeout: 5000 },
15
+ async () => {
16
+ const stderr = new PassThrough();
17
+ stderr.write(
18
+ 'DevTools listening on ws://127.0.0.1:9777/devtools/browser/ours\nCVDisplayLinkCreateWithCGDisplay failed\n'
19
+ );
20
+ let probes = 0;
21
+ const endpoint = await waitForCdpEndpoint({
22
+ remoteDebuggingPort: 9777,
23
+ userDataDir: '/nonexistent',
24
+ browserProcess: { exitCode: null, stderr },
25
+ timeout: 2000,
26
+ fetchImplementation: async () => {
27
+ probes++;
28
+ if (probes === 1) {
29
+ throw new Error('ECONNREFUSED');
30
+ }
31
+ if (probes === 2) {
32
+ return { ok: false };
33
+ }
34
+ return {
35
+ ok: true,
36
+ json: async () => ({
37
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9777/devtools/browser/ours',
38
+ }),
39
+ };
40
+ },
41
+ });
42
+ assert.equal(endpoint, 'http://127.0.0.1:9777');
43
+ assert.equal(probes, 3);
44
+ }
45
+ );
46
+
47
+ for (const suppliedProfile of [false, true]) {
48
+ it(
49
+ `attachment timeout cleans only owned state (supplied profile: ${suppliedProfile})`,
50
+ { timeout: 5000 },
51
+ async () => {
52
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-startup-test-'));
53
+ const marker = path.join(root, 'keep.txt');
54
+ await fs.writeFile(marker, 'caller owned');
55
+ let profile;
56
+ let kills = 0;
57
+ let launches = 0;
58
+ const child = {
59
+ exitCode: null,
60
+ stderrTail:
61
+ 'DevTools listening on ws://127.0.0.1:9777/devtools/browser/ours\nCVDisplayLinkCreateWithCGDisplay failed',
62
+ kill: () => {
63
+ kills++;
64
+ child.exitCode = 143;
65
+ },
66
+ };
67
+ try {
68
+ await assert.rejects(
69
+ () =>
70
+ launchAndConnectRealBrowserWithDependencies(
71
+ {
72
+ ...(suppliedProfile ? { userDataDir: root } : {}),
73
+ debug: false,
74
+ headless: true,
75
+ persistSessionCookies: suppliedProfile,
76
+ timeout: 123,
77
+ closeTimeout: 50,
78
+ },
79
+ {
80
+ resolveExecutable: async () => '/opt/google/chrome',
81
+ reservePort: async () => 9777,
82
+ spawnBrowser: (_file, args) => {
83
+ launches++;
84
+ profile = args
85
+ .find((arg) => arg.startsWith('--user-data-dir='))
86
+ .slice('--user-data-dir='.length);
87
+ return child;
88
+ },
89
+ waitForEndpoint: async () => 'http://127.0.0.1:9777',
90
+ connect: async (options) => {
91
+ assert.equal(options.timeout, 123);
92
+ const error = new Error(
93
+ 'connectOverCDP: Timeout 123ms exceeded'
94
+ );
95
+ error.attachmentStage = 'engine-connect';
96
+ throw error;
97
+ },
98
+ }
99
+ ),
100
+ (error) => {
101
+ assert.equal(error.phase, 'connect');
102
+ assert.equal(error.category, 'startup_timeout');
103
+ assert.equal(error.attachmentStage, 'engine-connect');
104
+ assert.match(
105
+ error.message,
106
+ /connectOverCDP: Timeout 123ms exceeded/
107
+ );
108
+ assert.match(error.message, /CVDisplayLink/);
109
+ return true;
110
+ }
111
+ );
112
+ assert.equal(kills, 1);
113
+ assert.equal(launches, 1);
114
+ assert.equal(await fs.readFile(marker, 'utf8'), 'caller owned');
115
+ if (!suppliedProfile) {
116
+ await assert.rejects(
117
+ () => fs.readFile(path.join(profile, 'First Run')),
118
+ /ENOENT/
119
+ );
120
+ }
121
+ } finally {
122
+ await fs.rm(root, { recursive: true, force: true });
123
+ if (!suppliedProfile && profile) {
124
+ await fs.rm(profile, { recursive: true, force: true });
125
+ }
126
+ }
127
+ }
128
+ );
129
+ }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import {
4
+ normalizeSelector,
5
+ withTextSelectorSupport,
6
+ } from '../../../src/elements/selectors.js';
7
+ import { createPlaywrightLocator } from '../../../src/elements/locators.js';
8
+ import { PlaywrightAdapter } from '../../../src/core/engine-adapter.js';
9
+
10
+ it('keeps native Playwright text selectors without an eager DOM lookup', async () => {
11
+ const selector = '.parent button:has-text("B")';
12
+ const page = {
13
+ evaluate: () => {
14
+ throw new Error('unexpected DOM lookup');
15
+ },
16
+ };
17
+ assert.equal(
18
+ await normalizeSelector({ page, engine: 'playwright', selector }),
19
+ selector
20
+ );
21
+ const wrapped = withTextSelectorSupport(
22
+ async ({ selector }) => selector,
23
+ 'playwright',
24
+ page
25
+ );
26
+ assert.equal(await wrapped({ selector }), selector);
27
+ });
28
+
29
+ for (const [name, create] of [
30
+ [
31
+ 'locator helper',
32
+ (page, selector) => createPlaywrightLocator({ page, selector }),
33
+ ],
34
+ [
35
+ 'shared adapter',
36
+ (page, selector) => new PlaywrightAdapter(page).createLocator(selector),
37
+ ],
38
+ ]) {
39
+ it(`preserves CSS sibling semantics in the Playwright ${name}`, () => {
40
+ const selector = 'section button:nth-of-type(2)';
41
+ const expected = {};
42
+ const page = {
43
+ locator: (received) => {
44
+ assert.equal(received, selector);
45
+ return expected;
46
+ },
47
+ };
48
+ assert.equal(create(page, selector), expected);
49
+ });
50
+ }
@@ -205,32 +205,18 @@ describe('selectors', () => {
205
205
  return page;
206
206
  }
207
207
 
208
- it('splits :has-text into base selector and text', async () => {
209
- const page = capturingPage();
210
- await normalizeSelector({
211
- page,
212
- engine: 'playwright',
213
- selector: 'nav a:has-text("Sign in")',
214
- });
215
- assert.deepStrictEqual(page.parsed, {
216
- baseSelector: 'nav a',
217
- text: 'Sign in',
218
- exact: false,
219
- });
220
- });
221
-
222
- it('splits :text-is into base selector and exact text', async () => {
223
- const page = capturingPage();
224
- await normalizeSelector({
225
- page,
226
- engine: 'playwright',
227
- selector: 'button:text-is("Submit")',
228
- });
229
- assert.deepStrictEqual(page.parsed, {
230
- baseSelector: 'button',
231
- text: 'Submit',
232
- exact: true,
233
- });
208
+ it('preserves native text selectors without an eager lookup', async () => {
209
+ for (const selector of [
210
+ 'nav a:has-text("Sign in")',
211
+ 'button:text-is("Submit")',
212
+ ]) {
213
+ const page = capturingPage();
214
+ assert.equal(
215
+ await normalizeSelector({ page, engine: 'playwright', selector }),
216
+ selector
217
+ );
218
+ assert.equal(page.parsed, null);
219
+ }
234
220
  });
235
221
 
236
222
  it('returns an unparseable selector unchanged', async () => {
@@ -196,6 +196,7 @@ describe('CI execution budgets', () => {
196
196
  assert.deepEqual(budgeted, [
197
197
  'docs.yml build-docs Rust API docs',
198
198
  'js.yml test Node.js test suite',
199
+ 'js.yml test macOS Chrome startup regression',
199
200
  'parity.yml cli CLI and API coverage suites',
200
201
  'parity.yml cli Cross-language CLI contract',
201
202
  'parity.yml cli Rust CLI build',
@@ -0,0 +1,182 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import assert from 'node:assert';
5
+ import { describe, it } from 'node:test';
6
+ import {
7
+ interruptionMode,
8
+ startProcess,
9
+ } from '../../../src/utilities/subprocess.js';
10
+ import { repoPath } from '../../helpers/repo.js';
11
+ import { waitForFileText } from '../../helpers/temp-directory.js';
12
+
13
+ // feature-parity: process.interruption@native-typed
14
+
15
+ const node = process.execPath;
16
+ const delayedShutdown = repoPath(
17
+ 'experiments',
18
+ 'issue-172',
19
+ 'delayed-shutdown.mjs'
20
+ );
21
+ const unixOnly = {
22
+ timeout: 15000,
23
+ skip:
24
+ process.platform === 'win32' &&
25
+ 'Command Stream only creates separate process groups on Unix',
26
+ };
27
+
28
+ function isAlive(pid) {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (error) {
33
+ if (error.code === 'ESRCH') {
34
+ return false;
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function forceKill(pid) {
41
+ if (pid && isAlive(pid)) {
42
+ process.kill(pid, 'SIGKILL');
43
+ }
44
+ }
45
+
46
+ async function tempDirectory(t, prefix) {
47
+ const directory = await fs.mkdtemp(path.join(tmpdir(), prefix));
48
+ t.after(() => fs.rm(directory, { recursive: true, force: true }));
49
+ return directory;
50
+ }
51
+
52
+ /**
53
+ * Run an embedding supervisor that interrupts itself, keeps its child from
54
+ * its own SIGINT handler, and exits. Returns whether the child survived.
55
+ */
56
+ async function childSurvivesSupervisorInterrupt(t, mode) {
57
+ const directory = await tempDirectory(t, 'bc-interrupt-174-');
58
+ const pidFile = path.join(directory, 'pid');
59
+ const supervisor = startProcess(node, [
60
+ repoPath('experiments', 'issue-174', 'sigint-supervisor.mjs'),
61
+ mode,
62
+ pidFile,
63
+ ]);
64
+ assert.equal(await supervisor.exited, 130, supervisor.stderrTail);
65
+ const pid = Number(await fs.readFile(pidFile, 'utf8'));
66
+ t.after(() => forceKill(pid));
67
+ assert.ok(pid > 0);
68
+ await new Promise((resolve) => setTimeout(resolve, 300));
69
+ return isAlive(pid);
70
+ }
71
+
72
+ describe('interruption ownership (issue #174)', unixOnly, () => {
73
+ it('forwards SIGINT to the child by default, before the embedder can keep it', async (t) => {
74
+ assert.equal(await childSurvivesSupervisorInterrupt(t, 'managed'), false);
75
+ assert.equal(
76
+ await childSurvivesSupervisorInterrupt(t, 'detach-in-handler'),
77
+ false
78
+ );
79
+ });
80
+
81
+ it("leaves SIGINT to the embedder's handler with interruption: 'caller'", async (t) => {
82
+ assert.equal(await childSurvivesSupervisorInterrupt(t, 'caller'), true);
83
+ });
84
+
85
+ it('keeps streaming output and reporting the exit code for a caller-owned child', async () => {
86
+ const child = startProcess(
87
+ node,
88
+ [
89
+ '-e',
90
+ 'setTimeout(()=>{process.stderr.write("hello\\n");process.exit(4)},100)',
91
+ ],
92
+ { interruption: 'caller' }
93
+ );
94
+ assert.equal(child.interruption, 'caller');
95
+ let stderr = '';
96
+ child.stderr.on('data', (chunk) => {
97
+ stderr += chunk;
98
+ });
99
+ assert.equal(await child.exited, 4);
100
+ assert.equal(stderr, 'hello\n');
101
+ assert.equal(child.kill(), false);
102
+ });
103
+
104
+ it('kill() still awaits the actual exit of a caller-owned child', async (t) => {
105
+ const directory = await tempDirectory(t, 'bc-caller-kill-174-');
106
+ const marker = path.join(directory, 'flushed');
107
+ const child = startProcess(node, [delayedShutdown, marker], {
108
+ interruption: 'caller',
109
+ killGrace: 1000,
110
+ });
111
+ t.after(() => forceKill(child.pid));
112
+ await new Promise((resolve) => child.stdout.on('data', resolve));
113
+ assert.equal(child.kill(), true);
114
+ assert.equal(await child.exited, 0);
115
+ assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
116
+ assert.equal(isAlive(child.pid), false);
117
+ });
118
+
119
+ it('escalates to SIGKILL when a caller-owned child ignores SIGTERM', async (t) => {
120
+ const child = startProcess(
121
+ node,
122
+ [
123
+ '-e',
124
+ 'process.on("SIGTERM",()=>{});process.stdout.write("ready");setInterval(()=>{},1000)',
125
+ ],
126
+ { interruption: 'caller', killGrace: 200 }
127
+ );
128
+ t.after(() => forceKill(child.pid));
129
+ await new Promise((resolve) => child.stdout.on('data', resolve));
130
+ child.kill();
131
+ assert.equal(await child.exited, 128 + 9);
132
+ assert.equal(child.signalCode, 'SIGKILL');
133
+ });
134
+
135
+ it('detach() lets the supervisor end while the exact child keeps running', async (t) => {
136
+ const directory = await tempDirectory(t, 'bc-detach-174-');
137
+ const marker = path.join(directory, 'survived');
138
+ const launcher = startProcess(node, [
139
+ repoPath('experiments', 'issue-174', 'detach-launcher.mjs'),
140
+ marker,
141
+ ]);
142
+ let output = '';
143
+ launcher.stdout.on('data', (chunk) => {
144
+ output += chunk;
145
+ });
146
+ assert.equal(await launcher.exited, 0, launcher.stderrTail);
147
+ const { pid, detached } = JSON.parse(output);
148
+ t.after(() => forceKill(pid));
149
+ assert.equal(detached, true);
150
+ assert.equal(isAlive(pid), true);
151
+ assert.equal(
152
+ await waitForFileText(marker, 'survived'),
153
+ true,
154
+ 'detached child did not outlive its supervisor'
155
+ );
156
+ });
157
+
158
+ it('detach() is idempotent and a detached child can still be stopped', async () => {
159
+ const child = startProcess(node, ['-e', 'setInterval(()=>{},1000)'], {
160
+ killGrace: 200,
161
+ });
162
+ await new Promise((resolve) => setTimeout(resolve, 100));
163
+ assert.equal(child.detach(), true);
164
+ assert.equal(child.detach(), true);
165
+ assert.equal(child.detached, true);
166
+ assert.equal(child.kill(), true);
167
+ assert.equal(await child.exited, 128 + 15);
168
+ assert.equal(child.detach(), false);
169
+ });
170
+ });
171
+
172
+ describe('interruptionMode', () => {
173
+ it('defaults to managed and rejects unknown modes', () => {
174
+ assert.equal(interruptionMode(), 'managed');
175
+ assert.equal(interruptionMode('caller'), 'caller');
176
+ assert.throws(() => interruptionMode('detached'), TypeError);
177
+ assert.throws(
178
+ () => startProcess(node, [], { interruption: 'none' }),
179
+ /interruption must be one of managed, caller/
180
+ );
181
+ });
182
+ });
@@ -9,6 +9,7 @@ import {
9
9
  startProcess,
10
10
  } from '../../../src/utilities/subprocess.js';
11
11
  import { repoPath } from '../../helpers/repo.js';
12
+ import { waitForFileText } from '../../helpers/temp-directory.js';
12
13
 
13
14
  const node = process.execPath;
14
15
 
@@ -97,6 +98,39 @@ describe('startProcess', { timeout: 10000 }, () => {
97
98
  const exitCode = await child.exited;
98
99
  assert.notEqual(exitCode, 0);
99
100
  });
101
+
102
+ it(
103
+ 'reports exit only after a signalled child finishes its profile writes',
104
+ { skip: process.platform === 'win32' && 'Windows termination is forceful' },
105
+ async (t) => {
106
+ const directory = await fs.mkdtemp(
107
+ path.join(tmpdir(), 'bc-shutdown-172-')
108
+ );
109
+ const marker = path.join(directory, 'flushed');
110
+ const child = startProcess(
111
+ node,
112
+ [repoPath('experiments', 'issue-172', 'delayed-shutdown.mjs'), marker],
113
+ { killGrace: 1000 }
114
+ );
115
+ const processHandle = child.runner.child;
116
+ t.after(async () => {
117
+ if (
118
+ processHandle.exitCode === null &&
119
+ processHandle.signalCode === null
120
+ ) {
121
+ processHandle.kill('SIGKILL');
122
+ await new Promise((resolve) => processHandle.once('exit', resolve));
123
+ }
124
+ await fs.rm(directory, { recursive: true, force: true });
125
+ });
126
+ await new Promise((resolve) => child.stdout.on('data', resolve));
127
+ child.kill();
128
+ await child.exited;
129
+ assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
130
+ assert.equal(processHandle.exitCode, 0);
131
+ assert.equal(child.exitCode, 0);
132
+ }
133
+ );
100
134
  });
101
135
 
102
136
  describe(
@@ -140,19 +174,11 @@ describe(
140
174
  assert.equal(await launcher.exited, 0, launcher.stderrTail);
141
175
  assert.ok(pid > 0);
142
176
  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');
177
+ assert.equal(
178
+ await waitForFileText(marker, 'survived'),
179
+ true,
180
+ 'detached child did not survive the launcher exit'
181
+ );
156
182
  });
157
183
  }
158
184
  );