browser-commander 0.27.0 → 0.29.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 +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +149 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { screenshot } from '../../../src/capture/index.js';
|
|
4
|
+
import { parseCommandLine } from '../../../src/cli/args.js';
|
|
5
|
+
|
|
6
|
+
function cdpPage(session, capture) {
|
|
7
|
+
return {
|
|
8
|
+
context: () => ({ newCDPSession: async () => session }),
|
|
9
|
+
createCDPSession: async () => session,
|
|
10
|
+
screenshot: capture,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
15
|
+
it(`${engine} emulated viewports preserve engine pixel scaling`, async () => {
|
|
16
|
+
const page = cdpPage(
|
|
17
|
+
{
|
|
18
|
+
send: async () => assert.fail('native view ignores emulation'),
|
|
19
|
+
detach: async () => {},
|
|
20
|
+
},
|
|
21
|
+
async (options) => {
|
|
22
|
+
assert.equal(options.fullPage, false);
|
|
23
|
+
return Buffer.from('emulated viewport');
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
page.viewportSize = () => ({ width: 320, height: 200 });
|
|
27
|
+
page.viewport = () => ({ width: 320, height: 200, deviceScaleFactor: 2 });
|
|
28
|
+
assert.equal(
|
|
29
|
+
(await screenshot({ page, engine, stableViewport: true })).toString(),
|
|
30
|
+
'emulated viewport'
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('Puppeteer BiDi without CDP uses a viewport-only fallback', async () => {
|
|
36
|
+
const error = Object.assign(new Error(''), { name: 'UnsupportedOperation' });
|
|
37
|
+
const page = {
|
|
38
|
+
createCDPSession: async () => {
|
|
39
|
+
throw error;
|
|
40
|
+
},
|
|
41
|
+
screenshot: async (options) => {
|
|
42
|
+
assert.equal(options.fullPage, false);
|
|
43
|
+
return Buffer.from('bidi viewport');
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
assert.equal(
|
|
47
|
+
(
|
|
48
|
+
await screenshot({ page, engine: 'puppeteer', stableViewport: true })
|
|
49
|
+
).toString(),
|
|
50
|
+
'bidi viewport'
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('the shared CLI parses stable viewport capture', () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
parseCommandLine(['screenshot', 'viewport.png', '--stable-viewport'])
|
|
57
|
+
.options.stableViewport,
|
|
58
|
+
true
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
it('Firefox without CDP falls back to its existing viewport', async () => {
|
|
62
|
+
const page = {
|
|
63
|
+
context: () => ({
|
|
64
|
+
newCDPSession: async () => {
|
|
65
|
+
throw new Error('CDP sessions are only supported by Chromium');
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
screenshot: async (options) => {
|
|
69
|
+
assert.equal(options.fullPage, false);
|
|
70
|
+
assert.equal(options.caret, 'initial');
|
|
71
|
+
return Buffer.from('firefox viewport');
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
assert.equal(
|
|
75
|
+
(await screenshot({ page, stableViewport: true })).toString(),
|
|
76
|
+
'firefox viewport'
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
81
|
+
it(`${engine} stable viewport captures the view and detaches CDP`, async () => {
|
|
82
|
+
let detached = false;
|
|
83
|
+
const session = {
|
|
84
|
+
send: async (method, options) => {
|
|
85
|
+
assert.equal(method, 'Page.captureScreenshot');
|
|
86
|
+
assert.deepEqual(options, {
|
|
87
|
+
format: 'png',
|
|
88
|
+
fromSurface: false,
|
|
89
|
+
captureBeyondViewport: false,
|
|
90
|
+
});
|
|
91
|
+
return { data: Buffer.from('viewport').toString('base64') };
|
|
92
|
+
},
|
|
93
|
+
detach: async () => {
|
|
94
|
+
detached = true;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const page = cdpPage(session, async () =>
|
|
98
|
+
assert.fail('view capture must bypass engine screenshot')
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
(await screenshot({ page, engine, stableViewport: true })).toString(),
|
|
102
|
+
'viewport'
|
|
103
|
+
);
|
|
104
|
+
assert.equal(detached, true);
|
|
105
|
+
});
|
|
106
|
+
it(`${engine} stable viewport has a viewport-only headless fallback`, async () => {
|
|
107
|
+
let detached = false;
|
|
108
|
+
const session = {
|
|
109
|
+
send: async () => {
|
|
110
|
+
throw new Error('Unable to capture screenshot');
|
|
111
|
+
},
|
|
112
|
+
detach: async () => {
|
|
113
|
+
detached = true;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const page = cdpPage(session, async (options) => {
|
|
117
|
+
assert.equal(options.fullPage, false);
|
|
118
|
+
return Buffer.from('fallback');
|
|
119
|
+
});
|
|
120
|
+
assert.equal(
|
|
121
|
+
(await screenshot({ page, engine, stableViewport: true })).toString(),
|
|
122
|
+
'fallback'
|
|
123
|
+
);
|
|
124
|
+
assert.equal(detached, true);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
it('stable viewport rejects regions and visual capture styling', async () => {
|
|
128
|
+
for (const options of [
|
|
129
|
+
{ fullPage: true },
|
|
130
|
+
{ selector: '#pager' },
|
|
131
|
+
{ clip: { x: 0, y: 0, width: 1, height: 1 } },
|
|
132
|
+
{ hideCaret: true },
|
|
133
|
+
{ animations: 'disabled' },
|
|
134
|
+
{ omitBackground: true },
|
|
135
|
+
]) {
|
|
136
|
+
await assert.rejects(
|
|
137
|
+
screenshot({
|
|
138
|
+
page: { screenshot: async () => Buffer.alloc(0) },
|
|
139
|
+
stableViewport: true,
|
|
140
|
+
...options,
|
|
141
|
+
}),
|
|
142
|
+
/stableViewport/
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
it('stable viewport preserves genuine protocol errors and detaches', async () => {
|
|
147
|
+
const error = new Error('Target closed');
|
|
148
|
+
let detached = false;
|
|
149
|
+
const page = {
|
|
150
|
+
context: () => ({
|
|
151
|
+
newCDPSession: async () => ({
|
|
152
|
+
send: async () => {
|
|
153
|
+
throw error;
|
|
154
|
+
},
|
|
155
|
+
detach: async () => {
|
|
156
|
+
detached = true;
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
screenshot: async () =>
|
|
161
|
+
assert.fail('unrelated errors must not invoke fallback'),
|
|
162
|
+
};
|
|
163
|
+
await assert.rejects(
|
|
164
|
+
screenshot({ page, stableViewport: true }),
|
|
165
|
+
(actual) => actual === error
|
|
166
|
+
);
|
|
167
|
+
assert.equal(detached, true);
|
|
168
|
+
});
|
|
@@ -121,6 +121,31 @@ describe('dispatcher: high-level methods', () => {
|
|
|
121
121
|
assert.deepEqual(pages[0].calls, [['fill', '#q', 'v']]);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
125
|
+
for (const keepOpen of [false, true]) {
|
|
126
|
+
it(`closes ${engine} launches with detach support according to keepOpen=${keepOpen}`, async () => {
|
|
127
|
+
const { dependencies, launches } = createFakeDependencies();
|
|
128
|
+
const launchBrowser = dependencies.launchBrowser;
|
|
129
|
+
let detached = false;
|
|
130
|
+
const dispatcher = createDispatcher({
|
|
131
|
+
dependencies: {
|
|
132
|
+
...dependencies,
|
|
133
|
+
launchBrowser: async (options) => ({
|
|
134
|
+
...(await launchBrowser(options)),
|
|
135
|
+
detach: async () => {
|
|
136
|
+
detached = true;
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
await dispatcher.dispatch('session.launch', { engine, keepOpen });
|
|
142
|
+
await dispatcher.close();
|
|
143
|
+
assert.equal(launches[0].closed, !keepOpen);
|
|
144
|
+
assert.equal(detached, keepOpen);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
it('connects without closing the browser', async () => {
|
|
125
150
|
const { dispatcher, connections } = setup();
|
|
126
151
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isNavigationError } from '../../../src/core/navigation-safety.js';
|
|
4
|
+
import { createManagedNavigator } from '../../../src/core/managed-navigation.js';
|
|
5
|
+
|
|
6
|
+
it('classifies net::ERR_ABORTED as an interrupted navigation outcome', async () => {
|
|
7
|
+
const error = new Error(
|
|
8
|
+
'page.goto: net::ERR_ABORTED at https://example.test/a'
|
|
9
|
+
);
|
|
10
|
+
assert.equal(isNavigationError(error), true);
|
|
11
|
+
const goto = createManagedNavigator({
|
|
12
|
+
page: {
|
|
13
|
+
goto: async () => {
|
|
14
|
+
throw error;
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
state: {},
|
|
18
|
+
config: {},
|
|
19
|
+
triggerNavigationStart: async () => {},
|
|
20
|
+
updateCurrentUrl() {},
|
|
21
|
+
abandonNavigation() {},
|
|
22
|
+
waitForReady: async () => ({ ready: true }),
|
|
23
|
+
});
|
|
24
|
+
assert.equal(
|
|
25
|
+
(await goto({ url: 'https://example.test/a', returnOutcome: true })).status,
|
|
26
|
+
'interrupted'
|
|
27
|
+
);
|
|
28
|
+
assert.equal(
|
|
29
|
+
isNavigationError(new Error('net::ERR_NAME_NOT_RESOLVED')),
|
|
30
|
+
false
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -427,6 +427,27 @@ describe('readiness', () => {
|
|
|
427
427
|
assert.strictEqual(result.ready, false);
|
|
428
428
|
});
|
|
429
429
|
|
|
430
|
+
it('should preserve timer expiry while a fractional clock budget remains', async () => {
|
|
431
|
+
const clock = createFakeClock();
|
|
432
|
+
const deadline = createDeadline({ timeout: 20, now: clock.now });
|
|
433
|
+
// The timer and monotonic clock can disagree by a fraction of a
|
|
434
|
+
// millisecond. Freeze that disagreement to reproduce it every time.
|
|
435
|
+
clock.advance(19.4);
|
|
436
|
+
const result = await runReadinessChecks({
|
|
437
|
+
checks: [
|
|
438
|
+
{ name: 'never', run: () => new Promise(() => {}) },
|
|
439
|
+
ok('unreached'),
|
|
440
|
+
],
|
|
441
|
+
deadline,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
assert.strictEqual(deadline.expired(), false);
|
|
445
|
+
assert.strictEqual(result.status, READINESS_STATUS.TIMED_OUT);
|
|
446
|
+
assert.strictEqual(result.ready, false);
|
|
447
|
+
assert.deepStrictEqual(result.checks.pending, ['unreached']);
|
|
448
|
+
assert.strictEqual(result.evidence[0].detail.reason, 'deadline reached');
|
|
449
|
+
});
|
|
450
|
+
|
|
430
451
|
it('should report elapsed and total budget on the result', async () => {
|
|
431
452
|
const clock = createFakeClock();
|
|
432
453
|
const deadline = createDeadline({ timeout: 800, now: clock.now });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { createPageTriggerManager } from '../../../src/core/page-trigger-manager.js';
|
|
5
|
+
|
|
6
|
+
it('DOM-ready triggers start without network idle and skip overlapping runs', async () => {
|
|
7
|
+
const events = new EventEmitter();
|
|
8
|
+
let release;
|
|
9
|
+
let calls = 0;
|
|
10
|
+
const manager = createPageTriggerManager({
|
|
11
|
+
navigationManager: events,
|
|
12
|
+
log: { debug() {} },
|
|
13
|
+
});
|
|
14
|
+
manager.pageTrigger({
|
|
15
|
+
readyOn: 'domcontentloaded',
|
|
16
|
+
concurrency: 'skip',
|
|
17
|
+
condition: () => true,
|
|
18
|
+
action: async () => {
|
|
19
|
+
calls++;
|
|
20
|
+
await new Promise((resolve) => {
|
|
21
|
+
release = resolve;
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const page = new EventEmitter();
|
|
26
|
+
page.url = () => 'https://example.com/form';
|
|
27
|
+
manager.initialize({ page });
|
|
28
|
+
page.emit('domcontentloaded');
|
|
29
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
30
|
+
assert.equal(calls, 1);
|
|
31
|
+
page.emit('domcontentloaded');
|
|
32
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
33
|
+
assert.equal(calls, 1);
|
|
34
|
+
release();
|
|
35
|
+
await manager.destroy();
|
|
36
|
+
assert.equal(page.listenerCount('domcontentloaded'), 0);
|
|
37
|
+
assert.equal(events.listenerCount('onPageReady'), 0);
|
|
38
|
+
});
|
|
39
|
+
import { setImmediate } from 'node:timers';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { createPageTriggerManager } from '../../../src/core/page-trigger-manager.js';
|
|
5
|
+
|
|
6
|
+
const flush = () => new Promise((resolve) => globalThis.setImmediate(resolve));
|
|
7
|
+
|
|
8
|
+
for (const concurrency of ['skip', 'queue', 'restart']) {
|
|
9
|
+
it(`retains DOM-ready work during stop (${concurrency})`, async () => {
|
|
10
|
+
const navigationManager = new EventEmitter();
|
|
11
|
+
const page = new EventEmitter();
|
|
12
|
+
page.url = () => 'https://example.test/new';
|
|
13
|
+
const manager = createPageTriggerManager({
|
|
14
|
+
navigationManager,
|
|
15
|
+
log: { debug() {} },
|
|
16
|
+
stopGraceMs: 20,
|
|
17
|
+
});
|
|
18
|
+
const calls = [];
|
|
19
|
+
let release;
|
|
20
|
+
manager.pageTrigger({
|
|
21
|
+
condition: () => true,
|
|
22
|
+
readyOn: 'domcontentloaded',
|
|
23
|
+
concurrency,
|
|
24
|
+
action: async (ctx) => {
|
|
25
|
+
calls.push(ctx.url);
|
|
26
|
+
if (calls.length === 1) {
|
|
27
|
+
await new Promise((resolve) => {
|
|
28
|
+
release = resolve;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
manager.initialize({ page });
|
|
34
|
+
page.emit('domcontentloaded');
|
|
35
|
+
await flush();
|
|
36
|
+
const stopping = manager.stopCurrentAction();
|
|
37
|
+
page.emit('domcontentloaded');
|
|
38
|
+
await stopping;
|
|
39
|
+
assert.equal(calls.length, 1, 'grace expiry must never overlap actions');
|
|
40
|
+
release();
|
|
41
|
+
await flush();
|
|
42
|
+
await flush();
|
|
43
|
+
assert.equal(calls.length, 2, 'ready event survives grace expiry');
|
|
44
|
+
await manager.destroy();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it('queue coalesces repeated events and unregister cancels pending work', async () => {
|
|
49
|
+
const nav = new EventEmitter();
|
|
50
|
+
const page = new EventEmitter();
|
|
51
|
+
page.url = () => 'https://example.test/';
|
|
52
|
+
const manager = createPageTriggerManager({
|
|
53
|
+
navigationManager: nav,
|
|
54
|
+
log: { debug() {} },
|
|
55
|
+
});
|
|
56
|
+
let release;
|
|
57
|
+
let count = 0;
|
|
58
|
+
const remove = manager.pageTrigger({
|
|
59
|
+
condition: () => true,
|
|
60
|
+
concurrency: 'queue',
|
|
61
|
+
action: async () => {
|
|
62
|
+
count++;
|
|
63
|
+
await new Promise((resolve) => {
|
|
64
|
+
release = resolve;
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
manager.initialize({ page });
|
|
69
|
+
nav.emit('onPageReady', { url: page.url() });
|
|
70
|
+
await flush();
|
|
71
|
+
for (let i = 0; i < 20; i++) {
|
|
72
|
+
nav.emit('onPageReady', { url: page.url() });
|
|
73
|
+
}
|
|
74
|
+
await flush();
|
|
75
|
+
remove();
|
|
76
|
+
release();
|
|
77
|
+
await flush();
|
|
78
|
+
assert.equal(count, 1);
|
|
79
|
+
await manager.destroy();
|
|
80
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { count } from '../../../src/elements/visibility.js';
|
|
4
|
+
|
|
5
|
+
it('counts only visible matches when requested', async () => {
|
|
6
|
+
const locator = {
|
|
7
|
+
count: async () => 3,
|
|
8
|
+
evaluateAll: async (fn) =>
|
|
9
|
+
fn([
|
|
10
|
+
{ getClientRects: () => [1], checkVisibility: () => true },
|
|
11
|
+
{ getClientRects: () => [], checkVisibility: () => false },
|
|
12
|
+
{ getClientRects: () => [1], checkVisibility: () => true },
|
|
13
|
+
]),
|
|
14
|
+
};
|
|
15
|
+
assert.equal(
|
|
16
|
+
await count({
|
|
17
|
+
page: { locator: () => locator },
|
|
18
|
+
engine: 'playwright',
|
|
19
|
+
selector: 'div',
|
|
20
|
+
visible: true,
|
|
21
|
+
}),
|
|
22
|
+
2
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { waitForLocatorOrElement } from '../../../src/elements/locators.js';
|
|
4
|
+
import { waitForSelector } from '../../../src/elements/selectors.js';
|
|
5
|
+
|
|
6
|
+
for (const wait of [waitForLocatorOrElement, waitForSelector]) {
|
|
7
|
+
it(`${wait.name} treats a timeout after navigation as interrupted`, async () => {
|
|
8
|
+
let url = 'https://example.test/form';
|
|
9
|
+
const locator = {
|
|
10
|
+
first() {
|
|
11
|
+
return this;
|
|
12
|
+
},
|
|
13
|
+
async waitFor() {
|
|
14
|
+
url = 'https://example.test/sent';
|
|
15
|
+
throw Object.assign(new Error('timeout'), { name: 'TimeoutError' });
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const result = await wait({
|
|
19
|
+
page: { url: () => url, locator: () => locator },
|
|
20
|
+
engine: 'playwright',
|
|
21
|
+
selector: 'button',
|
|
22
|
+
throwOnNavigation: false,
|
|
23
|
+
});
|
|
24
|
+
assert.ok(result === null || result === false);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { clickButton } from '../../../src/interactions/click.js';
|
|
4
|
+
|
|
5
|
+
for (const change of ['url', 'session', 'none']) {
|
|
6
|
+
it(`element timeout detects ${change} changes`, async () => {
|
|
7
|
+
let url = 'https://example.com/form';
|
|
8
|
+
let session = 1;
|
|
9
|
+
const error = Object.assign(new Error('waitFor: Timeout 5000ms exceeded'), {
|
|
10
|
+
name: 'TimeoutError',
|
|
11
|
+
});
|
|
12
|
+
const locator = {
|
|
13
|
+
first: () => locator,
|
|
14
|
+
waitFor: async () => {
|
|
15
|
+
if (change === 'url') {
|
|
16
|
+
url = 'https://example.com/done';
|
|
17
|
+
}
|
|
18
|
+
if (change === 'session') {
|
|
19
|
+
session++;
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const operation = clickButton({
|
|
25
|
+
page: { url: () => url, locator: () => locator },
|
|
26
|
+
engine: 'playwright',
|
|
27
|
+
navigationManager: { getSessionId: () => session },
|
|
28
|
+
selector: '#submit',
|
|
29
|
+
});
|
|
30
|
+
if (change === 'none') {
|
|
31
|
+
await assert.rejects(operation, error);
|
|
32
|
+
} else {
|
|
33
|
+
const result = await operation;
|
|
34
|
+
assert.equal(result.status, 'interrupted');
|
|
35
|
+
assert.equal(result.navigated, true);
|
|
36
|
+
assert.equal(result.dispatched, false);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { dismissOverlays } from '../../../src/interactions/overlays.js';
|
|
4
|
+
|
|
5
|
+
it('dismisses the first visible match and reports errors without failing the interaction', async () => {
|
|
6
|
+
const clicks = [],
|
|
7
|
+
reports = [];
|
|
8
|
+
const targets = [
|
|
9
|
+
{ isVisible: async () => false, click: async () => clicks.push('hidden') },
|
|
10
|
+
{
|
|
11
|
+
isVisible: async () => true,
|
|
12
|
+
click: async () => {
|
|
13
|
+
clicks.push('visible');
|
|
14
|
+
throw new Error('detached');
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
const locator = {
|
|
19
|
+
first: () => targets[0],
|
|
20
|
+
count: async () => targets.length,
|
|
21
|
+
nth: (i) => targets[i],
|
|
22
|
+
};
|
|
23
|
+
await dismissOverlays({
|
|
24
|
+
page: { locator: () => locator },
|
|
25
|
+
engine: 'playwright',
|
|
26
|
+
overlays: ['.dismiss'],
|
|
27
|
+
onDismiss: (report) => reports.push(report),
|
|
28
|
+
});
|
|
29
|
+
assert.deepEqual(clicks, ['visible']);
|
|
30
|
+
assert.equal(reports[0].status, 'error');
|
|
31
|
+
assert.equal(reports[0].error.message, 'detached');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reports successful dismissal and isolates reporting callback errors', async () => {
|
|
35
|
+
let clicked = false;
|
|
36
|
+
const target = {
|
|
37
|
+
isVisible: async () => true,
|
|
38
|
+
click: async () => {
|
|
39
|
+
clicked = true;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const reports = [];
|
|
43
|
+
await dismissOverlays({
|
|
44
|
+
page: { locator: () => ({ count: async () => 1, nth: () => target }) },
|
|
45
|
+
engine: 'playwright',
|
|
46
|
+
overlays: [
|
|
47
|
+
{
|
|
48
|
+
selector: '.dismiss',
|
|
49
|
+
onDismiss: (result) => {
|
|
50
|
+
reports.push(result);
|
|
51
|
+
throw new Error('reporting');
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
assert.equal(clicked, true);
|
|
57
|
+
assert.equal(reports[0].status, 'dismissed');
|
|
58
|
+
});
|
|
@@ -202,6 +202,7 @@ describe('CI execution budgets', () => {
|
|
|
202
202
|
'parity.yml engine-suites Engine lifecycle suites',
|
|
203
203
|
'parity.yml fixture-suites React fixture suites',
|
|
204
204
|
'parity.yml parity Fingerprint parity suite',
|
|
205
|
+
'parity.yml parity Stable viewport displayed-pixel regression',
|
|
205
206
|
'parity.yml parity WebDriver suite',
|
|
206
207
|
'parity.yml snapshots Python headful real-browser launches',
|
|
207
208
|
'parity.yml snapshots Python native snapshot launches',
|
|
@@ -232,6 +232,22 @@ describe('trace bundle writer (issue #87)', () => {
|
|
|
232
232
|
assert.strictEqual(manifest.outcome, TRACE_OUTCOME.COMPLETE);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
it('retains every periodic drain in the same checkpoint interval', async () => {
|
|
236
|
+
const bundle = await open();
|
|
237
|
+
await bundle.writeMutations(1, [{ records: ['first'] }]);
|
|
238
|
+
await bundle.writeMutations(1, [{ records: ['second'] }]);
|
|
239
|
+
const lines = (
|
|
240
|
+
await fs.readFile(path.join(bundle.root, 'mutations/0001.ndjson'), 'utf8')
|
|
241
|
+
)
|
|
242
|
+
.trim()
|
|
243
|
+
.split('\n')
|
|
244
|
+
.map(JSON.parse);
|
|
245
|
+
assert.deepStrictEqual(lines, [
|
|
246
|
+
{ records: ['first'] },
|
|
247
|
+
{ records: ['second'] },
|
|
248
|
+
]);
|
|
249
|
+
});
|
|
250
|
+
|
|
235
251
|
it('should record a dropped member even under a tiny resource limit', async () => {
|
|
236
252
|
// The record of a gap must not be the next thing that falls through it.
|
|
237
253
|
const bundle = await open({ limits: { maxResourceBytes: 16 } });
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { startTrace } from '../../../src/traces/recorder.js';
|
|
7
|
+
import {
|
|
8
|
+
normalizePrivacyOptions,
|
|
9
|
+
redactValue,
|
|
10
|
+
redactHeaders,
|
|
11
|
+
redactUrl,
|
|
12
|
+
} from '../../../src/traces/redaction.js';
|
|
13
|
+
import {
|
|
14
|
+
createFakePage,
|
|
15
|
+
createFakeCommander,
|
|
16
|
+
makeSnapshot,
|
|
17
|
+
} from '../../helpers/trace-fixtures.js';
|
|
18
|
+
|
|
19
|
+
it('redacts JSON, form, multipart fields and credential header/query names', () => {
|
|
20
|
+
const privacy = normalizePrivacyOptions();
|
|
21
|
+
const cases = [
|
|
22
|
+
{
|
|
23
|
+
postData: JSON.stringify({
|
|
24
|
+
password: 'PRIVATE_A',
|
|
25
|
+
nested: { _xsrf: 'PRIVATE_B', otp: 123456 },
|
|
26
|
+
public: 'visible',
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
{ postData: 'public=visible&password=PRIVATE_A&_xsrf=PRIVATE_B' },
|
|
30
|
+
{
|
|
31
|
+
postData:
|
|
32
|
+
'--boundary\r\nContent-Disposition: form-data; name="password"\r\n\r\nPRIVATE_A\r\n--boundary--\r\n',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
body: { data: JSON.stringify({ csrf: 'PRIVATE_B', public: 'visible' }) },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
for (const value of cases) {
|
|
39
|
+
const result = JSON.stringify(redactValue(value, privacy));
|
|
40
|
+
assert.doesNotMatch(result, /PRIVATE_[AB]|123456/);
|
|
41
|
+
}
|
|
42
|
+
assert.equal(
|
|
43
|
+
redactHeaders({ 'X-Xsrftoken': 'PRIVATE_A' }, privacy)['X-Xsrftoken'],
|
|
44
|
+
'[redacted]'
|
|
45
|
+
);
|
|
46
|
+
assert.doesNotMatch(
|
|
47
|
+
redactUrl('https://example.test/?xsrf=PRIVATE_A&csrf=PRIVATE_B', privacy),
|
|
48
|
+
/PRIVATE/
|
|
49
|
+
);
|
|
50
|
+
assert.ok(privacy.redactSelectors.includes('input[type=hidden]'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('redacts checkpoint HTML and mutations before bundle and incremental links writes', async () => {
|
|
54
|
+
const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-private-'));
|
|
55
|
+
const page = createFakePage({
|
|
56
|
+
snapshot: makeSnapshot({ html: '<div>public PRIVATE_SENTINEL</div>' }),
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const trace = await startTrace({
|
|
60
|
+
commander: createFakeCommander(page),
|
|
61
|
+
output,
|
|
62
|
+
mode: 'continuous',
|
|
63
|
+
screenshots: false,
|
|
64
|
+
links: { output: path.join(output, 'trace.lino'), dom: 'full' },
|
|
65
|
+
privacy: { redactPatterns: [/PRIVATE_SENTINEL/g] },
|
|
66
|
+
});
|
|
67
|
+
page.queueMutations([
|
|
68
|
+
{
|
|
69
|
+
records: [
|
|
70
|
+
{
|
|
71
|
+
kind: 'attribute',
|
|
72
|
+
target: { path: '#public' },
|
|
73
|
+
value: 'public PRIVATE_SENTINEL',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
const cp = await trace.checkpoint('public');
|
|
79
|
+
await trace.stop();
|
|
80
|
+
assert.doesNotMatch(
|
|
81
|
+
await fs.readFile(path.join(output, cp.members.html), 'utf8'),
|
|
82
|
+
/PRIVATE_SENTINEL/
|
|
83
|
+
);
|
|
84
|
+
assert.doesNotMatch(
|
|
85
|
+
await fs.readFile(path.join(output, 'trace.lino'), 'utf8'),
|
|
86
|
+
/PRIVATE_SENTINEL/
|
|
87
|
+
);
|
|
88
|
+
const mutations = await fs.readFile(
|
|
89
|
+
path.join(output, 'mutations/0001.ndjson'),
|
|
90
|
+
'utf8'
|
|
91
|
+
);
|
|
92
|
+
assert.doesNotMatch(mutations, /PRIVATE_SENTINEL/);
|
|
93
|
+
assert.match(mutations, /public/);
|
|
94
|
+
} finally {
|
|
95
|
+
await fs.rm(output, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('redacts escaped script values, numeric OTPs and multipart file fields', () => {
|
|
100
|
+
const privacy = normalizePrivacyOptions();
|
|
101
|
+
for (const postData of [
|
|
102
|
+
'<script>const xsrfToken="PRIVATE_A\\"PRIVATE_B"; const otp=123456;</script>',
|
|
103
|
+
'--b\r\nContent-Disposition: form-data; name="password"; filename="public.txt"\r\n\r\nPRIVATE_A\r\n--b--\r\n',
|
|
104
|
+
]) {
|
|
105
|
+
assert.doesNotMatch(
|
|
106
|
+
JSON.stringify(redactValue({ postData }, privacy)),
|
|
107
|
+
/PRIVATE_[AB]|123456/
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
});
|