browser-commander 0.17.2 → 0.19.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 (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,220 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ attachDownloads,
8
+ normalizeDownloadOptions,
9
+ } from '../../../src/downloads/attach.js';
10
+ import { connectBrowserWithDependencies } from '../../../src/browser/connector.js';
11
+ import { makeBrowserCommander } from '../../../src/factory.js';
12
+ import {
13
+ createFakeContext,
14
+ makeFakeDownload,
15
+ useTempDownloadDirectory,
16
+ } from '../../helpers/download-fixtures.js';
17
+
18
+ /**
19
+ * A page that belongs to the given context, as Playwright's page does.
20
+ *
21
+ * @param {Object} context - Fake browser context
22
+ * @returns {Object} Minimal page object
23
+ */
24
+ function pageOf(context) {
25
+ return {
26
+ context: () => context,
27
+ // detectEngine() recognizes Playwright by locator() plus context().
28
+ locator: () => ({}),
29
+ on: () => {},
30
+ off: () => {},
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Build a commander over a fake context, with only the download wiring on.
36
+ *
37
+ * @param {Object} context - Fake browser context
38
+ * @param {Object} [downloads] - A manager to expose as commander.downloads
39
+ * @returns {Object} Browser commander
40
+ */
41
+ function commanderOver(context, downloads) {
42
+ return makeBrowserCommander({
43
+ page: pageOf(context),
44
+ enableNetworkTracking: false,
45
+ enableNavigationManager: false,
46
+ enableDialogManager: false,
47
+ downloads,
48
+ });
49
+ }
50
+
51
+ describe('download attachment (issue #88)', () => {
52
+ const directory = useTempDownloadDirectory();
53
+
54
+ describe('normalizeDownloadOptions', () => {
55
+ it('should treat an absent or disabled option as no manager', () => {
56
+ assert.strictEqual(normalizeDownloadOptions(undefined), null);
57
+ assert.strictEqual(normalizeDownloadOptions(false), null);
58
+ assert.strictEqual(normalizeDownloadOptions(null), null);
59
+ });
60
+
61
+ it('should read true as "manage downloads with the defaults"', () => {
62
+ assert.deepStrictEqual(normalizeDownloadOptions(true), {});
63
+ });
64
+
65
+ it('should pass an options object through untouched', () => {
66
+ const options = { directory: '/tmp/x', conflict: 'overwrite' };
67
+ assert.strictEqual(normalizeDownloadOptions(options), options);
68
+ });
69
+
70
+ it('should refuse a value that cannot describe a directory', () => {
71
+ assert.throws(
72
+ () => normalizeDownloadOptions('/tmp/downloads'),
73
+ /downloads must be true, false or an options object/
74
+ );
75
+ });
76
+ });
77
+
78
+ it('should attach nothing when the caller did not ask for downloads', () => {
79
+ assert.strictEqual(
80
+ attachDownloads({ engine: 'playwright', browser: createFakeContext() }),
81
+ null
82
+ );
83
+ });
84
+
85
+ it('should manage downloads from the context it was attached to', async () => {
86
+ const context = createFakeContext();
87
+ const manager = await attachDownloads({
88
+ engine: 'playwright',
89
+ browser: context,
90
+ downloads: { directory: directory.path },
91
+ });
92
+
93
+ await context.emitDownload(
94
+ await makeFakeDownload({
95
+ dir: directory.path,
96
+ suggestedFilename: 'a.txt',
97
+ })
98
+ );
99
+ await manager.idle();
100
+
101
+ assert.deepStrictEqual(
102
+ manager.list().map((artifact) => path.basename(artifact.path)),
103
+ ['a.txt']
104
+ );
105
+ await manager.dispose();
106
+ });
107
+
108
+ it('should give a connected browser the same lifecycle as a launched one', async () => {
109
+ // Issue #88 asks for one manager "identical across launchBrowser(),
110
+ // connectBrowser() and launchRealBrowser()", so the connector must return
111
+ // the same handle rather than leaving attached browsers unmanaged.
112
+ const context = createFakeContext();
113
+ const browser = { contexts: () => [context] };
114
+ context.pages = () => [pageOf(context)];
115
+
116
+ const connection = await connectBrowserWithDependencies(
117
+ {
118
+ engine: 'playwright',
119
+ cdpEndpoint: 'http://127.0.0.1:9222',
120
+ downloads: { directory: directory.path },
121
+ },
122
+ {
123
+ loadPlaywright: async () => ({
124
+ chromium: { connectOverCDP: async () => browser },
125
+ }),
126
+ }
127
+ );
128
+
129
+ assert.ok(connection.downloads, 'connectBrowser should return a manager');
130
+ assert.strictEqual(connection.downloads.directory, directory.path);
131
+
132
+ await context.emitDownload(
133
+ await makeFakeDownload({
134
+ dir: directory.path,
135
+ suggestedFilename: 'attached.txt',
136
+ })
137
+ );
138
+ await connection.downloads.idle();
139
+
140
+ assert.strictEqual(
141
+ await fs.readFile(path.join(directory.path, 'attached.txt'), 'utf8'),
142
+ 'hello'
143
+ );
144
+ await connection.downloads.dispose();
145
+ });
146
+
147
+ it('should leave a connection unmanaged when downloads were not requested', async () => {
148
+ const context = createFakeContext();
149
+ context.pages = () => [pageOf(context)];
150
+
151
+ const connection = await connectBrowserWithDependencies(
152
+ { engine: 'playwright', cdpEndpoint: 'http://127.0.0.1:9222' },
153
+ {
154
+ loadPlaywright: async () => ({
155
+ chromium: {
156
+ connectOverCDP: async () => ({ contexts: () => [context] }),
157
+ },
158
+ }),
159
+ }
160
+ );
161
+
162
+ assert.strictEqual(connection.downloads, null);
163
+ });
164
+
165
+ describe('commander.configureDownloads', () => {
166
+ it('should expose a manager the caller already has', () => {
167
+ const context = createFakeContext();
168
+ const manager = { directory: directory.path };
169
+ const commander = commanderOver(context, manager);
170
+
171
+ assert.strictEqual(commander.downloads, manager);
172
+ });
173
+
174
+ it('should attach a manager to a commander built without one', async () => {
175
+ const context = createFakeContext();
176
+ const commander = commanderOver(context);
177
+ assert.strictEqual(commander.downloads, null);
178
+
179
+ const manager = await commander.configureDownloads({
180
+ directory: directory.path,
181
+ });
182
+
183
+ assert.strictEqual(commander.downloads, manager);
184
+ await context.emitDownload(
185
+ await makeFakeDownload({
186
+ dir: directory.path,
187
+ suggestedFilename: 'configured.txt',
188
+ })
189
+ );
190
+ await manager.idle();
191
+
192
+ assert.strictEqual(
193
+ path.basename(manager.list()[0].path),
194
+ 'configured.txt'
195
+ );
196
+ await manager.dispose();
197
+ });
198
+
199
+ it('should keep downloaded files when the commander is destroyed', async () => {
200
+ // Persistence is the point: destroying the commander stops observation,
201
+ // it does not take the user's file away.
202
+ const context = createFakeContext();
203
+ const commander = commanderOver(context);
204
+ await commander.configureDownloads({ directory: directory.path });
205
+
206
+ await context.emitDownload(
207
+ await makeFakeDownload({
208
+ dir: directory.path,
209
+ suggestedFilename: 'kept.txt',
210
+ })
211
+ );
212
+ await commander.destroy();
213
+
214
+ assert.strictEqual(
215
+ await fs.readFile(path.join(directory.path, 'kept.txt'), 'utf8'),
216
+ 'hello'
217
+ );
218
+ });
219
+ });
220
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import {
8
+ ARTIFACT_DIRECTORY_MODE,
9
+ prepareDownloadDirectory,
10
+ resolveDownloadDirectory,
11
+ } from '../../../src/downloads/destination.js';
12
+ import { useTempDownloadDirectory } from '../../helpers/download-fixtures.js';
13
+ import { assertMode, HAS_POSIX_MODES } from '../../helpers/file-modes.js';
14
+
15
+ describe('download destination (issue #88)', () => {
16
+ const directory = useTempDownloadDirectory();
17
+
18
+ describe('resolveDownloadDirectory', () => {
19
+ it('should resolve the temporary preset under the system temp directory', async () => {
20
+ const resolved = await resolveDownloadDirectory('temporary');
21
+
22
+ assert.strictEqual(
23
+ resolved,
24
+ path.join(os.tmpdir(), 'browser-commander-downloads')
25
+ );
26
+ });
27
+
28
+ it('should resolve the user preset to an absolute path', async () => {
29
+ const resolved = await resolveDownloadDirectory('user-downloads');
30
+
31
+ assert.ok(path.isAbsolute(resolved));
32
+ });
33
+
34
+ it('should default to the user preset', async () => {
35
+ assert.strictEqual(
36
+ await resolveDownloadDirectory(),
37
+ await resolveDownloadDirectory('user-downloads')
38
+ );
39
+ });
40
+
41
+ it('should accept an absolute custom path', async () => {
42
+ assert.strictEqual(
43
+ await resolveDownloadDirectory(directory.path),
44
+ directory.path
45
+ );
46
+ });
47
+
48
+ it('should refuse a relative path rather than guess a base for it', async () => {
49
+ await assert.rejects(
50
+ resolveDownloadDirectory('./downloads'),
51
+ /must be absolute/
52
+ );
53
+ });
54
+
55
+ it('should refuse a setting that is neither a path nor a preset', async () => {
56
+ await assert.rejects(resolveDownloadDirectory(42), TypeError);
57
+ await assert.rejects(resolveDownloadDirectory(''), TypeError);
58
+ });
59
+ });
60
+
61
+ describe('prepareDownloadDirectory', () => {
62
+ it('should create the directory owner-only before any download runs', async () => {
63
+ const nested = path.join(directory.path, 'deep', 'downloads');
64
+
65
+ assert.strictEqual(await prepareDownloadDirectory(nested), nested);
66
+
67
+ const stat = await fs.stat(nested);
68
+ assert.ok(stat.isDirectory());
69
+ await assertMode(nested, ARTIFACT_DIRECTORY_MODE);
70
+ });
71
+
72
+ it('should leave no probe file behind', async () => {
73
+ await prepareDownloadDirectory(directory.path);
74
+
75
+ assert.deepStrictEqual(await fs.readdir(directory.path), []);
76
+ });
77
+
78
+ it(
79
+ 'should report an unwritable directory before the first download',
80
+ {
81
+ // Root may write into any directory, so the mode bits prove nothing
82
+ // there - and on Windows there are no mode bits to take away, since
83
+ // `chmod` only toggles a read-only flag that directories ignore.
84
+ skip:
85
+ (process.getuid?.() === 0 && 'running as root') ||
86
+ (!HAS_POSIX_MODES && 'Windows has no directory mode bits'),
87
+ },
88
+ async () => {
89
+ // Issue #88: the failure has to surface at configuration time, because a
90
+ // permission error discovered after a click looks like a missing file.
91
+ const readOnly = path.join(directory.path, 'read-only');
92
+ await fs.mkdir(readOnly);
93
+ await fs.chmod(readOnly, 0o500);
94
+
95
+ await assert.rejects(
96
+ prepareDownloadDirectory(readOnly),
97
+ /is not writable/
98
+ );
99
+
100
+ await fs.chmod(readOnly, 0o700);
101
+ }
102
+ );
103
+
104
+ it('should report a directory it cannot create', async () => {
105
+ const blocker = path.join(directory.path, 'blocker');
106
+ await fs.writeFile(blocker, 'not a directory');
107
+
108
+ await assert.rejects(
109
+ prepareDownloadDirectory(path.join(blocker, 'downloads')),
110
+ /could not be created/
111
+ );
112
+ });
113
+ });
114
+ });