browser-commander 0.17.2 → 0.18.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 (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -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 +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,174 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import path from 'node:path';
4
+
5
+ import {
6
+ extensionFromContent,
7
+ isInsideRoot,
8
+ renamedCandidate,
9
+ resolveInsideRoot,
10
+ sanitizeDownloadName,
11
+ withExtension,
12
+ } from '../../../src/downloads/naming.js';
13
+
14
+ describe('download naming (issue #88)', () => {
15
+ describe('sanitizeDownloadName', () => {
16
+ it('should keep an ordinary name unchanged', () => {
17
+ assert.strictEqual(
18
+ sanitizeDownloadName('quarterly report.pdf'),
19
+ 'quarterly report.pdf'
20
+ );
21
+ });
22
+
23
+ it('should refuse to let a page choose a directory', () => {
24
+ // A suggested filename is page-controlled input. `../../etc/passwd` is a
25
+ // request to write outside the download root, not a name.
26
+ assert.strictEqual(sanitizeDownloadName('../../etc/passwd'), 'passwd');
27
+ assert.strictEqual(
28
+ sanitizeDownloadName('C:\\Windows\\system32\\evil.exe'),
29
+ 'evil.exe'
30
+ );
31
+ assert.strictEqual(sanitizeDownloadName('/etc/shadow'), 'shadow');
32
+ });
33
+
34
+ it('should strip control characters a name should never contain', () => {
35
+ assert.strictEqual(
36
+ sanitizeDownloadName('re\u0000port\u001f.pdf'),
37
+ 'report.pdf'
38
+ );
39
+ });
40
+
41
+ it('should fall back when the page suggests nothing usable', () => {
42
+ assert.strictEqual(sanitizeDownloadName(''), 'download');
43
+ assert.strictEqual(sanitizeDownloadName('..'), 'download');
44
+ assert.strictEqual(sanitizeDownloadName(' '), 'download');
45
+ assert.strictEqual(sanitizeDownloadName(undefined), 'download');
46
+ });
47
+
48
+ it('should not slow down on a name padded with whitespace', () => {
49
+ // The padding used to be stripped with `/^\s+|[\s.]+$/`, whose second
50
+ // half retries from every position of a whitespace run that never
51
+ // reaches the end. A page chooses this string, so the cost is an
52
+ // attacker's to set: 40k spaces took 2.8 seconds before the trimming
53
+ // walked in from both ends instead, which takes under a millisecond.
54
+ const hostile = `report${' '.repeat(40_000)}x`;
55
+
56
+ const started = performance.now();
57
+ assert.strictEqual(sanitizeDownloadName(hostile), hostile);
58
+ assert.ok(performance.now() - started < 500);
59
+ });
60
+
61
+ it('should defuse reserved device names', () => {
62
+ // `con.txt` is unopenable on Windows, and a library that produces a file
63
+ // nobody can open has not really downloaded anything.
64
+ assert.strictEqual(sanitizeDownloadName('con.txt'), '_con.txt');
65
+ assert.strictEqual(sanitizeDownloadName('LPT1'), '_LPT1');
66
+ });
67
+ });
68
+
69
+ describe('withExtension', () => {
70
+ it('should leave a name that already has an extension alone', () => {
71
+ assert.strictEqual(
72
+ withExtension({ name: 'report.pdf', mimeType: 'text/csv' }),
73
+ 'report.pdf'
74
+ );
75
+ });
76
+
77
+ it('should name a UUID-like download from its declared type', () => {
78
+ assert.strictEqual(
79
+ withExtension({
80
+ name: '3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50',
81
+ mimeType: 'application/pdf; charset=binary',
82
+ }),
83
+ '3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50.pdf'
84
+ );
85
+ });
86
+
87
+ it('should fall back to the bytes when the server declared nothing', () => {
88
+ assert.strictEqual(
89
+ withExtension({
90
+ name: 'mystery',
91
+ head: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]),
92
+ }),
93
+ 'mystery.pdf'
94
+ );
95
+ });
96
+
97
+ it('should trust the bytes over a generic binary type', () => {
98
+ // `application/octet-stream` is what a server sends when it cannot name
99
+ // the format either, so it must not win over a PDF signature and leave
100
+ // the caller with a `.bin` file it cannot open.
101
+ assert.strictEqual(
102
+ withExtension({
103
+ name: '3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50',
104
+ mimeType: 'application/octet-stream',
105
+ head: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]),
106
+ }),
107
+ '3f2b7c1e-0a41-4f2a-9a55-7b1c2d3e4f50.pdf'
108
+ );
109
+ });
110
+
111
+ it('should still name a truly unidentifiable binary download', () => {
112
+ assert.strictEqual(
113
+ withExtension({
114
+ name: 'payload',
115
+ mimeType: 'application/octet-stream',
116
+ head: Buffer.from([0x01, 0x02]),
117
+ }),
118
+ 'payload.bin'
119
+ );
120
+ });
121
+
122
+ it('should leave the name alone when nothing identifies the format', () => {
123
+ assert.strictEqual(
124
+ withExtension({ name: 'mystery', head: Buffer.from([0x01, 0x02]) }),
125
+ 'mystery'
126
+ );
127
+ });
128
+ });
129
+
130
+ describe('extensionFromContent', () => {
131
+ it('should identify formats by their leading bytes', () => {
132
+ assert.strictEqual(
133
+ extensionFromContent(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
134
+ '.png'
135
+ );
136
+ assert.strictEqual(
137
+ extensionFromContent(Buffer.from([0x50, 0x4b, 0x03, 0x04])),
138
+ '.zip'
139
+ );
140
+ assert.strictEqual(extensionFromContent(Buffer.alloc(0)), '');
141
+ assert.strictEqual(extensionFromContent(undefined), '');
142
+ });
143
+ });
144
+
145
+ describe('root containment', () => {
146
+ const root = path.resolve('/tmp/downloads');
147
+
148
+ it('should accept a file inside the root', () => {
149
+ assert.strictEqual(isInsideRoot(root, `${root}/report.pdf`), true);
150
+ });
151
+
152
+ it('should reject the root itself and anything outside it', () => {
153
+ assert.strictEqual(isInsideRoot(root, root), false);
154
+ assert.strictEqual(isInsideRoot(root, '/tmp/report.pdf'), false);
155
+ assert.strictEqual(isInsideRoot(root, `${root}/../report.pdf`), false);
156
+ });
157
+
158
+ it('should refuse to resolve a path that escapes the root', () => {
159
+ assert.throws(
160
+ () => resolveInsideRoot(root, '../escaped.pdf'),
161
+ /outside the download directory/
162
+ );
163
+ });
164
+ });
165
+
166
+ describe('renamedCandidate', () => {
167
+ it('should number collisions deterministically', () => {
168
+ assert.strictEqual(renamedCandidate('report.pdf', 0), 'report.pdf');
169
+ assert.strictEqual(renamedCandidate('report.pdf', 1), 'report (2).pdf');
170
+ assert.strictEqual(renamedCandidate('report.pdf', 2), 'report (3).pdf');
171
+ assert.strictEqual(renamedCandidate('archive', 1), 'archive (2)');
172
+ });
173
+ });
174
+ });
@@ -0,0 +1,215 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createHash } from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+
8
+ import {
9
+ cleanPartials,
10
+ DOWNLOAD_CONFLICT,
11
+ saveDownload,
12
+ } from '../../../src/downloads/store.js';
13
+ import { useTempDownloadDirectory } from '../../helpers/download-fixtures.js';
14
+ import { assertMode } from '../../helpers/file-modes.js';
15
+
16
+ /**
17
+ * A download source made of literal bytes.
18
+ *
19
+ * @param {string|Buffer} body - File contents
20
+ * @returns {Object} Source accepted by `saveDownload`
21
+ */
22
+ const bytes = (body) => ({ stream: Readable.from([Buffer.from(body)]) });
23
+
24
+ describe('download store (issue #88)', () => {
25
+ const directory = useTempDownloadDirectory();
26
+
27
+ /**
28
+ * List the files currently in the download root.
29
+ *
30
+ * @returns {Promise<string[]>} Sorted entry names
31
+ */
32
+ const listing = async () => (await fs.readdir(directory.path)).sort();
33
+
34
+ /**
35
+ * Save two downloads that suggest the same name.
36
+ *
37
+ * @param {string} [conflict] - Conflict policy for the second one
38
+ * @returns {Promise<Object>} What the second save reported
39
+ */
40
+ const saveTwice = async (conflict) => {
41
+ await saveDownload({
42
+ root: directory.path,
43
+ source: bytes('first'),
44
+ suggestedFilename: 'report.pdf',
45
+ });
46
+ return saveDownload({
47
+ root: directory.path,
48
+ source: bytes('second'),
49
+ suggestedFilename: 'report.pdf',
50
+ conflict,
51
+ });
52
+ };
53
+
54
+ it('should save a download under its suggested name', async () => {
55
+ const saved = await saveDownload({
56
+ root: directory.path,
57
+ source: bytes('report body'),
58
+ suggestedFilename: 'report.pdf',
59
+ });
60
+
61
+ assert.strictEqual(saved.path, path.join(directory.path, 'report.pdf'));
62
+ assert.strictEqual(saved.bytes, 11);
63
+ assert.strictEqual(
64
+ saved.checksum,
65
+ createHash('sha256').update('report body').digest('hex')
66
+ );
67
+ assert.strictEqual(await fs.readFile(saved.path, 'utf8'), 'report body');
68
+ });
69
+
70
+ it('should write the file with owner-only permissions', async () => {
71
+ const saved = await saveDownload({
72
+ root: directory.path,
73
+ source: bytes('secret'),
74
+ suggestedFilename: 'secret.txt',
75
+ });
76
+
77
+ await assertMode(saved.path, 0o600);
78
+ });
79
+
80
+ it('should number a colliding name instead of replacing the file', async () => {
81
+ const second = await saveTwice();
82
+
83
+ assert.strictEqual(path.basename(second.path), 'report (2).pdf');
84
+ assert.deepStrictEqual(await listing(), ['report (2).pdf', 'report.pdf']);
85
+ assert.strictEqual(
86
+ await fs.readFile(path.join(directory.path, 'report.pdf'), 'utf8'),
87
+ 'first'
88
+ );
89
+ });
90
+
91
+ it('should replace the file when the caller asked for overwrite', async () => {
92
+ const second = await saveTwice(DOWNLOAD_CONFLICT.OVERWRITE);
93
+
94
+ assert.deepStrictEqual(await listing(), ['report.pdf']);
95
+ assert.strictEqual(await fs.readFile(second.path, 'utf8'), 'second');
96
+ });
97
+
98
+ it('should refuse a collision when the caller asked for error', async () => {
99
+ await assert.rejects(
100
+ saveTwice(DOWNLOAD_CONFLICT.ERROR),
101
+ /refusing to replace/
102
+ );
103
+ assert.deepStrictEqual(await listing(), ['report.pdf']);
104
+ });
105
+
106
+ it('should keep a traversal attempt inside the download root', async () => {
107
+ // A naming callback expresses a preference, not a grant of write access:
108
+ // the path part is dropped and only the leaf name survives.
109
+ const saved = await saveDownload({
110
+ root: directory.path,
111
+ source: bytes('payload'),
112
+ suggestedFilename: 'safe.txt',
113
+ filename: () => '../../escaped.txt',
114
+ });
115
+
116
+ assert.strictEqual(saved.path, path.join(directory.path, 'escaped.txt'));
117
+ const parent = await fs.readdir(path.dirname(directory.path));
118
+ assert.ok(!parent.includes('escaped.txt'));
119
+ });
120
+
121
+ it('should refuse a page-suggested name that points outside the root', async () => {
122
+ const saved = await saveDownload({
123
+ root: directory.path,
124
+ source: bytes('payload'),
125
+ suggestedFilename: '../../../etc/passwd',
126
+ });
127
+
128
+ assert.strictEqual(saved.path, path.join(directory.path, 'passwd'));
129
+ });
130
+
131
+ // Issue #88: "a half-written or invalid artifact must never appear under
132
+ // the final name". A validator rejects either by throwing or by answering
133
+ // `false`, and neither may leave anything behind.
134
+ const rejections = [
135
+ {
136
+ title: 'validation throws',
137
+ expected: /expected a PDF/,
138
+ validate: ({ bytes: size }) => {
139
+ if (size < 1000) {
140
+ throw new Error('expected a PDF, got a login page');
141
+ }
142
+ },
143
+ },
144
+ {
145
+ title: 'validation answers false',
146
+ expected: /rejected by the caller/,
147
+ validate: ({ mimeType }) => mimeType === 'application/pdf',
148
+ },
149
+ ];
150
+
151
+ for (const { title, expected, validate } of rejections) {
152
+ it(`should leave no file behind when ${title}`, async () => {
153
+ await assert.rejects(
154
+ saveDownload({
155
+ root: directory.path,
156
+ source: bytes('<html>login page</html>'),
157
+ suggestedFilename: 'invoice.pdf',
158
+ mimeType: 'text/html',
159
+ validate,
160
+ }),
161
+ expected
162
+ );
163
+
164
+ assert.deepStrictEqual(await listing(), []);
165
+ });
166
+ }
167
+
168
+ it('should hand validation the real bytes before publishing them', async () => {
169
+ const seen = [];
170
+ await saveDownload({
171
+ root: directory.path,
172
+ source: bytes('%PDF-1.7 body'),
173
+ suggestedFilename: 'invoice',
174
+ mimeType: 'application/pdf',
175
+ validate: async (details) => {
176
+ seen.push({
177
+ ...details,
178
+ head: (await fs.readFile(details.path)).subarray(0, 4).toString(),
179
+ });
180
+ },
181
+ });
182
+
183
+ assert.strictEqual(seen.length, 1);
184
+ assert.strictEqual(seen[0].head, '%PDF');
185
+ assert.strictEqual(seen[0].bytes, 13);
186
+ // Validation sees the partial file, never the published name.
187
+ assert.ok(seen[0].path.endsWith('.partial'));
188
+ assert.deepStrictEqual(await listing(), ['invoice.pdf']);
189
+ });
190
+
191
+ it('should let a caller name the file without losing the safety rules', async () => {
192
+ const saved = await saveDownload({
193
+ root: directory.path,
194
+ source: bytes('payload'),
195
+ suggestedFilename: '8d0f9e2c-4a11-4b22-9f00-1d2e3f405162',
196
+ mimeType: 'application/pdf',
197
+ filename: ({ suggestedFilename }) => `invoice-${suggestedFilename}`,
198
+ });
199
+
200
+ assert.strictEqual(
201
+ path.basename(saved.path),
202
+ 'invoice-8d0f9e2c-4a11-4b22-9f00-1d2e3f405162.pdf'
203
+ );
204
+ });
205
+
206
+ it('should clean up partial files left by an interrupted run', async () => {
207
+ const stale = path.join(directory.path, 'report.pdf.1234.partial');
208
+ await fs.writeFile(stale, 'half');
209
+
210
+ const removed = await cleanPartials(directory.path);
211
+
212
+ assert.deepStrictEqual(removed, [stale]);
213
+ assert.deepStrictEqual(await listing(), []);
214
+ });
215
+ });
@@ -0,0 +1,283 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {
4
+ CLICK_ACTIVATION,
5
+ CLICK_SCROLL,
6
+ CLICK_ACTIONABILITY,
7
+ ScrollConstraintError,
8
+ resolveActivationOptions,
9
+ measureClickPoint,
10
+ readScrollPosition,
11
+ restoreScrollPosition,
12
+ dispatchClick,
13
+ } from '../../../src/interactions/click-activation.js';
14
+ import { createMockLogger } from '../../helpers/mocks.js';
15
+ import {
16
+ IN_VIEWPORT_POINT,
17
+ createScrollModel,
18
+ } from '../../helpers/click-fixtures.js';
19
+
20
+ /**
21
+ * Adapter stub that records what the engine was asked to do and models a
22
+ * scroll position the engine may move.
23
+ *
24
+ * @param {Object} [options] - Stub configuration
25
+ * @param {Object} [options.point] - Geometry returned by the click-point probe
26
+ * @param {number} [options.scrollOnClick] - Scroll position the engine click lands on
27
+ * @param {boolean} [options.hasEvaluateOnPage] - Whether the adapter exposes `evaluateOnPage`
28
+ * @returns {Object} Adapter stub
29
+ */
30
+ function createAdapter(options = {}) {
31
+ const {
32
+ point = IN_VIEWPORT_POINT,
33
+ scrollOnClick = 0,
34
+ hasEvaluateOnPage = true,
35
+ } = options;
36
+
37
+ const calls = { click: [], domClick: 0 };
38
+ const scroll = createScrollModel();
39
+
40
+ const adapter = {
41
+ calls,
42
+ click: async (el, opts) => {
43
+ calls.click.push(opts);
44
+ scroll.set(scrollOnClick);
45
+ },
46
+ evaluateOnElement: async (el, fn) => {
47
+ if (typeof fn === 'function' && fn.length === 1) {
48
+ // The DOM-activation path passes `el => el.click()`.
49
+ const source = fn.toString();
50
+ if (source.includes('.click()')) {
51
+ calls.domClick += 1;
52
+ return undefined;
53
+ }
54
+ }
55
+ return { ...point, scroll: { x: 0, y: scroll.current() } };
56
+ },
57
+ };
58
+
59
+ if (hasEvaluateOnPage) {
60
+ adapter.evaluateOnPage = scroll.evaluateOnPage;
61
+ }
62
+
63
+ adapter.currentScrollY = scroll.current;
64
+ return adapter;
65
+ }
66
+
67
+ /**
68
+ * Page stub that records real pointer clicks.
69
+ *
70
+ * @returns {Object} Page stub
71
+ */
72
+ function createPage() {
73
+ const clicks = [];
74
+ return { clicks, mouse: { click: async (x, y) => clicks.push({ x, y }) } };
75
+ }
76
+
77
+ /**
78
+ * Build the page/adapter pair and a `dispatchClick` invoker for one scenario.
79
+ *
80
+ * @param {Object} [options] - Scenario configuration
81
+ * @param {Object} [options.adapter] - Options forwarded to {@link createAdapter}
82
+ * @param {Object} [options.activation] - Options forwarded to `resolveActivationOptions`
83
+ * @returns {{page: Object, adapter: Object, dispatch: Function}} Scenario handle
84
+ */
85
+ function scenario(options = {}) {
86
+ const page = createPage();
87
+ const adapter = createAdapter(options.adapter);
88
+
89
+ const dispatch = () =>
90
+ dispatchClick({
91
+ page,
92
+ adapter,
93
+ locatorOrElement: {},
94
+ activationOptions: resolveActivationOptions(options.activation),
95
+ });
96
+
97
+ return { page, adapter, dispatch };
98
+ }
99
+
100
+ describe('click activation', () => {
101
+ describe('resolveActivationOptions', () => {
102
+ it('should default to a pointer click that may scroll', () => {
103
+ const resolved = resolveActivationOptions();
104
+ assert.strictEqual(resolved.activation, CLICK_ACTIVATION.POINTER);
105
+ assert.strictEqual(resolved.scroll, CLICK_SCROLL.AUTO);
106
+ assert.strictEqual(resolved.actionability, CLICK_ACTIONABILITY.NORMAL);
107
+ assert.deepStrictEqual(resolved.deprecations, []);
108
+ });
109
+
110
+ it('should map the deprecated noAutoScroll onto scroll semantics', () => {
111
+ // Regression test for issue #89: noAutoScroll used to mean
112
+ // `{ force: true }`, which is an actionability flag, not a scroll flag.
113
+ const on = resolveActivationOptions({ noAutoScroll: true });
114
+ assert.strictEqual(on.scroll, CLICK_SCROLL.NONE);
115
+ assert.strictEqual(on.actionability, CLICK_ACTIONABILITY.NORMAL);
116
+ assert.strictEqual(on.deprecations.length, 1);
117
+ assert.ok(on.deprecations[0].includes('noAutoScroll is deprecated'));
118
+
119
+ const off = resolveActivationOptions({ noAutoScroll: false });
120
+ assert.strictEqual(off.scroll, CLICK_SCROLL.AUTO);
121
+ });
122
+
123
+ it('should let an explicit scroll option win over noAutoScroll', () => {
124
+ const resolved = resolveActivationOptions({
125
+ noAutoScroll: true,
126
+ scroll: CLICK_SCROLL.PRESERVE,
127
+ });
128
+ assert.strictEqual(resolved.scroll, CLICK_SCROLL.PRESERVE);
129
+ });
130
+
131
+ it('should log the deprecation notice when a logger is supplied', () => {
132
+ const log = createMockLogger({ collectLogs: true });
133
+ resolveActivationOptions({ noAutoScroll: true, log });
134
+ const messages = log.getLogs().map((entry) => entry.message);
135
+ assert.ok(messages.some((message) => message.includes('deprecated')));
136
+ });
137
+
138
+ it('should reject unknown values on every axis', () => {
139
+ assert.throws(
140
+ () => resolveActivationOptions({ activation: 'telepathy' }),
141
+ /activation must be one of/u
142
+ );
143
+ assert.throws(
144
+ () => resolveActivationOptions({ scroll: 'maybe' }),
145
+ /scroll must be one of/u
146
+ );
147
+ assert.throws(
148
+ () => resolveActivationOptions({ actionability: 'hard' }),
149
+ /actionability must be one of/u
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('measureClickPoint', () => {
155
+ it('should report the element centre and hit-test result', async () => {
156
+ const adapter = createAdapter();
157
+ const point = await measureClickPoint({
158
+ adapter,
159
+ locatorOrElement: {},
160
+ });
161
+ assert.strictEqual(point.x, 50);
162
+ assert.strictEqual(point.y, 60);
163
+ assert.strictEqual(point.inViewport, true);
164
+ assert.strictEqual(point.hitsTarget, true);
165
+ });
166
+ });
167
+
168
+ describe('scroll position helpers', () => {
169
+ it('should tolerate an adapter without evaluateOnPage', async () => {
170
+ const adapter = createAdapter({ hasEvaluateOnPage: false });
171
+ assert.strictEqual(await readScrollPosition(adapter), null);
172
+ await restoreScrollPosition(adapter, { x: 0, y: 10 });
173
+ });
174
+
175
+ it('should tolerate an adapter that throws', async () => {
176
+ const adapter = {
177
+ evaluateOnPage: async () => {
178
+ throw new Error('context destroyed');
179
+ },
180
+ };
181
+ assert.strictEqual(await readScrollPosition(adapter), null);
182
+ });
183
+
184
+ it('should do nothing when there is no position to restore', async () => {
185
+ const adapter = createAdapter();
186
+ await restoreScrollPosition(adapter, null);
187
+ assert.strictEqual(adapter.currentScrollY(), 0);
188
+ });
189
+ });
190
+
191
+ describe('dispatchClick', () => {
192
+ it('should send force to the engine only for actionability: force', async () => {
193
+ const normal = scenario();
194
+ await normal.dispatch();
195
+ assert.deepStrictEqual(normal.adapter.calls.click, [{}]);
196
+
197
+ const forced = scenario({
198
+ activation: { actionability: CLICK_ACTIONABILITY.FORCE },
199
+ });
200
+ await forced.dispatch();
201
+ assert.deepStrictEqual(forced.adapter.calls.click, [{ force: true }]);
202
+ });
203
+
204
+ it('should restore the scroll position for scroll: preserve', async () => {
205
+ // The engine is free to scroll; the caller asked for the viewport to end
206
+ // up where it started.
207
+ const { adapter, dispatch } = scenario({
208
+ adapter: { scrollOnClick: 3911 },
209
+ activation: { scroll: CLICK_SCROLL.PRESERVE },
210
+ });
211
+
212
+ const detail = await dispatch();
213
+
214
+ assert.strictEqual(adapter.currentScrollY(), 0);
215
+ assert.strictEqual(detail.scrollAfter.y, 0);
216
+ });
217
+
218
+ it('should use a real pointer at the element point for scroll: none', async () => {
219
+ // Regression test for issue #89: `{ force: true }` still scrolled
220
+ // (scrollY 0 -> 3911 in the live repro). A mouse click at the measured
221
+ // point cannot scroll at all.
222
+ const { page, adapter, dispatch } = scenario({
223
+ adapter: { scrollOnClick: 3911 },
224
+ activation: { scroll: CLICK_SCROLL.NONE },
225
+ });
226
+
227
+ const detail = await dispatch();
228
+
229
+ assert.deepStrictEqual(page.clicks, [{ x: 50, y: 60 }]);
230
+ assert.deepStrictEqual(adapter.calls.click, []);
231
+ assert.strictEqual(adapter.currentScrollY(), 0);
232
+ assert.strictEqual(detail.scrollBefore.y, detail.scrollAfter.y);
233
+ });
234
+
235
+ it('should refuse scroll: none when the element is off-screen', async () => {
236
+ const { page, dispatch } = scenario({
237
+ adapter: {
238
+ point: {
239
+ ...IN_VIEWPORT_POINT,
240
+ y: 4000,
241
+ top: 3980,
242
+ inViewport: false,
243
+ hitsTarget: false,
244
+ },
245
+ },
246
+ activation: { scroll: CLICK_SCROLL.NONE },
247
+ });
248
+
249
+ await assert.rejects(dispatch, (error) => {
250
+ assert.ok(error instanceof ScrollConstraintError);
251
+ assert.ok(error.message.includes('outside the viewport'));
252
+ assert.ok(error.message.includes('scroll: "preserve"'));
253
+ assert.strictEqual(error.detail.inViewport, false);
254
+ return true;
255
+ });
256
+ assert.deepStrictEqual(page.clicks, []);
257
+ });
258
+
259
+ it('should refuse scroll: none when another element covers the target', async () => {
260
+ const { page, dispatch } = scenario({
261
+ adapter: { point: { ...IN_VIEWPORT_POINT, hitsTarget: false } },
262
+ activation: { scroll: CLICK_SCROLL.NONE },
263
+ });
264
+
265
+ await assert.rejects(dispatch, /covers the target/u);
266
+ assert.deepStrictEqual(page.clicks, []);
267
+ });
268
+
269
+ it('should dispatch an untrusted DOM click for activation: dom', async () => {
270
+ const { page, adapter, dispatch } = scenario({
271
+ activation: { activation: CLICK_ACTIVATION.DOM },
272
+ });
273
+
274
+ const detail = await dispatch();
275
+
276
+ assert.strictEqual(adapter.calls.domClick, 1);
277
+ assert.deepStrictEqual(adapter.calls.click, []);
278
+ assert.deepStrictEqual(page.clicks, []);
279
+ assert.strictEqual(detail.mode, CLICK_ACTIVATION.DOM);
280
+ assert.strictEqual(detail.point, null);
281
+ });
282
+ });
283
+ });