browser-commander 0.27.0 → 0.28.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/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/connector.js +117 -13
- 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 +360 -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-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -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 +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -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 +51 -0
- package/src/core/page-trigger-manager.js +115 -30
- 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 +2 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +28 -0
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -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/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/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -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/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { closeRemoteBrowser } from '../../../src/browser/persistent-session.js';
|
|
4
|
+
|
|
5
|
+
function websocket(context) {
|
|
6
|
+
const sockets = [];
|
|
7
|
+
class Socket {
|
|
8
|
+
constructor() {
|
|
9
|
+
sockets.push(this);
|
|
10
|
+
globalThis.queueMicrotask(() => this.onopen());
|
|
11
|
+
}
|
|
12
|
+
send(message) {
|
|
13
|
+
this.request = JSON.parse(message);
|
|
14
|
+
}
|
|
15
|
+
close() {
|
|
16
|
+
this.clientClosed = true;
|
|
17
|
+
this.onclose();
|
|
18
|
+
}
|
|
19
|
+
message(value) {
|
|
20
|
+
this.onmessage({ data: JSON.stringify(value) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function createSocket() {
|
|
24
|
+
return new Socket();
|
|
25
|
+
}
|
|
26
|
+
context.mock.method(globalThis, 'WebSocket', createSocket);
|
|
27
|
+
return sockets;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('waits for browser shutdown rather than closing on unrelated CDP events', async (context) => {
|
|
31
|
+
const sockets = websocket(context);
|
|
32
|
+
const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
|
|
33
|
+
const socket = sockets[0];
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
socket.message({ method: 'Target.targetCreated', params: {} });
|
|
36
|
+
const prematurelyClosed = socket.clientClosed;
|
|
37
|
+
socket.message({ id: socket.request.id, result: {} });
|
|
38
|
+
socket.onclose();
|
|
39
|
+
await closing;
|
|
40
|
+
assert.equal(prematurelyClosed, undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports CDP shutdown errors instead of claiming the browser closed', async (context) => {
|
|
44
|
+
const sockets = websocket(context);
|
|
45
|
+
const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
|
|
46
|
+
await Promise.resolve();
|
|
47
|
+
sockets[0].message({ id: 1, error: { message: 'shutdown refused' } });
|
|
48
|
+
await assert.rejects(closing, /shutdown refused/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts Chrome disconnecting abruptly after its shutdown acknowledgement', async (context) => {
|
|
52
|
+
const sockets = websocket(context);
|
|
53
|
+
const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
sockets[0].message({ id: 1, result: {} });
|
|
56
|
+
sockets[0].onerror();
|
|
57
|
+
sockets[0].onclose();
|
|
58
|
+
await closing;
|
|
59
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolveRestrictions } from '../../../src/browser/restrictions.js';
|
|
4
|
+
|
|
5
|
+
it('quiet-ui merges caller features and disables Translate in preferences', () => {
|
|
6
|
+
const result = resolveRestrictions(['quiet-ui'], {
|
|
7
|
+
disableFeatures: ['CustomFeature', 'Translate'],
|
|
8
|
+
});
|
|
9
|
+
const switches = result.args.filter((arg) =>
|
|
10
|
+
arg.startsWith('--disable-features=')
|
|
11
|
+
);
|
|
12
|
+
assert.equal(switches.length, 1);
|
|
13
|
+
assert.ok(switches[0].includes('SessionRestoreInfobar'));
|
|
14
|
+
assert.ok(switches[0].includes('CustomFeature'));
|
|
15
|
+
assert.equal(result.preferences.translate.enabled, false);
|
|
16
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { pickForegroundPage } from '../../../src/browser/connector.js';
|
|
4
|
+
|
|
5
|
+
it('explicit URL selection wins over emulated visibility', async () => {
|
|
6
|
+
const pages = ['background', 'automation'].map((name) => ({
|
|
7
|
+
url: () => `https://example.com/${name}`,
|
|
8
|
+
evaluate: async () => 'visible',
|
|
9
|
+
}));
|
|
10
|
+
assert.equal(
|
|
11
|
+
await pickForegroundPage(pages, { url: /automation/ }),
|
|
12
|
+
pages[1]
|
|
13
|
+
);
|
|
14
|
+
await assert.rejects(pickForegroundPage(pages, { url: /missing/ }), /No tab/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('focus emulation is disabled before reading foreground visibility', async () => {
|
|
18
|
+
let emulated = true;
|
|
19
|
+
const session = {
|
|
20
|
+
send: async (method) => {
|
|
21
|
+
if (method === 'Emulation.setFocusEmulationEnabled') {
|
|
22
|
+
emulated = false;
|
|
23
|
+
}
|
|
24
|
+
return { targetInfo: { targetId: 'background' } };
|
|
25
|
+
},
|
|
26
|
+
detach: async () => {},
|
|
27
|
+
};
|
|
28
|
+
const context = { newCDPSession: async () => session };
|
|
29
|
+
const background = {
|
|
30
|
+
context: () => context,
|
|
31
|
+
evaluate: async () => (emulated ? 'visible' : 'hidden'),
|
|
32
|
+
};
|
|
33
|
+
const foreground = {
|
|
34
|
+
context: () => context,
|
|
35
|
+
evaluate: async () => 'visible',
|
|
36
|
+
};
|
|
37
|
+
assert.equal(await pickForegroundPage([background, foreground]), foreground);
|
|
38
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import UPNG from 'upng-js';
|
|
4
|
+
import {
|
|
5
|
+
screenshot,
|
|
6
|
+
encodeAnimation,
|
|
7
|
+
UnsupportedCaptureError,
|
|
8
|
+
} from '../../../src/capture/index.js';
|
|
9
|
+
|
|
10
|
+
const png = (color) =>
|
|
11
|
+
Buffer.from(
|
|
12
|
+
UPNG.encode([new Uint8Array(color).buffer], 1, 1, 0, undefined, true)
|
|
13
|
+
);
|
|
14
|
+
it('screenshots honor WebP format without handing unsupported options to the engine', async () => {
|
|
15
|
+
const page = {
|
|
16
|
+
screenshot: async (options) => {
|
|
17
|
+
assert.equal(options.type, 'png');
|
|
18
|
+
return png([255, 0, 0, 255]);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const result = await screenshot({
|
|
22
|
+
page,
|
|
23
|
+
engine: 'playwright',
|
|
24
|
+
format: 'webp',
|
|
25
|
+
quality: 80,
|
|
26
|
+
});
|
|
27
|
+
assert.equal(result.subarray(8, 12).toString(), 'WEBP');
|
|
28
|
+
});
|
|
29
|
+
it('animation encoders produce multiple frames with no external binary', async () => {
|
|
30
|
+
const frames = [png([255, 0, 0, 255]), png([0, 0, 255, 255])];
|
|
31
|
+
assert.equal(
|
|
32
|
+
(await encodeAnimation(frames, { format: 'gif', fps: 5 }))
|
|
33
|
+
.subarray(0, 6)
|
|
34
|
+
.toString(),
|
|
35
|
+
'GIF89a'
|
|
36
|
+
);
|
|
37
|
+
const apng = await encodeAnimation(frames, { format: 'apng', fps: 5 });
|
|
38
|
+
assert.equal(
|
|
39
|
+
UPNG.decode(
|
|
40
|
+
apng.buffer.slice(apng.byteOffset, apng.byteOffset + apng.length)
|
|
41
|
+
).frames.length,
|
|
42
|
+
2
|
|
43
|
+
);
|
|
44
|
+
const webp = await encodeAnimation(frames, { format: 'webp', fps: 5 });
|
|
45
|
+
assert.equal(webp.toString('latin1').split('ANMF').length - 1, 2);
|
|
46
|
+
});
|
|
47
|
+
it('rejects unsupported options with a typed capability error', async () => {
|
|
48
|
+
await assert.rejects(
|
|
49
|
+
screenshot({ page: {}, engine: 'unknown' }),
|
|
50
|
+
UnsupportedCaptureError
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createTraceStop } from '../../../src/traces/stop.js';
|
|
4
|
+
|
|
5
|
+
it('failed video export still releases the trace resources', async () => {
|
|
6
|
+
const released = [];
|
|
7
|
+
const stop = createTraceStop({
|
|
8
|
+
getStopped: () => false,
|
|
9
|
+
setStopped: () => {},
|
|
10
|
+
options: {},
|
|
11
|
+
detachers: [],
|
|
12
|
+
navigationCapture: async () => {},
|
|
13
|
+
video: {
|
|
14
|
+
stop: async () => {
|
|
15
|
+
throw new Error('encoder failed');
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
detachNetwork: async () => released.push('network'),
|
|
19
|
+
mutations: { stop: async () => released.push('mutations') },
|
|
20
|
+
bundle: { abort: async () => released.push('bundle') },
|
|
21
|
+
});
|
|
22
|
+
await assert.rejects(stop(), /encoder failed/);
|
|
23
|
+
assert.deepEqual(released, ['network', 'mutations', 'bundle']);
|
|
24
|
+
});
|
|
@@ -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
|
|
|
@@ -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,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
|
+
}
|
|
@@ -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,97 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { startTrace } from '../../../src/traces/recorder.js';
|
|
7
|
+
import { UnsupportedCaptureError } from '../../../src/capture/errors.js';
|
|
8
|
+
import {
|
|
9
|
+
createFakeCommander,
|
|
10
|
+
createFakePage,
|
|
11
|
+
} from '../../helpers/trace-fixtures.js';
|
|
12
|
+
|
|
13
|
+
it('records filtered network metadata and bounded redacted bodies in HAR', async () => {
|
|
14
|
+
const output = await fs.mkdtemp(path.join(os.tmpdir(), 'commander-network-'));
|
|
15
|
+
const page = createFakePage();
|
|
16
|
+
try {
|
|
17
|
+
const trace = await startTrace({
|
|
18
|
+
commander: createFakeCommander(page),
|
|
19
|
+
output,
|
|
20
|
+
initialCheckpoint: false,
|
|
21
|
+
network: {
|
|
22
|
+
resourceTypes: ['fetch'],
|
|
23
|
+
bodies: true,
|
|
24
|
+
maxBodyBytes: 4,
|
|
25
|
+
har: true,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const request = {
|
|
29
|
+
url: () => 'https://example.test/api',
|
|
30
|
+
method: () => 'GET',
|
|
31
|
+
resourceType: () => 'fetch',
|
|
32
|
+
headers: () => ({
|
|
33
|
+
authorization: 'secret',
|
|
34
|
+
cookie: 'session',
|
|
35
|
+
accept: 'text/plain',
|
|
36
|
+
}),
|
|
37
|
+
timing: () => ({ startTime: Date.now(), responseEnd: 10 }),
|
|
38
|
+
};
|
|
39
|
+
page.emit('request', request);
|
|
40
|
+
page.emit('response', {
|
|
41
|
+
request: () => request,
|
|
42
|
+
url: request.url,
|
|
43
|
+
status: () => 200,
|
|
44
|
+
headers: () => ({ 'set-cookie': 'secret', 'content-type': 'text/plain' }),
|
|
45
|
+
body: async () => Buffer.from('abcdef'),
|
|
46
|
+
});
|
|
47
|
+
await trace.stop();
|
|
48
|
+
const events = await fs.readFile(
|
|
49
|
+
path.join(output, 'events.ndjson'),
|
|
50
|
+
'utf8'
|
|
51
|
+
);
|
|
52
|
+
assert.match(events, /network.response/);
|
|
53
|
+
assert.doesNotMatch(events, /secret|session/);
|
|
54
|
+
const har = JSON.parse(
|
|
55
|
+
await fs.readFile(path.join(output, 'network.har'), 'utf8')
|
|
56
|
+
);
|
|
57
|
+
assert.equal(har.log.entries.length, 1);
|
|
58
|
+
assert.equal(har.log.entries[0].response.content.text, 'YWJjZA==');
|
|
59
|
+
assert.equal(har.log.entries[0].response.content._truncated, true);
|
|
60
|
+
} finally {
|
|
61
|
+
await fs.rm(output, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejected video startup removes every installed observer', async () => {
|
|
66
|
+
const output = await fs.mkdtemp(path.join(os.tmpdir(), 'commander-start-'));
|
|
67
|
+
const page = createFakePage();
|
|
68
|
+
const listeners = new Set();
|
|
69
|
+
const evaluate = page.evaluate.bind(page);
|
|
70
|
+
page.evaluate = (fn, argument) =>
|
|
71
|
+
argument === 'mov' ? Promise.resolve(false) : evaluate(fn, argument);
|
|
72
|
+
const on = page.on.bind(page);
|
|
73
|
+
const off = page.off.bind(page);
|
|
74
|
+
page.on = (event, listener) => {
|
|
75
|
+
listeners.add(listener);
|
|
76
|
+
return on(event, listener);
|
|
77
|
+
};
|
|
78
|
+
page.off = (event, listener) => {
|
|
79
|
+
listeners.delete(listener);
|
|
80
|
+
return off(event, listener);
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
await assert.rejects(
|
|
84
|
+
startTrace({
|
|
85
|
+
commander: createFakeCommander(page),
|
|
86
|
+
output,
|
|
87
|
+
links: { output: path.join(output, 'trace.lino') },
|
|
88
|
+
video: { format: 'mov' },
|
|
89
|
+
network: true,
|
|
90
|
+
}),
|
|
91
|
+
UnsupportedCaptureError
|
|
92
|
+
);
|
|
93
|
+
assert.equal(listeners.size, 0);
|
|
94
|
+
} finally {
|
|
95
|
+
await fs.rm(output, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|