browser-commander 0.17.1 → 0.18.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 +7 -18
- package/CHANGELOG.md +48 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/scripts/version-and-commit.mjs +13 -2
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/scripts/check-release-format.test.js +4 -1
- package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
- package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +289 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-specific download event sources (issue #88).
|
|
3
|
+
*
|
|
4
|
+
* Each source turns one engine's download notifications into the same three
|
|
5
|
+
* calls - `started`, `finished`, `failed` - so the manager above them does not
|
|
6
|
+
* know or care whether the bytes came from Playwright or from CDP, and a
|
|
7
|
+
* download a human started by hand is reported exactly like an automated one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
import { ARTIFACT_DIRECTORY_MODE } from './destination.js';
|
|
14
|
+
|
|
15
|
+
/** Why a download ended without a file. */
|
|
16
|
+
export const DOWNLOAD_FAILURE = Object.freeze({
|
|
17
|
+
CANCELLED: 'cancelled',
|
|
18
|
+
FAILED: 'failed',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Directory the engine writes into before we place the file. */
|
|
22
|
+
export const STAGING_DIRECTORY = '.browser-commander-staging';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Classify an engine's failure text.
|
|
26
|
+
*
|
|
27
|
+
* "A download UI entry is not completion evidence" (issue #88): the engine's
|
|
28
|
+
* own words are the only thing that distinguishes a user cancelling a download
|
|
29
|
+
* from a network error, so they are preserved and classified, not flattened.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} [reason] - Engine-reported reason
|
|
32
|
+
* @returns {string} One of {@link DOWNLOAD_FAILURE}
|
|
33
|
+
*/
|
|
34
|
+
export function classifyFailure(reason) {
|
|
35
|
+
return /cancel/i.test(String(reason ?? ''))
|
|
36
|
+
? DOWNLOAD_FAILURE.CANCELLED
|
|
37
|
+
: DOWNLOAD_FAILURE.FAILED;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Attach to Playwright's context-level download event.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} options - Source options
|
|
44
|
+
* @param {Object} options.context - Playwright browser context
|
|
45
|
+
* @param {Object} options.sink - `{started, finished, failed}` callbacks
|
|
46
|
+
* @returns {Object} Handle with `detach()`
|
|
47
|
+
*/
|
|
48
|
+
export function attachPlaywrightSource({ context, sink }) {
|
|
49
|
+
const onDownload = async (download) => {
|
|
50
|
+
const record = sink.started({
|
|
51
|
+
engineHandle: download,
|
|
52
|
+
url: download.url(),
|
|
53
|
+
suggestedFilename: download.suggestedFilename(),
|
|
54
|
+
page: download.page?.(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const failure = await download.failure();
|
|
59
|
+
if (failure) {
|
|
60
|
+
sink.failed(record, classifyFailure(failure), failure);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// `path()` resolves only once the bytes are on disk, which is the
|
|
65
|
+
// engine's own evidence that the download completed.
|
|
66
|
+
const enginePath = await download.path();
|
|
67
|
+
sink.finished(record, { path: enginePath });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
sink.failed(record, classifyFailure(error.message), error.message);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
context.on('download', onDownload);
|
|
74
|
+
return {
|
|
75
|
+
detach: () => context.off('download', onDownload),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Point Chromium at a staging directory and report every download it starts.
|
|
81
|
+
*
|
|
82
|
+
* This is the only source that sees a download a *person* started, because
|
|
83
|
+
* `Browser.setDownloadBehavior` is browser-wide rather than per-automation.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} options - Source options
|
|
86
|
+
* @param {Object} options.session - CDP session with Browser domain access
|
|
87
|
+
* @param {string} options.root - Managed download directory
|
|
88
|
+
* @param {Object} options.sink - `{started, finished, failed}` callbacks
|
|
89
|
+
* @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
|
|
90
|
+
*/
|
|
91
|
+
export async function attachCdpSource({ session, root, sink }) {
|
|
92
|
+
const stagingDirectory = path.join(root, STAGING_DIRECTORY);
|
|
93
|
+
await fs.mkdir(stagingDirectory, {
|
|
94
|
+
recursive: true,
|
|
95
|
+
mode: ARTIFACT_DIRECTORY_MODE,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// `allowAndName` writes each file under its GUID, so two downloads that
|
|
99
|
+
// suggest the same name cannot overwrite each other before we have placed
|
|
100
|
+
// them, and the GUID is the identity we deduplicate on.
|
|
101
|
+
await session.send('Browser.setDownloadBehavior', {
|
|
102
|
+
behavior: 'allowAndName',
|
|
103
|
+
downloadPath: stagingDirectory,
|
|
104
|
+
eventsEnabled: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const byGuid = new Map();
|
|
108
|
+
|
|
109
|
+
const onWillBegin = ({ guid, url, suggestedFilename }) => {
|
|
110
|
+
byGuid.set(
|
|
111
|
+
guid,
|
|
112
|
+
sink.started({
|
|
113
|
+
engineHandle: guid,
|
|
114
|
+
url,
|
|
115
|
+
suggestedFilename,
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const onProgress = ({ guid, state }) => {
|
|
121
|
+
const record = byGuid.get(guid);
|
|
122
|
+
if (!record || state === 'inProgress') {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
byGuid.delete(guid);
|
|
127
|
+
if (state === 'completed') {
|
|
128
|
+
sink.finished(record, {
|
|
129
|
+
path: path.join(stagingDirectory, guid),
|
|
130
|
+
removeSource: true,
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
sink.failed(
|
|
135
|
+
record,
|
|
136
|
+
DOWNLOAD_FAILURE.CANCELLED,
|
|
137
|
+
`download ${state} by the browser`
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
session.on('Browser.downloadWillBegin', onWillBegin);
|
|
142
|
+
session.on('Browser.downloadProgress', onProgress);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
stagingDirectory,
|
|
146
|
+
detach: () => {
|
|
147
|
+
session.off?.('Browser.downloadWillBegin', onWillBegin);
|
|
148
|
+
session.off?.('Browser.downloadProgress', onProgress);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Open a CDP session that can drive the Browser domain.
|
|
155
|
+
*
|
|
156
|
+
* Both engines can open a session that is not tied to a single page, which is
|
|
157
|
+
* what the Browser domain needs: one session then covers every page and every
|
|
158
|
+
* download no page started.
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} options - Session options
|
|
161
|
+
* @param {string} options.engine - Engine name
|
|
162
|
+
* @param {Object} [options.browser] - Browser or persistent context
|
|
163
|
+
* @param {Object} [options.page] - Page to borrow a session from
|
|
164
|
+
* @returns {Promise<Object>|undefined} CDP session, or undefined when unavailable
|
|
165
|
+
*/
|
|
166
|
+
export function openBrowserCdpSession({ engine, browser, page }) {
|
|
167
|
+
if (engine === 'puppeteer') {
|
|
168
|
+
const target = browser?.target?.();
|
|
169
|
+
if (target?.createCDPSession) {
|
|
170
|
+
return target.createCDPSession();
|
|
171
|
+
}
|
|
172
|
+
return page?.target?.().createCDPSession?.();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A page-scoped session only hears about the downloads that page started,
|
|
176
|
+
// so a browser-wide session is what makes a download a *person* began - in a
|
|
177
|
+
// tab the automation never opened - observable at all.
|
|
178
|
+
const browserHandle = browser?.browser?.() ?? browser;
|
|
179
|
+
if (browserHandle?.newBrowserCDPSession) {
|
|
180
|
+
return browserHandle.newBrowserCDPSession();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const context = browser?.browserContext?.() ?? browser;
|
|
184
|
+
if (page && context?.newCDPSession) {
|
|
185
|
+
return context.newCDPSession(page);
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing a download into the managed directory (issue #88).
|
|
3
|
+
*
|
|
4
|
+
* The rule the whole module exists to keep: a file that appears under its
|
|
5
|
+
* final name is complete and has passed validation. Everything else lives
|
|
6
|
+
* under a `.partial` name and is removed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { pipeline } from 'node:stream/promises';
|
|
14
|
+
|
|
15
|
+
import { ARTIFACT_DIRECTORY_MODE, ARTIFACT_FILE_MODE } from './destination.js';
|
|
16
|
+
import {
|
|
17
|
+
renamedCandidate,
|
|
18
|
+
resolveInsideRoot,
|
|
19
|
+
sanitizeDownloadName,
|
|
20
|
+
withExtension,
|
|
21
|
+
} from './naming.js';
|
|
22
|
+
|
|
23
|
+
/** How a name that is already taken is resolved. */
|
|
24
|
+
export const DOWNLOAD_CONFLICT = Object.freeze({
|
|
25
|
+
RENAME: 'rename',
|
|
26
|
+
OVERWRITE: 'overwrite',
|
|
27
|
+
ERROR: 'error',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** How many bytes are kept to sniff a format from. */
|
|
31
|
+
const MAGIC_BYTES = 8;
|
|
32
|
+
|
|
33
|
+
/** Highest rename attempt before giving up rather than looping forever. */
|
|
34
|
+
const MAX_RENAME_ATTEMPTS = 1000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Open the download's bytes as a readable stream.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} source - Either `{stream}` or `{path}`
|
|
40
|
+
* @returns {Object} Readable stream
|
|
41
|
+
*/
|
|
42
|
+
function openSource(source) {
|
|
43
|
+
if (source.stream) {
|
|
44
|
+
return source.stream;
|
|
45
|
+
}
|
|
46
|
+
if (source.path) {
|
|
47
|
+
return createReadStream(source.path);
|
|
48
|
+
}
|
|
49
|
+
throw new TypeError('a download source needs either a stream or a path');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stream the download into a partial file, hashing it on the way through.
|
|
54
|
+
*
|
|
55
|
+
* Hashing during the copy means the bytes are read once: reading the file a
|
|
56
|
+
* second time to checksum it would double the I/O and leave a window where the
|
|
57
|
+
* file could change between the two reads.
|
|
58
|
+
*
|
|
59
|
+
* @param {Object} options - Copy options
|
|
60
|
+
* @param {Object} options.source - `{stream}` or `{path}` to read from
|
|
61
|
+
* @param {string} options.partialPath - Where the bytes are written
|
|
62
|
+
* @returns {Promise<{bytes: number, checksum: string, head: Buffer}>} What was written
|
|
63
|
+
*/
|
|
64
|
+
async function copyToPartial({ source, partialPath }) {
|
|
65
|
+
const hash = createHash('sha256');
|
|
66
|
+
let bytes = 0;
|
|
67
|
+
let head = Buffer.alloc(0);
|
|
68
|
+
|
|
69
|
+
const readable = openSource(source);
|
|
70
|
+
const writable = createWriteStream(partialPath, { mode: ARTIFACT_FILE_MODE });
|
|
71
|
+
|
|
72
|
+
await pipeline(
|
|
73
|
+
readable,
|
|
74
|
+
async function* (chunks) {
|
|
75
|
+
for await (const chunk of chunks) {
|
|
76
|
+
hash.update(chunk);
|
|
77
|
+
bytes += chunk.length;
|
|
78
|
+
if (head.length < MAGIC_BYTES) {
|
|
79
|
+
head = Buffer.concat([head, chunk.subarray(0, MAGIC_BYTES)]).subarray(
|
|
80
|
+
0,
|
|
81
|
+
MAGIC_BYTES
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
yield chunk;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
writable
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { bytes, checksum: hash.digest('hex'), head };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Report whether a path already exists.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} candidate - Absolute path
|
|
97
|
+
* @returns {Promise<boolean>} Whether something is there
|
|
98
|
+
*/
|
|
99
|
+
async function exists(candidate) {
|
|
100
|
+
try {
|
|
101
|
+
await fs.access(candidate);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Choose the final path for a completed download.
|
|
110
|
+
*
|
|
111
|
+
* @param {Object} options - Placement options
|
|
112
|
+
* @param {string} options.root - Absolute download directory
|
|
113
|
+
* @param {string} options.name - Sanitized file name
|
|
114
|
+
* @param {string} options.conflict - Conflict policy
|
|
115
|
+
* @returns {Promise<string>} Absolute final path
|
|
116
|
+
* @throws {Error} When the name is taken and the policy says to fail
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveFinalPath({ root, name, conflict }) {
|
|
119
|
+
if (conflict === DOWNLOAD_CONFLICT.OVERWRITE) {
|
|
120
|
+
return resolveInsideRoot(root, name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt += 1) {
|
|
124
|
+
const candidate = resolveInsideRoot(root, renamedCandidate(name, attempt));
|
|
125
|
+
if (!(await exists(candidate))) {
|
|
126
|
+
return candidate;
|
|
127
|
+
}
|
|
128
|
+
if (conflict === DOWNLOAD_CONFLICT.ERROR) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`refusing to replace ${candidate}: downloads.conflict is 'error'`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw new Error(
|
|
136
|
+
`could not find a free name for "${name}" after ${MAX_RENAME_ATTEMPTS} attempts`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Save a download into the managed directory.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} options - Save options
|
|
144
|
+
* @param {string} options.root - Absolute download directory
|
|
145
|
+
* @param {Object} options.source - `{stream}` or `{path}` to read from
|
|
146
|
+
* @param {string} options.suggestedFilename - Name the page suggested
|
|
147
|
+
* @param {string} [options.mimeType] - MIME type declared by the server
|
|
148
|
+
* @param {string} [options.conflict] - Conflict policy
|
|
149
|
+
* @param {Function} [options.filename] - Caller naming callback
|
|
150
|
+
* @param {Function} [options.validate] - Validation run before the file is
|
|
151
|
+
* published; throwing or returning `false` rejects the download
|
|
152
|
+
* @returns {Promise<{path: string, bytes: number, checksum: string}>} What was saved
|
|
153
|
+
*/
|
|
154
|
+
export async function saveDownload({
|
|
155
|
+
root,
|
|
156
|
+
source,
|
|
157
|
+
suggestedFilename,
|
|
158
|
+
mimeType,
|
|
159
|
+
conflict = DOWNLOAD_CONFLICT.RENAME,
|
|
160
|
+
filename,
|
|
161
|
+
validate,
|
|
162
|
+
}) {
|
|
163
|
+
await fs.mkdir(root, { recursive: true, mode: ARTIFACT_DIRECTORY_MODE });
|
|
164
|
+
|
|
165
|
+
// A caller callback runs *before* sanitization, never instead of it: it is a
|
|
166
|
+
// naming preference, not a grant of write access outside the root.
|
|
167
|
+
const chosen = filename
|
|
168
|
+
? await filename({ suggestedFilename, mimeType })
|
|
169
|
+
: suggestedFilename;
|
|
170
|
+
const safeName = sanitizeDownloadName(chosen ?? suggestedFilename);
|
|
171
|
+
|
|
172
|
+
const partialPath = resolveInsideRoot(
|
|
173
|
+
root,
|
|
174
|
+
`${safeName}.${process.pid}.${Date.now()}.partial`
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
let written;
|
|
178
|
+
try {
|
|
179
|
+
written = await copyToPartial({ source, partialPath });
|
|
180
|
+
|
|
181
|
+
if (validate) {
|
|
182
|
+
// Validation sees the partial file, so a rejected download never exists
|
|
183
|
+
// under the name a caller would pick it up by.
|
|
184
|
+
const verdict = await validate({
|
|
185
|
+
path: partialPath,
|
|
186
|
+
bytes: written.bytes,
|
|
187
|
+
checksum: written.checksum,
|
|
188
|
+
mimeType,
|
|
189
|
+
suggestedFilename,
|
|
190
|
+
});
|
|
191
|
+
// A predicate that returns `false` rejects just as loudly as one that
|
|
192
|
+
// throws; anything else (including `undefined`) accepts, so a validator
|
|
193
|
+
// written only for its side effects still works.
|
|
194
|
+
if (verdict === false) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`"${safeName}" was rejected by the caller's validation`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await fs.rm(partialPath, { force: true });
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const finalName = withExtension({
|
|
206
|
+
name: safeName,
|
|
207
|
+
mimeType,
|
|
208
|
+
head: written.head,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const finalPath = await resolveFinalPath({
|
|
213
|
+
root,
|
|
214
|
+
name: finalName,
|
|
215
|
+
conflict,
|
|
216
|
+
});
|
|
217
|
+
// Rename rather than copy: within one filesystem it is atomic, so a reader
|
|
218
|
+
// watching the directory never sees a half-written file under this name.
|
|
219
|
+
await fs.rename(partialPath, finalPath);
|
|
220
|
+
await fs.chmod(finalPath, ARTIFACT_FILE_MODE);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
path: finalPath,
|
|
224
|
+
bytes: written.bytes,
|
|
225
|
+
checksum: written.checksum,
|
|
226
|
+
};
|
|
227
|
+
} catch (error) {
|
|
228
|
+
await fs.rm(partialPath, { force: true });
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove every partial file left behind in a download directory.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} root - Absolute download directory
|
|
237
|
+
* @returns {Promise<string[]>} Paths that were removed
|
|
238
|
+
*/
|
|
239
|
+
export async function cleanPartials(root) {
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = await fs.readdir(root);
|
|
243
|
+
} catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const removed = [];
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
if (entry.endsWith('.partial')) {
|
|
250
|
+
const candidate = path.join(root, entry);
|
|
251
|
+
await fs.rm(candidate, { force: true });
|
|
252
|
+
removed.push(candidate);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return removed;
|
|
256
|
+
}
|
package/src/exports.js
CHANGED
|
@@ -60,6 +60,7 @@ export {
|
|
|
60
60
|
setContent,
|
|
61
61
|
waitForNavigation,
|
|
62
62
|
waitForPageReady,
|
|
63
|
+
waitForReady,
|
|
63
64
|
waitAfterAction,
|
|
64
65
|
// Navigation verification
|
|
65
66
|
defaultNavigationVerification,
|
|
@@ -115,8 +116,31 @@ export {
|
|
|
115
116
|
defaultClickVerification,
|
|
116
117
|
capturePreClickState,
|
|
117
118
|
verifyClick,
|
|
119
|
+
// Truthful click result model
|
|
120
|
+
CLICK_STATUS,
|
|
121
|
+
CLICK_EFFECT,
|
|
122
|
+
// Orthogonal activation options
|
|
123
|
+
CLICK_ACTIVATION,
|
|
124
|
+
CLICK_SCROLL,
|
|
125
|
+
CLICK_ACTIONABILITY,
|
|
126
|
+
ScrollConstraintError,
|
|
118
127
|
} from './interactions/click.js';
|
|
119
128
|
|
|
129
|
+
export {
|
|
130
|
+
READINESS_STATUS,
|
|
131
|
+
LONG_LIVED_REQUEST_PATTERNS,
|
|
132
|
+
isLongLivedRequest,
|
|
133
|
+
createDeadline,
|
|
134
|
+
runReadinessChecks,
|
|
135
|
+
// Composable readiness checks
|
|
136
|
+
urlStableFor,
|
|
137
|
+
networkIdleFor,
|
|
138
|
+
domStableFor,
|
|
139
|
+
visibleImages,
|
|
140
|
+
predicate,
|
|
141
|
+
stableCheck,
|
|
142
|
+
} from './core/readiness.js';
|
|
143
|
+
|
|
120
144
|
export {
|
|
121
145
|
checkIfElementEmpty,
|
|
122
146
|
performFill,
|
|
@@ -173,3 +197,51 @@ export {
|
|
|
173
197
|
findFingerprintLimitation,
|
|
174
198
|
relevantFingerprintLimitations,
|
|
175
199
|
} from './fingerprint/limitations.js';
|
|
200
|
+
|
|
201
|
+
// Re-export the managed download subsystem: one lifecycle for automated and
|
|
202
|
+
// manual downloads, with files that outlive the browser (issue #88).
|
|
203
|
+
export {
|
|
204
|
+
attachDownloads,
|
|
205
|
+
normalizeDownloadOptions,
|
|
206
|
+
} from './downloads/attach.js';
|
|
207
|
+
export { DOWNLOAD_EVENT, createDownloadManager } from './downloads/manager.js';
|
|
208
|
+
export {
|
|
209
|
+
ARTIFACT_DIRECTORY_MODE,
|
|
210
|
+
ARTIFACT_FILE_MODE,
|
|
211
|
+
DOWNLOAD_DIRECTORY_PRESETS,
|
|
212
|
+
prepareDownloadDirectory,
|
|
213
|
+
resolveDownloadDirectory,
|
|
214
|
+
} from './downloads/destination.js';
|
|
215
|
+
export {
|
|
216
|
+
DOWNLOAD_CONFLICT,
|
|
217
|
+
cleanPartials,
|
|
218
|
+
saveDownload,
|
|
219
|
+
} from './downloads/store.js';
|
|
220
|
+
export { DOWNLOAD_FAILURE } from './downloads/sources.js';
|
|
221
|
+
export {
|
|
222
|
+
extensionFromContent,
|
|
223
|
+
sanitizeDownloadName,
|
|
224
|
+
withExtension,
|
|
225
|
+
} from './downloads/naming.js';
|
|
226
|
+
|
|
227
|
+
// Re-export the portable trace subsystem: one recorder, one versioned bundle,
|
|
228
|
+
// and one offline viewer for every consumer (issue #87).
|
|
229
|
+
export { startTrace } from './traces/recorder.js';
|
|
230
|
+
export {
|
|
231
|
+
TRACE_EVENT,
|
|
232
|
+
TRACE_EVENT_SOURCES,
|
|
233
|
+
TRACE_FILES,
|
|
234
|
+
TRACE_MODE,
|
|
235
|
+
TRACE_OUTCOME,
|
|
236
|
+
TRACE_SCHEMA_VERSION,
|
|
237
|
+
} from './traces/schema.js';
|
|
238
|
+
export { diffControlState, parseNdjson, readTrace } from './traces/reader.js';
|
|
239
|
+
export { renderTraceViewer, writeTraceViewer } from './traces/viewer.js';
|
|
240
|
+
export {
|
|
241
|
+
DEFAULT_REDACT_ATTRIBUTES,
|
|
242
|
+
DEFAULT_REDACT_SELECTORS,
|
|
243
|
+
REDACTED,
|
|
244
|
+
normalizePrivacyOptions,
|
|
245
|
+
redactUrl,
|
|
246
|
+
redactValue,
|
|
247
|
+
} from './traces/redaction.js';
|
package/src/factory.js
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
notCondition,
|
|
21
21
|
} from './core/page-trigger-manager.js';
|
|
22
22
|
import { createBoundFunctions } from './bindings.js';
|
|
23
|
+
import { attachDownloads } from './downloads/attach.js';
|
|
24
|
+
import { startTrace } from './traces/recorder.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Create a browser commander instance for a specific page
|
|
@@ -28,6 +30,7 @@ import { createBoundFunctions } from './bindings.js';
|
|
|
28
30
|
* @param {boolean} options.verbose - Enable verbose logging
|
|
29
31
|
* @param {boolean} options.enableNetworkTracking - Enable network request tracking (default: true)
|
|
30
32
|
* @param {boolean} options.enableNavigationManager - Enable navigation manager (default: true)
|
|
33
|
+
* @param {Object} [options.downloads] - A download manager to expose as commander.downloads, such as the one launchBrowser() returned
|
|
31
34
|
* @returns {Object} - Browser commander API
|
|
32
35
|
*/
|
|
33
36
|
export function makeBrowserCommander(options = {}) {
|
|
@@ -37,6 +40,7 @@ export function makeBrowserCommander(options = {}) {
|
|
|
37
40
|
enableNetworkTracking = true,
|
|
38
41
|
enableNavigationManager = true,
|
|
39
42
|
enableDialogManager = true,
|
|
43
|
+
downloads = null,
|
|
40
44
|
} = options;
|
|
41
45
|
|
|
42
46
|
if (!page) {
|
|
@@ -108,6 +112,12 @@ export function makeBrowserCommander(options = {}) {
|
|
|
108
112
|
|
|
109
113
|
// Cleanup function
|
|
110
114
|
const destroy = async () => {
|
|
115
|
+
// Downloads are disposed first: disposing waits for bytes still being
|
|
116
|
+
// written, and a caller that destroys the commander right after a click
|
|
117
|
+
// should still find the file.
|
|
118
|
+
if (commander.downloads) {
|
|
119
|
+
await commander.downloads.dispose();
|
|
120
|
+
}
|
|
111
121
|
if (pageTriggerManager) {
|
|
112
122
|
await pageTriggerManager.destroy();
|
|
113
123
|
}
|
|
@@ -142,6 +152,40 @@ export function makeBrowserCommander(options = {}) {
|
|
|
142
152
|
// All bound functions
|
|
143
153
|
...boundFunctions,
|
|
144
154
|
|
|
155
|
+
// Managed downloads (issue #88). Present once a manager is attached,
|
|
156
|
+
// either by passing the one launchBrowser() returned or by calling
|
|
157
|
+
// configureDownloads() on an existing commander.
|
|
158
|
+
downloads,
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Attach a managed download lifecycle to this commander's browser.
|
|
162
|
+
*
|
|
163
|
+
* @param {boolean|Object} [downloadOptions=true] - true for defaults, or {directory, persist, conflict}
|
|
164
|
+
* @returns {Promise<Object>} The attached download manager
|
|
165
|
+
*/
|
|
166
|
+
configureDownloads: async (downloadOptions = true) => {
|
|
167
|
+
// Replacing a manager detaches the old one rather than leaving two
|
|
168
|
+
// sources writing into the same directory.
|
|
169
|
+
await commander.downloads?.dispose();
|
|
170
|
+
commander.downloads = await attachDownloads({
|
|
171
|
+
engine,
|
|
172
|
+
browser: page.context?.() ?? page.browser?.(),
|
|
173
|
+
page,
|
|
174
|
+
downloads: downloadOptions,
|
|
175
|
+
log,
|
|
176
|
+
});
|
|
177
|
+
return commander.downloads;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Start recording a privacy-aware trace of this session (issue #87).
|
|
182
|
+
*
|
|
183
|
+
* @param {Object} [traceOptions] - {output, mode, screenshots, dom, events, privacy, limits}
|
|
184
|
+
* @returns {Promise<Object>} The running trace: {checkpoint, event, stop}
|
|
185
|
+
*/
|
|
186
|
+
startTrace: (traceOptions = {}) =>
|
|
187
|
+
startTrace({ commander, page, log, ...traceOptions }),
|
|
188
|
+
|
|
145
189
|
// Lifecycle
|
|
146
190
|
destroy,
|
|
147
191
|
|
|
@@ -209,6 +253,17 @@ export function makeBrowserCommander(options = {}) {
|
|
|
209
253
|
);
|
|
210
254
|
},
|
|
211
255
|
|
|
256
|
+
// Watch dialogs without answering them (issue #87): an observer is told
|
|
257
|
+
// about every dialog and never suppresses the manager's auto-dismissal.
|
|
258
|
+
observeDialogs: dialogManager
|
|
259
|
+
? (fn) => dialogManager.observeDialogs(fn)
|
|
260
|
+
: () => {
|
|
261
|
+
throw new Error('observeDialogs requires enableDialogManager: true');
|
|
262
|
+
},
|
|
263
|
+
unobserveDialogs: dialogManager
|
|
264
|
+
? (fn) => dialogManager.unobserveDialogs(fn)
|
|
265
|
+
: () => {},
|
|
266
|
+
|
|
212
267
|
// URL condition helpers
|
|
213
268
|
makeUrlCondition,
|
|
214
269
|
allConditions,
|