browser-commander 0.26.3 → 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/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- 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/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- 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 +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- 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/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- 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/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -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/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
installSessionPersistence,
|
|
8
|
+
sessionPersistencePath,
|
|
9
|
+
} from '../../../src/browser/session-persistence.js';
|
|
10
|
+
import {
|
|
11
|
+
findSiteSessions,
|
|
12
|
+
readBrowserCookieSession,
|
|
13
|
+
} from '../../../src/browser/browser-cookie-session.js';
|
|
14
|
+
|
|
15
|
+
it('discovery reads Firefox and Safari through their existing cookie readers', async () => {
|
|
16
|
+
const read = [];
|
|
17
|
+
const sessions = await findSiteSessions(
|
|
18
|
+
{
|
|
19
|
+
domains: ['example.test'],
|
|
20
|
+
sources: ['firefox', 'safari'].map((browser) => ({
|
|
21
|
+
browser,
|
|
22
|
+
path: '/artificial/profile',
|
|
23
|
+
})),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
readCookies: async (options) => {
|
|
27
|
+
read.push(options);
|
|
28
|
+
return [
|
|
29
|
+
{ name: 'session', domain: '.example.test' },
|
|
30
|
+
{ domain: 'notexample.test' },
|
|
31
|
+
];
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
assert.equal(read.length, 2);
|
|
36
|
+
assert.ok(
|
|
37
|
+
read.every(
|
|
38
|
+
(options) => options.via === 'database' && options.cache === false
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
assert.ok(
|
|
42
|
+
sessions.every(
|
|
43
|
+
(session) =>
|
|
44
|
+
session.cookies.length === 1 &&
|
|
45
|
+
session.loggedIn === null &&
|
|
46
|
+
!session.error
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('non-Chromium session validation imports cookies into a disposable engine browser', async () => {
|
|
52
|
+
let seeded;
|
|
53
|
+
let closed = 0;
|
|
54
|
+
const sessions = await findSiteSessions(
|
|
55
|
+
{
|
|
56
|
+
domains: ['example.test'],
|
|
57
|
+
sources: [{ browser: 'firefox', path: '/artificial/profile' }],
|
|
58
|
+
isLoggedIn: async ({ cookies }) => cookies.length === 1,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
readCookies: async () => [
|
|
62
|
+
{
|
|
63
|
+
name: 'session',
|
|
64
|
+
value: 'artificial',
|
|
65
|
+
domain: '.example.test',
|
|
66
|
+
path: '/',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
launch: async (options) => {
|
|
70
|
+
assert.equal(options.launch, 'engine');
|
|
71
|
+
assert.equal(options.userDataDir, undefined);
|
|
72
|
+
return {
|
|
73
|
+
page: {
|
|
74
|
+
context: () => ({
|
|
75
|
+
addCookies: async (cookies) => {
|
|
76
|
+
seeded = cookies;
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
close: async () => closed++,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
assert.equal(seeded.length, 1);
|
|
86
|
+
assert.equal(closed, 1);
|
|
87
|
+
assert.equal(sessions[0].loggedIn, true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('browser reads request cookie-only copies, filter domains and always close', async () => {
|
|
91
|
+
let closed = 0,
|
|
92
|
+
launched;
|
|
93
|
+
const result = {
|
|
94
|
+
page: {
|
|
95
|
+
context: () => ({
|
|
96
|
+
storageState: async () => ({
|
|
97
|
+
cookies: [{ domain: '.example.test' }, { domain: 'notexample.test' }],
|
|
98
|
+
origins: [],
|
|
99
|
+
}),
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
close: async () => closed++,
|
|
103
|
+
};
|
|
104
|
+
const cookies = await readBrowserCookieSession(
|
|
105
|
+
{
|
|
106
|
+
browser: 'chrome',
|
|
107
|
+
profileDir: '/artificial/Default',
|
|
108
|
+
domainFilter: 'example.test',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
launch: async (options) => {
|
|
112
|
+
launched = options;
|
|
113
|
+
return result;
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
assert.equal(cookies.length, 1);
|
|
118
|
+
assert.deepEqual(launched.attach.include, ['cookies']);
|
|
119
|
+
assert.equal(closed, 1);
|
|
120
|
+
});
|
|
121
|
+
it('live validation failure closes the snapshot and records the error', async () => {
|
|
122
|
+
let closed = 0;
|
|
123
|
+
const results = await findSiteSessions(
|
|
124
|
+
{
|
|
125
|
+
domains: ['example.test'],
|
|
126
|
+
sources: [{ browser: 'chrome', path: '/artificial/Default' }],
|
|
127
|
+
isLoggedIn: async () => {
|
|
128
|
+
throw new Error('artificial validation failure');
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
launch: async () => ({
|
|
133
|
+
page: {
|
|
134
|
+
context: () => ({
|
|
135
|
+
storageState: async () => ({ cookies: [], origins: [] }),
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
close: async () => closed++,
|
|
139
|
+
}),
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
assert.equal(closed, 1);
|
|
143
|
+
assert.match(results[0].error, /validation failure/);
|
|
144
|
+
});
|
|
145
|
+
it('persistence requires a dedicated profile and writes only session cookies with restricted permissions', async () => {
|
|
146
|
+
assert.throws(
|
|
147
|
+
() => sessionPersistencePath({ persistSessionCookies: true }),
|
|
148
|
+
/dedicated/
|
|
149
|
+
);
|
|
150
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-session-test-'));
|
|
151
|
+
try {
|
|
152
|
+
let closed = 0;
|
|
153
|
+
const state = {
|
|
154
|
+
cookies: [
|
|
155
|
+
{ name: 'session', expires: -1 },
|
|
156
|
+
{ name: 'persistent', expires: 9999999999 },
|
|
157
|
+
],
|
|
158
|
+
origins: [],
|
|
159
|
+
};
|
|
160
|
+
const page = { context: () => ({ storageState: async () => state }) };
|
|
161
|
+
const result = await installSessionPersistence(
|
|
162
|
+
{ page, browser: {}, close: async () => closed++ },
|
|
163
|
+
{ userDataDir: directory, persistSessionCookies: true }
|
|
164
|
+
);
|
|
165
|
+
await Promise.all([result.close(), result.close()]);
|
|
166
|
+
const file = path.join(directory, 'browser-commander-session.json');
|
|
167
|
+
assert.equal(closed, 1);
|
|
168
|
+
assert.deepEqual(JSON.parse(await readFile(file, 'utf8')).cookies, [
|
|
169
|
+
state.cookies[0],
|
|
170
|
+
]);
|
|
171
|
+
if (process.platform !== 'win32') {
|
|
172
|
+
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await rm(directory, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('session discovery continues after a source close failure', async () => {
|
|
180
|
+
let closed = 0;
|
|
181
|
+
const sessions = await findSiteSessions(
|
|
182
|
+
{
|
|
183
|
+
domains: ['example.test'],
|
|
184
|
+
sources: [
|
|
185
|
+
{ browser: 'chrome', path: '/artificial/Default' },
|
|
186
|
+
{ browser: 'chrome', path: '/artificial/Profile 1' },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
launch: async () => ({
|
|
191
|
+
page: {
|
|
192
|
+
context: () => ({
|
|
193
|
+
storageState: async () => ({ cookies: [], origins: [] }),
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
close: async () => {
|
|
197
|
+
closed++;
|
|
198
|
+
throw new Error('artificial close failure');
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
assert.equal(sessions.length, 2);
|
|
204
|
+
assert.equal(closed, 2);
|
|
205
|
+
assert.ok(sessions.every((session) => /close failure/.test(session.error)));
|
|
206
|
+
});
|
|
@@ -106,6 +106,10 @@ describe('browser storage state', () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
it('delegates Playwright saves to the page context', async () => {
|
|
109
|
+
temporaryDirectory = await mkdtemp(
|
|
110
|
+
path.join(os.tmpdir(), 'browser-commander-playwright-state-')
|
|
111
|
+
);
|
|
112
|
+
const filePath = path.join(temporaryDirectory, 'state.json');
|
|
109
113
|
const savedState = { cookies: [], origins: [] };
|
|
110
114
|
const calls = [];
|
|
111
115
|
const page = {
|
|
@@ -117,8 +121,9 @@ describe('browser storage state', () => {
|
|
|
117
121
|
}),
|
|
118
122
|
};
|
|
119
123
|
|
|
120
|
-
assert.equal(await saveStorageState(page,
|
|
121
|
-
assert.deepEqual(calls, [
|
|
124
|
+
assert.equal(await saveStorageState(page, filePath), savedState);
|
|
125
|
+
assert.deepEqual(calls, [undefined]);
|
|
126
|
+
assert.deepEqual(JSON.parse(await readFile(filePath, 'utf8')), savedState);
|
|
122
127
|
});
|
|
123
128
|
|
|
124
129
|
it('saves Puppeteer cookies and current-origin localStorage', async () => {
|
|
@@ -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
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// feature-parity: navigation.budget@native-typed
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { createNavigationManager } from '../../../src/core/navigation-manager.js';
|
|
5
|
+
import { goto } from '../../../src/browser/navigation.js';
|
|
6
|
+
import { createNetworkTracker } from '../../../src/core/network-tracker.js';
|
|
7
|
+
import {
|
|
8
|
+
createMockPlaywrightPage,
|
|
9
|
+
createMockLogger,
|
|
10
|
+
} from '../../helpers/mocks.js';
|
|
11
|
+
|
|
12
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
const noWait = {
|
|
14
|
+
waitForStableUrlBefore: false,
|
|
15
|
+
waitForStableUrlAfter: false,
|
|
16
|
+
waitForNetworkIdle: false,
|
|
17
|
+
verify: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
it('managed goto honors every no-wait option and keeps tracking', async () => {
|
|
21
|
+
const page = createMockPlaywrightPage();
|
|
22
|
+
let idleCalls = 0;
|
|
23
|
+
const manager = createNavigationManager({
|
|
24
|
+
page,
|
|
25
|
+
engine: 'playwright',
|
|
26
|
+
log: createMockLogger(),
|
|
27
|
+
networkTracker: {
|
|
28
|
+
reset() {},
|
|
29
|
+
waitForNetworkIdle: async () => {
|
|
30
|
+
idleCalls++;
|
|
31
|
+
await delay(80);
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const started = performance.now();
|
|
37
|
+
const result = await goto({
|
|
38
|
+
page,
|
|
39
|
+
navigationManager: manager,
|
|
40
|
+
url: 'data:text/html,<h1>fixture</h1>',
|
|
41
|
+
timeout: 50,
|
|
42
|
+
...noWait,
|
|
43
|
+
});
|
|
44
|
+
assert.equal(result.navigated, true);
|
|
45
|
+
assert.equal(idleCalls, 0);
|
|
46
|
+
assert.ok(performance.now() - started < 70);
|
|
47
|
+
assert.equal(manager.getSessionId(), 1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('engine navigation and stalled readiness share a single budget', async () => {
|
|
51
|
+
const page = createMockPlaywrightPage();
|
|
52
|
+
page.goto = async () => delay(30);
|
|
53
|
+
const manager = createNavigationManager({
|
|
54
|
+
page,
|
|
55
|
+
engine: 'playwright',
|
|
56
|
+
log: createMockLogger(),
|
|
57
|
+
});
|
|
58
|
+
const started = performance.now();
|
|
59
|
+
const result = await goto({
|
|
60
|
+
page,
|
|
61
|
+
navigationManager: manager,
|
|
62
|
+
url: 'data:text/html,fixture',
|
|
63
|
+
timeout: 60,
|
|
64
|
+
...noWait,
|
|
65
|
+
checks: [{ name: 'stall', run: async () => new Promise(() => {}) }],
|
|
66
|
+
});
|
|
67
|
+
assert.equal(result.status, 'timed_out');
|
|
68
|
+
assert.ok(performance.now() - started < 100);
|
|
69
|
+
assert.equal(manager.isNavigating(), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('caller abort promptly interrupts and unregisters its listener', async () => {
|
|
73
|
+
const page = createMockPlaywrightPage();
|
|
74
|
+
page.goto = async () => delay(180);
|
|
75
|
+
const manager = createNavigationManager({
|
|
76
|
+
page,
|
|
77
|
+
engine: 'playwright',
|
|
78
|
+
log: createMockLogger(),
|
|
79
|
+
});
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
let listeners = 0;
|
|
82
|
+
const add = controller.signal.addEventListener.bind(controller.signal);
|
|
83
|
+
const remove = controller.signal.removeEventListener.bind(controller.signal);
|
|
84
|
+
controller.signal.addEventListener = (...args) => {
|
|
85
|
+
listeners++;
|
|
86
|
+
return add(...args);
|
|
87
|
+
};
|
|
88
|
+
controller.signal.removeEventListener = (...args) => {
|
|
89
|
+
listeners--;
|
|
90
|
+
return remove(...args);
|
|
91
|
+
};
|
|
92
|
+
const pending = goto({
|
|
93
|
+
page,
|
|
94
|
+
navigationManager: manager,
|
|
95
|
+
url: 'data:text/html,fixture',
|
|
96
|
+
timeout: 1000,
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
...noWait,
|
|
99
|
+
});
|
|
100
|
+
setTimeout(() => controller.abort(), 20);
|
|
101
|
+
const result = await pending;
|
|
102
|
+
assert.equal(result.status, 'interrupted');
|
|
103
|
+
assert.equal(listeners, 0);
|
|
104
|
+
assert.equal(manager.isNavigating(), false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('an idle network wait respects a shorter timeout than its quiet period', async () => {
|
|
108
|
+
const page = createMockPlaywrightPage();
|
|
109
|
+
const tracker = createNetworkTracker({
|
|
110
|
+
page,
|
|
111
|
+
engine: 'playwright',
|
|
112
|
+
log: createMockLogger(),
|
|
113
|
+
});
|
|
114
|
+
const started = performance.now();
|
|
115
|
+
assert.equal(
|
|
116
|
+
await tracker.waitForNetworkIdle({ timeout: 30, idleTime: 100 }),
|
|
117
|
+
false
|
|
118
|
+
);
|
|
119
|
+
assert.ok(performance.now() - started < 80);
|
|
120
|
+
});
|