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
|
@@ -6,168 +6,201 @@ import {
|
|
|
6
6
|
verifyClick,
|
|
7
7
|
clickElement,
|
|
8
8
|
clickButton,
|
|
9
|
+
CLICK_STATUS,
|
|
10
|
+
CLICK_EFFECT,
|
|
9
11
|
} from '../../../src/interactions/click.js';
|
|
10
12
|
import {
|
|
11
13
|
createMockPlaywrightPage,
|
|
12
14
|
createMockLogger,
|
|
13
15
|
} from '../../helpers/mocks.js';
|
|
16
|
+
import {
|
|
17
|
+
UNCHANGED_STATE,
|
|
18
|
+
IN_VIEWPORT_POINT,
|
|
19
|
+
createScrollModel,
|
|
20
|
+
} from '../../helpers/click-fixtures.js';
|
|
21
|
+
import { createDeadline } from '../../../src/core/readiness.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run the default verifier against a fixed post-click element state.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} options - Configuration options
|
|
27
|
+
* @param {Object|Error} options.post - Post-click state, or an error to throw
|
|
28
|
+
* @param {Object} [options.pre] - Pre-click state
|
|
29
|
+
* @returns {Promise<Object>} Verification result
|
|
30
|
+
*/
|
|
31
|
+
function verifyAgainst(options) {
|
|
32
|
+
const { post, pre = {} } = options;
|
|
33
|
+
|
|
34
|
+
return defaultClickVerification({
|
|
35
|
+
page: createMockPlaywrightPage(),
|
|
36
|
+
engine: 'playwright',
|
|
37
|
+
locatorOrElement: {},
|
|
38
|
+
preClickState: pre,
|
|
39
|
+
adapter: {
|
|
40
|
+
evaluateOnElement: async () => {
|
|
41
|
+
if (post instanceof Error) {
|
|
42
|
+
throw post;
|
|
43
|
+
}
|
|
44
|
+
return post;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Verify a click that changed nothing at all about the target element.
|
|
52
|
+
*
|
|
53
|
+
* @returns {Promise<Object>} Verification result
|
|
54
|
+
*/
|
|
55
|
+
function verifyUnchanged() {
|
|
56
|
+
return verifyAgainst({
|
|
57
|
+
post: { ...UNCHANGED_STATE },
|
|
58
|
+
pre: { ...UNCHANGED_STATE },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Capture pre-click state through an element probe stub.
|
|
64
|
+
*
|
|
65
|
+
* @param {Function} evaluateOnElement - Adapter probe implementation
|
|
66
|
+
* @returns {Promise<Object>} Captured state
|
|
67
|
+
*/
|
|
68
|
+
function capturePreWith(evaluateOnElement) {
|
|
69
|
+
return capturePreClickState({
|
|
70
|
+
page: createMockPlaywrightPage(),
|
|
71
|
+
engine: 'playwright',
|
|
72
|
+
locatorOrElement: {},
|
|
73
|
+
adapter: { evaluateOnElement },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run `verifyClick` with a custom verifier.
|
|
79
|
+
*
|
|
80
|
+
* @param {Function} verifyFn - Custom verification function
|
|
81
|
+
* @returns {Promise<Object>} Normalized verification outcome
|
|
82
|
+
*/
|
|
83
|
+
function verifyClickWith(verifyFn) {
|
|
84
|
+
return verifyClick({
|
|
85
|
+
page: createMockPlaywrightPage(),
|
|
86
|
+
engine: 'playwright',
|
|
87
|
+
locatorOrElement: {},
|
|
88
|
+
log: createMockLogger(),
|
|
89
|
+
verifyFn,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Assert that a verdict claims nothing it did not observe.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} result - Verification result
|
|
97
|
+
* @param {string} reasonFragment - Substring the reason must contain
|
|
98
|
+
*/
|
|
99
|
+
function assertNotObserved(result, reasonFragment) {
|
|
100
|
+
assert.strictEqual(result.verified, false);
|
|
101
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
|
|
102
|
+
assert.ok(
|
|
103
|
+
result.reason.includes(reasonFragment),
|
|
104
|
+
`reason ${JSON.stringify(result.reason)} should mention ${reasonFragment}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Invoke `clickElement` against the mock page with a caller-supplied adapter.
|
|
110
|
+
*
|
|
111
|
+
* @param {Object} adapter - Engine adapter stub
|
|
112
|
+
* @param {Object} [options] - Extra clickElement options
|
|
113
|
+
* @returns {Promise<{result: Object, page: Object}>} Result and the mock page
|
|
114
|
+
*/
|
|
115
|
+
async function clickWith(adapter, options = {}) {
|
|
116
|
+
const page = createMockPlaywrightPage();
|
|
117
|
+
const result = await clickElement({
|
|
118
|
+
page,
|
|
119
|
+
engine: 'playwright',
|
|
120
|
+
log: createMockLogger(),
|
|
121
|
+
locatorOrElement: {},
|
|
122
|
+
adapter,
|
|
123
|
+
verify: false,
|
|
124
|
+
...options,
|
|
125
|
+
});
|
|
126
|
+
return { result, page };
|
|
127
|
+
}
|
|
14
128
|
|
|
15
129
|
describe('click', () => {
|
|
16
130
|
describe('defaultClickVerification', () => {
|
|
17
131
|
it('should verify click by checking element state', async () => {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
disabled: false,
|
|
22
|
-
ariaPressed: 'true',
|
|
23
|
-
ariaExpanded: null,
|
|
24
|
-
ariaSelected: null,
|
|
25
|
-
checked: false,
|
|
26
|
-
className: 'btn',
|
|
27
|
-
isConnected: true,
|
|
28
|
-
}),
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const result = await defaultClickVerification({
|
|
32
|
-
page,
|
|
33
|
-
engine: 'playwright',
|
|
34
|
-
locatorOrElement: {},
|
|
35
|
-
preClickState: {
|
|
36
|
-
ariaPressed: 'false',
|
|
37
|
-
},
|
|
38
|
-
adapter,
|
|
132
|
+
const result = await verifyAgainst({
|
|
133
|
+
post: { ...UNCHANGED_STATE, ariaPressed: 'true' },
|
|
134
|
+
pre: { ...UNCHANGED_STATE },
|
|
39
135
|
});
|
|
40
136
|
|
|
41
137
|
assert.strictEqual(result.verified, true);
|
|
138
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.CONFIRMED);
|
|
42
139
|
assert.ok(result.reason.includes('aria-pressed'));
|
|
43
140
|
});
|
|
44
141
|
|
|
45
142
|
it('should verify when className changed', async () => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
disabled: false,
|
|
50
|
-
className: 'btn active',
|
|
51
|
-
isConnected: true,
|
|
52
|
-
}),
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const result = await defaultClickVerification({
|
|
56
|
-
page,
|
|
57
|
-
engine: 'playwright',
|
|
58
|
-
locatorOrElement: {},
|
|
59
|
-
preClickState: {
|
|
60
|
-
className: 'btn',
|
|
61
|
-
},
|
|
62
|
-
adapter,
|
|
143
|
+
const result = await verifyAgainst({
|
|
144
|
+
post: { ...UNCHANGED_STATE, className: 'btn active' },
|
|
145
|
+
pre: { ...UNCHANGED_STATE },
|
|
63
146
|
});
|
|
64
147
|
|
|
65
148
|
assert.strictEqual(result.verified, true);
|
|
66
149
|
assert.ok(result.reason.includes('className'));
|
|
67
150
|
});
|
|
68
151
|
|
|
69
|
-
it('should
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
disabled: false,
|
|
74
|
-
isConnected: true,
|
|
75
|
-
}),
|
|
76
|
-
};
|
|
152
|
+
it('should NOT claim success when nothing about the element changed', async () => {
|
|
153
|
+
// Regression test for issue #89: a button whose handler does nothing left
|
|
154
|
+
// the element connected and unchanged, and that was reported as success.
|
|
155
|
+
const result = await verifyUnchanged();
|
|
77
156
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
engine: 'playwright',
|
|
81
|
-
locatorOrElement: {},
|
|
82
|
-
preClickState: {},
|
|
83
|
-
adapter,
|
|
84
|
-
});
|
|
157
|
+
assertNotObserved(result, 'no observable change');
|
|
158
|
+
});
|
|
85
159
|
|
|
86
|
-
|
|
87
|
-
|
|
160
|
+
it('should report not-observed when no pre-click state was captured', async () => {
|
|
161
|
+
const result = await verifyAgainst({ post: { ...UNCHANGED_STATE } });
|
|
162
|
+
|
|
163
|
+
assertNotObserved(result, 'no pre-click state');
|
|
88
164
|
});
|
|
89
165
|
|
|
90
166
|
it('should verify when element removed from DOM', async () => {
|
|
91
|
-
const
|
|
92
|
-
const adapter = {
|
|
93
|
-
evaluateOnElement: async () => ({
|
|
94
|
-
isConnected: false,
|
|
95
|
-
}),
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const result = await defaultClickVerification({
|
|
99
|
-
page,
|
|
100
|
-
engine: 'playwright',
|
|
101
|
-
locatorOrElement: {},
|
|
102
|
-
preClickState: {},
|
|
103
|
-
adapter,
|
|
104
|
-
});
|
|
167
|
+
const result = await verifyAgainst({ post: { isConnected: false } });
|
|
105
168
|
|
|
106
169
|
assert.strictEqual(result.verified, true);
|
|
170
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.CONFIRMED);
|
|
107
171
|
assert.ok(result.reason.includes('removed'));
|
|
108
172
|
});
|
|
109
173
|
|
|
110
|
-
it('should
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const result = await defaultClickVerification({
|
|
119
|
-
page,
|
|
120
|
-
engine: 'playwright',
|
|
121
|
-
locatorOrElement: {},
|
|
122
|
-
preClickState: {},
|
|
123
|
-
adapter,
|
|
174
|
+
it('should not treat a destroyed execution context as proof of effect', async () => {
|
|
175
|
+
// Regression test for issue #89: the click may or may not have caused the
|
|
176
|
+
// navigation that destroyed the context. Attribution is the caller's job.
|
|
177
|
+
const result = await verifyAgainst({
|
|
178
|
+
post: new Error('Execution context was destroyed'),
|
|
124
179
|
});
|
|
125
180
|
|
|
126
|
-
|
|
181
|
+
assertNotObserved(result, 'verification unavailable');
|
|
127
182
|
assert.strictEqual(result.navigationError, true);
|
|
128
183
|
});
|
|
184
|
+
|
|
185
|
+
it('should attach evidence to every verdict', async () => {
|
|
186
|
+
const result = await verifyUnchanged();
|
|
187
|
+
|
|
188
|
+
assert.ok(Array.isArray(result.evidence));
|
|
189
|
+
assert.strictEqual(result.evidence[0].type, 'element-state');
|
|
190
|
+
});
|
|
129
191
|
});
|
|
130
192
|
|
|
131
193
|
describe('capturePreClickState', () => {
|
|
132
194
|
it('should capture element state', async () => {
|
|
133
|
-
const
|
|
134
|
-
const adapter = {
|
|
135
|
-
evaluateOnElement: async () => ({
|
|
136
|
-
disabled: false,
|
|
137
|
-
ariaPressed: 'false',
|
|
138
|
-
ariaExpanded: null,
|
|
139
|
-
ariaSelected: null,
|
|
140
|
-
checked: false,
|
|
141
|
-
className: 'btn',
|
|
142
|
-
isConnected: true,
|
|
143
|
-
}),
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const state = await capturePreClickState({
|
|
147
|
-
page,
|
|
148
|
-
engine: 'playwright',
|
|
149
|
-
locatorOrElement: {},
|
|
150
|
-
adapter,
|
|
151
|
-
});
|
|
195
|
+
const state = await capturePreWith(async () => ({ ...UNCHANGED_STATE }));
|
|
152
196
|
|
|
153
|
-
assert.ok(state);
|
|
154
197
|
assert.strictEqual(state.disabled, false);
|
|
155
198
|
assert.strictEqual(state.className, 'btn');
|
|
156
199
|
});
|
|
157
200
|
|
|
158
201
|
it('should return empty object on navigation error', async () => {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
evaluateOnElement: async () => {
|
|
162
|
-
throw new Error('Execution context was destroyed');
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const state = await capturePreClickState({
|
|
167
|
-
page,
|
|
168
|
-
engine: 'playwright',
|
|
169
|
-
locatorOrElement: {},
|
|
170
|
-
adapter,
|
|
202
|
+
const state = await capturePreWith(async () => {
|
|
203
|
+
throw new Error('Execution context was destroyed');
|
|
171
204
|
});
|
|
172
205
|
|
|
173
206
|
assert.deepStrictEqual(state, {});
|
|
@@ -176,20 +209,11 @@ describe('click', () => {
|
|
|
176
209
|
|
|
177
210
|
describe('verifyClick', () => {
|
|
178
211
|
it('should use custom verify function', async () => {
|
|
179
|
-
const page = createMockPlaywrightPage();
|
|
180
|
-
const log = createMockLogger();
|
|
181
212
|
let customCalled = false;
|
|
182
|
-
|
|
213
|
+
|
|
214
|
+
const result = await verifyClickWith(async () => {
|
|
183
215
|
customCalled = true;
|
|
184
216
|
return { verified: true, reason: 'custom verification' };
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const result = await verifyClick({
|
|
188
|
-
page,
|
|
189
|
-
engine: 'playwright',
|
|
190
|
-
locatorOrElement: {},
|
|
191
|
-
verifyFn: customVerifyFn,
|
|
192
|
-
log,
|
|
193
217
|
});
|
|
194
218
|
|
|
195
219
|
assert.strictEqual(customCalled, true);
|
|
@@ -197,113 +221,160 @@ describe('click', () => {
|
|
|
197
221
|
assert.strictEqual(result.reason, 'custom verification');
|
|
198
222
|
});
|
|
199
223
|
|
|
200
|
-
it('should
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Should have logged
|
|
224
|
+
it('should derive an effect for custom verifiers that do not set one', async () => {
|
|
225
|
+
const confirmed = await verifyClickWith(async () => ({
|
|
226
|
+
verified: true,
|
|
227
|
+
reason: 'ok',
|
|
228
|
+
}));
|
|
229
|
+
assert.strictEqual(confirmed.effect, CLICK_EFFECT.CONFIRMED);
|
|
230
|
+
|
|
231
|
+
const denied = await verifyClickWith(async () => ({
|
|
232
|
+
verified: false,
|
|
233
|
+
reason: 'nope',
|
|
234
|
+
}));
|
|
235
|
+
assert.strictEqual(denied.effect, CLICK_EFFECT.NOT_OBSERVED);
|
|
213
236
|
});
|
|
214
237
|
});
|
|
215
238
|
|
|
216
239
|
describe('clickElement', () => {
|
|
217
240
|
it('should throw when locatorOrElement is not provided', async () => {
|
|
218
|
-
const page = createMockPlaywrightPage();
|
|
219
|
-
const log = createMockLogger();
|
|
220
|
-
|
|
221
241
|
await assert.rejects(
|
|
222
|
-
() =>
|
|
242
|
+
() =>
|
|
243
|
+
clickElement({
|
|
244
|
+
page: createMockPlaywrightPage(),
|
|
245
|
+
engine: 'playwright',
|
|
246
|
+
log: createMockLogger(),
|
|
247
|
+
}),
|
|
223
248
|
/locatorOrElement is required/
|
|
224
249
|
);
|
|
225
250
|
});
|
|
226
251
|
|
|
227
|
-
it('should
|
|
228
|
-
const page = createMockPlaywrightPage();
|
|
229
|
-
const log = createMockLogger();
|
|
252
|
+
it('should report unverified when verification was not requested', async () => {
|
|
230
253
|
let clicked = false;
|
|
231
|
-
const
|
|
254
|
+
const { result } = await clickWith({
|
|
232
255
|
click: async () => {
|
|
233
256
|
clicked = true;
|
|
234
257
|
},
|
|
235
258
|
evaluateOnElement: async () => ({ isConnected: true }),
|
|
236
|
-
};
|
|
237
|
-
const mockLocator = {};
|
|
238
|
-
|
|
239
|
-
const result = await clickElement({
|
|
240
|
-
page,
|
|
241
|
-
engine: 'playwright',
|
|
242
|
-
log,
|
|
243
|
-
locatorOrElement: mockLocator,
|
|
244
|
-
adapter,
|
|
245
|
-
verify: false,
|
|
246
259
|
});
|
|
247
260
|
|
|
248
|
-
assert.strictEqual(result.clicked, true);
|
|
249
261
|
assert.strictEqual(clicked, true);
|
|
262
|
+
assert.strictEqual(result.clicked, true);
|
|
263
|
+
assert.strictEqual(result.verified, false);
|
|
264
|
+
assert.strictEqual(result.status, CLICK_STATUS.UNVERIFIED);
|
|
250
265
|
});
|
|
251
266
|
|
|
252
|
-
it('should
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
267
|
+
it('should not route noAutoScroll through Playwright force, which still scrolls', async () => {
|
|
268
|
+
// Regression test for issue #89: `{ force: true }` skips actionability
|
|
269
|
+
// checks but does NOT disable scroll-into-view, so the old mapping
|
|
270
|
+
// silently scrolled the page while reporting that it had not.
|
|
271
|
+
let adapterClickCalled = false;
|
|
272
|
+
const { result, page } = await clickWith(
|
|
273
|
+
{
|
|
274
|
+
click: async () => {
|
|
275
|
+
adapterClickCalled = true;
|
|
276
|
+
},
|
|
277
|
+
evaluateOnElement: async () => ({ ...IN_VIEWPORT_POINT }),
|
|
278
|
+
evaluateOnPage: async () => ({ x: 0, y: 0 }),
|
|
259
279
|
},
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
{ noAutoScroll: true }
|
|
281
|
+
);
|
|
262
282
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
assert.strictEqual(adapterClickCalled, false);
|
|
284
|
+
assert.strictEqual(page.mouse.clicks.length, 1);
|
|
285
|
+
assert.strictEqual(page.mouse.clicks[0].x, IN_VIEWPORT_POINT.x);
|
|
286
|
+
assert.strictEqual(page.mouse.clicks[0].y, IN_VIEWPORT_POINT.y);
|
|
287
|
+
assert.strictEqual(result.dispatched, true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should pass force to the engine only for actionability: force', async () => {
|
|
291
|
+
let clickOptions = null;
|
|
292
|
+
await clickWith(
|
|
293
|
+
{
|
|
294
|
+
click: async (el, opts) => {
|
|
295
|
+
clickOptions = opts;
|
|
296
|
+
},
|
|
297
|
+
evaluateOnElement: async () => ({ isConnected: true }),
|
|
298
|
+
evaluateOnPage: async () => ({ x: 0, y: 0 }),
|
|
299
|
+
},
|
|
300
|
+
{ actionability: 'force' }
|
|
301
|
+
);
|
|
272
302
|
|
|
273
303
|
assert.deepStrictEqual(clickOptions, { force: true });
|
|
274
304
|
});
|
|
275
305
|
|
|
276
|
-
it('should
|
|
277
|
-
const page =
|
|
278
|
-
|
|
279
|
-
|
|
306
|
+
it('should fail clearly when scroll: none cannot be honored', async () => {
|
|
307
|
+
const { result, page } = await clickWith(
|
|
308
|
+
{
|
|
309
|
+
click: async () => {
|
|
310
|
+
throw new Error('adapter.click must not be reached');
|
|
311
|
+
},
|
|
312
|
+
evaluateOnElement: async () => ({
|
|
313
|
+
...IN_VIEWPORT_POINT,
|
|
314
|
+
y: 4000,
|
|
315
|
+
top: 3980,
|
|
316
|
+
inViewport: false,
|
|
317
|
+
hitsTarget: false,
|
|
318
|
+
}),
|
|
319
|
+
evaluateOnPage: async () => ({ x: 0, y: 0 }),
|
|
320
|
+
},
|
|
321
|
+
{ scroll: 'none' }
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
assert.strictEqual(result.status, CLICK_STATUS.FAILED);
|
|
325
|
+
assert.strictEqual(result.dispatched, false);
|
|
326
|
+
assert.strictEqual(page.mouse.clicks.length, 0);
|
|
327
|
+
assert.ok(result.reason.includes('outside the viewport'));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should restore the scroll position for scroll: preserve', async () => {
|
|
331
|
+
const scroll = createScrollModel();
|
|
332
|
+
await clickWith(
|
|
333
|
+
{
|
|
334
|
+
click: async () => scroll.set(3911),
|
|
335
|
+
evaluateOnElement: async () => ({ isConnected: true }),
|
|
336
|
+
evaluateOnPage: scroll.evaluateOnPage,
|
|
337
|
+
},
|
|
338
|
+
{ scroll: 'preserve' }
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
assert.strictEqual(scroll.current(), 0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should report interrupted, not verified, when navigation cuts in', async () => {
|
|
345
|
+
const { result } = await clickWith({
|
|
280
346
|
click: async () => {
|
|
281
347
|
throw new Error('Execution context was destroyed');
|
|
282
348
|
},
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const result = await clickElement({
|
|
286
|
-
page,
|
|
287
|
-
engine: 'playwright',
|
|
288
|
-
log,
|
|
289
|
-
locatorOrElement: {},
|
|
290
|
-
adapter,
|
|
291
|
-
verify: false,
|
|
292
349
|
});
|
|
293
350
|
|
|
294
351
|
assert.strictEqual(result.clicked, false);
|
|
295
|
-
assert.strictEqual(result.verified,
|
|
352
|
+
assert.strictEqual(result.verified, false);
|
|
353
|
+
assert.strictEqual(result.status, CLICK_STATUS.INTERRUPTED);
|
|
354
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should carry an action id for navigation correlation', async () => {
|
|
358
|
+
const { result } = await clickWith({
|
|
359
|
+
click: async () => {},
|
|
360
|
+
evaluateOnElement: async () => ({ isConnected: true }),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
assert.strictEqual(typeof result.actionId, 'string');
|
|
364
|
+
assert.ok(result.elapsedMs >= 0);
|
|
296
365
|
});
|
|
297
366
|
});
|
|
298
367
|
|
|
299
368
|
describe('clickButton', () => {
|
|
300
369
|
it('should throw when selector is not provided', async () => {
|
|
301
|
-
const page = createMockPlaywrightPage();
|
|
302
|
-
const log = createMockLogger();
|
|
303
|
-
const wait = async () => {};
|
|
304
|
-
|
|
305
370
|
await assert.rejects(
|
|
306
|
-
() =>
|
|
371
|
+
() =>
|
|
372
|
+
clickButton({
|
|
373
|
+
page: createMockPlaywrightPage(),
|
|
374
|
+
engine: 'playwright',
|
|
375
|
+
log: createMockLogger(),
|
|
376
|
+
wait: async () => {},
|
|
377
|
+
}),
|
|
307
378
|
/selector is required/
|
|
308
379
|
);
|
|
309
380
|
});
|
|
@@ -312,8 +383,6 @@ describe('click', () => {
|
|
|
312
383
|
const page = createMockPlaywrightPage({
|
|
313
384
|
elements: { button: { visible: true, count: 1 } },
|
|
314
385
|
});
|
|
315
|
-
const log = createMockLogger();
|
|
316
|
-
const wait = async ({ ms }) => ({ completed: true, aborted: false });
|
|
317
386
|
|
|
318
387
|
// This is a complex test that requires full mock setup
|
|
319
388
|
// For unit tests, we'll verify the interface
|
|
@@ -321,8 +390,8 @@ describe('click', () => {
|
|
|
321
390
|
const result = await clickButton({
|
|
322
391
|
page,
|
|
323
392
|
engine: 'playwright',
|
|
324
|
-
log,
|
|
325
|
-
wait,
|
|
393
|
+
log: createMockLogger(),
|
|
394
|
+
wait: async () => ({ completed: true, aborted: false }),
|
|
326
395
|
selector: 'button',
|
|
327
396
|
scrollIntoView: false,
|
|
328
397
|
waitAfterClick: 0,
|
|
@@ -331,10 +400,150 @@ describe('click', () => {
|
|
|
331
400
|
});
|
|
332
401
|
assert.ok(typeof result.clicked === 'boolean');
|
|
333
402
|
assert.ok(typeof result.navigated === 'boolean');
|
|
403
|
+
assert.ok(Object.values(CLICK_STATUS).includes(result.status));
|
|
334
404
|
} catch (e) {
|
|
335
405
|
// May fail due to mock limitations, but we verify the interface works
|
|
336
406
|
assert.ok(e.message);
|
|
337
407
|
}
|
|
338
408
|
});
|
|
339
409
|
});
|
|
410
|
+
|
|
411
|
+
describe('click deadlines', () => {
|
|
412
|
+
/** An element probe that never answers, like a locator on a lost document. */
|
|
413
|
+
const neverAnswers = () => new Promise(() => {});
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A probe that answers once and then hangs.
|
|
417
|
+
*
|
|
418
|
+
* This is what a real engine looks like once the document the element
|
|
419
|
+
* lived in is gone: the first read is served, and any later one waits for
|
|
420
|
+
* the engine's own far longer timeout.
|
|
421
|
+
*
|
|
422
|
+
* @returns {Function} Element probe for a stub adapter
|
|
423
|
+
*/
|
|
424
|
+
const answersOnceThenHangs = () => {
|
|
425
|
+
let probes = 0;
|
|
426
|
+
return async (...args) =>
|
|
427
|
+
(probes += 1) === 1 ? { ...UNCHANGED_STATE } : neverAnswers(...args);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Click a stub adapter with a budget far below any engine's own timeout.
|
|
432
|
+
*
|
|
433
|
+
* @param {Object} adapter - Stub engine adapter
|
|
434
|
+
* @returns {Promise<Object>} Click result
|
|
435
|
+
*/
|
|
436
|
+
const clickOnABudget = async (adapter) => {
|
|
437
|
+
const { result } = await clickWith(adapter, {
|
|
438
|
+
verify: true,
|
|
439
|
+
timeout: 60,
|
|
440
|
+
});
|
|
441
|
+
return result;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
it('should stop probing when the click budget is spent', async () => {
|
|
445
|
+
// Regression test for issue #89: Playwright's locator probe carries a
|
|
446
|
+
// 30-second default, so an unbounded verification outlived every budget
|
|
447
|
+
// the caller asked for.
|
|
448
|
+
const started = Date.now();
|
|
449
|
+
const result = await defaultClickVerification({
|
|
450
|
+
page: createMockPlaywrightPage(),
|
|
451
|
+
engine: 'playwright',
|
|
452
|
+
locatorOrElement: {},
|
|
453
|
+
preClickState: { ...UNCHANGED_STATE },
|
|
454
|
+
adapter: { evaluateOnElement: neverAnswers },
|
|
455
|
+
deadline: createDeadline({ timeout: 40 }),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
assert.strictEqual(result.timedOut, true);
|
|
459
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
|
|
460
|
+
assert.strictEqual(result.evidence[0].type, 'verification-timeout');
|
|
461
|
+
assert.ok(Date.now() - started < 2000);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should report timed_out rather than unverified for an unreadable effect', async () => {
|
|
465
|
+
const result = await clickOnABudget({
|
|
466
|
+
click: async () => {},
|
|
467
|
+
evaluateOnElement: answersOnceThenHangs(),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
assert.strictEqual(result.status, CLICK_STATUS.TIMED_OUT);
|
|
471
|
+
assert.strictEqual(result.dispatched, true);
|
|
472
|
+
assert.strictEqual(result.verified, false);
|
|
473
|
+
assert.ok(
|
|
474
|
+
result.evidence.some((item) => item.type === 'verification-timeout')
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should stop waiting on a dispatch that outlives the budget', async () => {
|
|
479
|
+
// Dispatch is an engine round-trip too, so an unbounded one spends the
|
|
480
|
+
// budget the caller reserved for the whole click before verification
|
|
481
|
+
// ever gets a turn.
|
|
482
|
+
const started = Date.now();
|
|
483
|
+
const result = await clickOnABudget({
|
|
484
|
+
click: neverAnswers,
|
|
485
|
+
evaluateOnElement: async () => ({ ...UNCHANGED_STATE }),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
assert.ok(Date.now() - started < 2000);
|
|
489
|
+
assert.strictEqual(result.status, CLICK_STATUS.TIMED_OUT);
|
|
490
|
+
assert.strictEqual(result.dispatched, false);
|
|
491
|
+
assert.strictEqual(result.evidence[0].type, 'dispatch-timeout');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should measure elapsed time monotonically, not by the wall clock', async () => {
|
|
495
|
+
const { result } = await clickWith({
|
|
496
|
+
click: async () => {},
|
|
497
|
+
evaluateOnElement: async () => ({ ...UNCHANGED_STATE }),
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
assert.ok(Number.isFinite(result.elapsedMs));
|
|
501
|
+
assert.ok(result.elapsedMs >= 0);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should give up on a target the page navigated away from', async () => {
|
|
505
|
+
// A navigation replaces the document, and the engine answers the probe on
|
|
506
|
+
// the new one instead of failing - so the URL is what ends the wait.
|
|
507
|
+
const page = createMockPlaywrightPage();
|
|
508
|
+
let url = 'https://example.com/start';
|
|
509
|
+
page.url = () => url;
|
|
510
|
+
// The pre-click probe answers; only the post-click one is left hanging,
|
|
511
|
+
// which is what a probe against a replaced document looks like.
|
|
512
|
+
const timer = setTimeout(() => {
|
|
513
|
+
url = 'https://example.com/arrived';
|
|
514
|
+
}, 60);
|
|
515
|
+
|
|
516
|
+
const started = Date.now();
|
|
517
|
+
const result = await clickElement({
|
|
518
|
+
page,
|
|
519
|
+
engine: 'playwright',
|
|
520
|
+
log: createMockLogger(),
|
|
521
|
+
locatorOrElement: {},
|
|
522
|
+
verify: true,
|
|
523
|
+
timeout: 5000,
|
|
524
|
+
adapter: {
|
|
525
|
+
click: async () => {},
|
|
526
|
+
evaluateOnElement: answersOnceThenHangs(),
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
clearTimeout(timer);
|
|
530
|
+
|
|
531
|
+
assert.ok(
|
|
532
|
+
Date.now() - started < 2000,
|
|
533
|
+
'the navigation, not the budget, should end the wait'
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
assert.strictEqual(result.status, CLICK_STATUS.UNVERIFIED);
|
|
537
|
+
assert.strictEqual(result.effect, CLICK_EFFECT.NOT_OBSERVED);
|
|
538
|
+
assert.strictEqual(result.dispatched, true);
|
|
539
|
+
const navigation = result.evidence.find(
|
|
540
|
+
(item) => item.type === 'navigation'
|
|
541
|
+
);
|
|
542
|
+
assert.ok(navigation, 'the navigation should be recorded as evidence');
|
|
543
|
+
// The navigation is correlated with the click, but it is not proof that
|
|
544
|
+
// the click caused it.
|
|
545
|
+
assert.strictEqual(navigation.detail.provesClickEffect, false);
|
|
546
|
+
assert.strictEqual(navigation.detail.to, 'https://example.com/arrived');
|
|
547
|
+
});
|
|
548
|
+
});
|
|
340
549
|
});
|