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.
- package/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +294 -0
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
DEFAULT_STAGING_POLL_INTERVAL,
|
|
8
|
+
DEFAULT_STAGING_TIMEOUT,
|
|
9
|
+
describeStagingTimeout,
|
|
10
|
+
STAGING_IN_PROGRESS_SUFFIXES,
|
|
11
|
+
waitForStagedFile,
|
|
12
|
+
} from '../../../src/downloads/staging.js';
|
|
13
|
+
import { useTempDownloadDirectory } from '../../helpers/download-fixtures.js';
|
|
14
|
+
|
|
15
|
+
describe('waiting for staged bytes (issue #92)', () => {
|
|
16
|
+
const directory = useTempDownloadDirectory();
|
|
17
|
+
|
|
18
|
+
it('should report a file that is already complete', async () => {
|
|
19
|
+
const staged = path.join(directory.path, 'guid-ready');
|
|
20
|
+
await fs.writeFile(staged, 'complete body');
|
|
21
|
+
|
|
22
|
+
const settled = await waitForStagedFile({ path: staged, timeout: 1000 });
|
|
23
|
+
|
|
24
|
+
assert.strictEqual(settled.ready, true);
|
|
25
|
+
assert.strictEqual(settled.bytes, 'complete body'.length);
|
|
26
|
+
assert.strictEqual(settled.reason, null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should wait for a file that appears after the completion event', async () => {
|
|
30
|
+
// This is issue #92 exactly: the browser says "completed" and the rename
|
|
31
|
+
// Chromium does behind it has not become visible yet.
|
|
32
|
+
const staged = path.join(directory.path, 'guid-late');
|
|
33
|
+
const appear = setTimeout(() => {
|
|
34
|
+
fs.writeFile(staged, 'late body').catch(() => {});
|
|
35
|
+
}, 60);
|
|
36
|
+
|
|
37
|
+
const settled = await waitForStagedFile({
|
|
38
|
+
path: staged,
|
|
39
|
+
timeout: 5000,
|
|
40
|
+
interval: 5,
|
|
41
|
+
});
|
|
42
|
+
clearTimeout(appear);
|
|
43
|
+
|
|
44
|
+
assert.strictEqual(settled.ready, true, settled.reason ?? '');
|
|
45
|
+
assert.strictEqual(settled.bytes, 'late body'.length);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should keep waiting while the file is still growing', async () => {
|
|
49
|
+
const staged = path.join(directory.path, 'guid-growing');
|
|
50
|
+
await fs.writeFile(staged, 'a');
|
|
51
|
+
const grow = setInterval(() => {
|
|
52
|
+
fs.appendFile(staged, 'a').catch(() => {});
|
|
53
|
+
}, 5);
|
|
54
|
+
setTimeout(() => clearInterval(grow), 80);
|
|
55
|
+
|
|
56
|
+
const settled = await waitForStagedFile({
|
|
57
|
+
path: staged,
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
interval: 5,
|
|
60
|
+
});
|
|
61
|
+
clearInterval(grow);
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(settled.ready, true, settled.reason ?? '');
|
|
64
|
+
// Two equal readings are what ends the wait, so the file had stopped
|
|
65
|
+
// growing before it was declared ready.
|
|
66
|
+
assert.strictEqual(settled.bytes, (await fs.stat(staged)).size);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should not claim a file whose partial sibling is still being written', async () => {
|
|
70
|
+
const staged = path.join(directory.path, 'guid-partial');
|
|
71
|
+
await fs.writeFile(staged, 'looks done');
|
|
72
|
+
await fs.writeFile(`${staged}.crdownload`, 'still writing');
|
|
73
|
+
|
|
74
|
+
const settled = await waitForStagedFile({
|
|
75
|
+
path: staged,
|
|
76
|
+
timeout: 60,
|
|
77
|
+
interval: 5,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
assert.strictEqual(settled.ready, false);
|
|
81
|
+
assert.match(settled.reason, /\.crdownload file is still being written/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should fail explicitly when the bytes never arrive', async () => {
|
|
85
|
+
const staged = path.join(directory.path, 'guid-missing');
|
|
86
|
+
|
|
87
|
+
const settled = await waitForStagedFile({
|
|
88
|
+
path: staged,
|
|
89
|
+
timeout: 60,
|
|
90
|
+
interval: 5,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.strictEqual(settled.ready, false);
|
|
94
|
+
assert.strictEqual(settled.bytes, null);
|
|
95
|
+
assert.match(settled.reason, /has not appeared/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should name the file, the budget and what it last saw', () => {
|
|
99
|
+
const message = describeStagingTimeout({
|
|
100
|
+
path: '/tmp/root/.browser-commander-staging/guid',
|
|
101
|
+
timeout: 250,
|
|
102
|
+
reason: 'the file has not appeared yet',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.match(message, /reported the download as completed/);
|
|
106
|
+
assert.match(message, /guid/);
|
|
107
|
+
assert.match(message, /250ms/);
|
|
108
|
+
assert.match(message, /has not appeared yet/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should ship defaults a download can actually finish within', () => {
|
|
112
|
+
assert.ok(DEFAULT_STAGING_TIMEOUT >= 1000);
|
|
113
|
+
assert.ok(DEFAULT_STAGING_POLL_INTERVAL > 0);
|
|
114
|
+
assert.ok(DEFAULT_STAGING_POLL_INTERVAL < DEFAULT_STAGING_TIMEOUT);
|
|
115
|
+
assert.ok(STAGING_IN_PROGRESS_SUFFIXES.includes('.crdownload'));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import {
|
|
4
|
+
CLICK_STATUS,
|
|
5
|
+
CLICK_EFFECT,
|
|
6
|
+
evidence,
|
|
7
|
+
makeClickResult,
|
|
8
|
+
nextActionId,
|
|
9
|
+
} from '../../../src/interactions/click-result.js';
|
|
10
|
+
|
|
11
|
+
describe('click result model', () => {
|
|
12
|
+
it('should expose the documented status and effect vocabularies', () => {
|
|
13
|
+
assert.deepStrictEqual(Object.values(CLICK_STATUS).sort(), [
|
|
14
|
+
'failed',
|
|
15
|
+
'interrupted',
|
|
16
|
+
'succeeded',
|
|
17
|
+
'timed_out',
|
|
18
|
+
'unverified',
|
|
19
|
+
]);
|
|
20
|
+
assert.deepStrictEqual(Object.values(CLICK_EFFECT).sort(), [
|
|
21
|
+
'confirmed',
|
|
22
|
+
'contradicted',
|
|
23
|
+
'not-observed',
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should build evidence entries with a type and detail', () => {
|
|
28
|
+
const entry = evidence('navigation', { urlChanged: true });
|
|
29
|
+
assert.deepStrictEqual(entry, {
|
|
30
|
+
type: 'navigation',
|
|
31
|
+
detail: { urlChanged: true },
|
|
32
|
+
});
|
|
33
|
+
assert.deepStrictEqual(evidence('element-state').detail, {});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should derive verified only from confirmed effect', () => {
|
|
37
|
+
// Regression test for issue #89: `verified` must never be true unless an
|
|
38
|
+
// effect was actually observed.
|
|
39
|
+
const confirmed = makeClickResult({
|
|
40
|
+
status: CLICK_STATUS.SUCCEEDED,
|
|
41
|
+
dispatched: true,
|
|
42
|
+
effect: CLICK_EFFECT.CONFIRMED,
|
|
43
|
+
});
|
|
44
|
+
assert.strictEqual(confirmed.verified, true);
|
|
45
|
+
|
|
46
|
+
for (const effect of [
|
|
47
|
+
CLICK_EFFECT.NOT_OBSERVED,
|
|
48
|
+
CLICK_EFFECT.CONTRADICTED,
|
|
49
|
+
]) {
|
|
50
|
+
const result = makeClickResult({
|
|
51
|
+
status: CLICK_STATUS.UNVERIFIED,
|
|
52
|
+
dispatched: true,
|
|
53
|
+
effect,
|
|
54
|
+
});
|
|
55
|
+
assert.strictEqual(result.verified, false, effect);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should derive clicked from dispatched', () => {
|
|
60
|
+
assert.strictEqual(makeClickResult({ dispatched: true }).clicked, true);
|
|
61
|
+
assert.strictEqual(makeClickResult({ dispatched: false }).clicked, false);
|
|
62
|
+
assert.strictEqual(makeClickResult({}).clicked, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should default to a conservative not-observed effect', () => {
|
|
66
|
+
const result = makeClickResult({ status: CLICK_STATUS.UNVERIFIED });
|
|
67
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
|
|
68
|
+
assert.strictEqual(result.verified, false);
|
|
69
|
+
assert.strictEqual(result.navigated, false);
|
|
70
|
+
assert.deepStrictEqual(result.evidence, []);
|
|
71
|
+
assert.strictEqual(result.elapsedMs, 0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should omit actionId unless one was supplied', () => {
|
|
75
|
+
assert.ok(!('actionId' in makeClickResult({})));
|
|
76
|
+
assert.strictEqual(makeClickResult({ actionId: 'a1' }).actionId, 'a1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should carry extra fields through untouched', () => {
|
|
80
|
+
const result = makeClickResult({ selector: '#go', ready: false });
|
|
81
|
+
assert.strictEqual(result.selector, '#go');
|
|
82
|
+
assert.strictEqual(result.ready, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should mint unique action ids for navigation correlation', () => {
|
|
86
|
+
const ids = new Set(Array.from({ length: 200 }, () => nextActionId()));
|
|
87
|
+
assert.strictEqual(ids.size, 200);
|
|
88
|
+
assert.ok([...ids].every((id) => id.startsWith('click-')));
|
|
89
|
+
});
|
|
90
|
+
});
|