dsh-apk-vm 0.6.2 → 0.8.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 +54 -0
- package/README.md +8 -3
- package/client.js +884 -16
- package/index.js +202 -7
- package/lib/aab.js +736 -0
- package/lib/android.js +95 -1
- package/lib/har.js +423 -0
- package/lib/ui-tests.js +393 -0
- package/package.json +6 -3
package/lib/android.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import { spawn } from 'node:child_process';
|
|
48
|
-
import { existsSync, statSync } from 'node:fs';
|
|
48
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
49
49
|
import { promises as fsp } from 'node:fs';
|
|
50
50
|
import os from 'node:os';
|
|
51
51
|
import path from 'node:path';
|
|
@@ -972,6 +972,100 @@ export class AndroidVm {
|
|
|
972
972
|
return { ok: true };
|
|
973
973
|
}
|
|
974
974
|
|
|
975
|
+
/** Return named emulator snapshots stored for this AVD. */
|
|
976
|
+
snapshotDir() {
|
|
977
|
+
return path.join(os.homedir(), '.android', 'avd', `${this.avdName}.avd`, 'snapshots');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
validateSnapshotName(name) {
|
|
981
|
+
const value = String(name || '').trim();
|
|
982
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(value)) {
|
|
983
|
+
throw new Error('snapshot name must be 1-64 chars: letters, digits, dot, underscore or dash');
|
|
984
|
+
}
|
|
985
|
+
return value;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
listNamedSnapshots() {
|
|
989
|
+
const dir = this.snapshotDir();
|
|
990
|
+
try {
|
|
991
|
+
return {
|
|
992
|
+
ok: true,
|
|
993
|
+
names: existsSync(dir)
|
|
994
|
+
? readdirSync(dir, { withFileTypes: true })
|
|
995
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
996
|
+
.map((entry) => entry.name)
|
|
997
|
+
.sort()
|
|
998
|
+
: [],
|
|
999
|
+
};
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return { ok: false, names: [], error: error.message };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async saveNamedSnapshot(name) {
|
|
1006
|
+
const snapshot = this.validateSnapshotName(name);
|
|
1007
|
+
const serial = await this._requireSerial();
|
|
1008
|
+
const result = await this._adb(
|
|
1009
|
+
['-s', serial, 'emu', 'avd', 'snapshot', 'save', snapshot],
|
|
1010
|
+
{ timeoutMs: 120000, maxBytes: 256 * 1024 },
|
|
1011
|
+
);
|
|
1012
|
+
const output = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
1013
|
+
if (result.code !== 0 || /error|unknown|failed/i.test(output)) {
|
|
1014
|
+
throw new Error(`snapshot save failed: ${output.slice(0, 500) || `exit ${result.code}`}`);
|
|
1015
|
+
}
|
|
1016
|
+
return { ok: true, name: snapshot, output: output.slice(0, 2000) };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async loadNamedSnapshot(name) {
|
|
1020
|
+
const snapshot = this.validateSnapshotName(name);
|
|
1021
|
+
const dir = path.join(this.snapshotDir(), snapshot);
|
|
1022
|
+
if (!existsSync(dir)) throw new Error(`snapshot not found: ${snapshot}`);
|
|
1023
|
+
this.snapshotName = snapshot;
|
|
1024
|
+
this.quickBoot = true;
|
|
1025
|
+
await this.restart();
|
|
1026
|
+
return { ok: true, name: snapshot, status: await this.status() };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async deleteNamedSnapshot(name) {
|
|
1030
|
+
const snapshot = this.validateSnapshotName(name);
|
|
1031
|
+
const target = path.join(this.snapshotDir(), snapshot);
|
|
1032
|
+
if (!existsSync(target)) throw new Error(`snapshot not found: ${snapshot}`);
|
|
1033
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
1034
|
+
return { ok: true, name: snapshot };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** Open a validated deep link with an Android VIEW intent. */
|
|
1038
|
+
async openUrl(url) {
|
|
1039
|
+
const target = String(url || '').trim();
|
|
1040
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s'"]+$/.test(target)) {
|
|
1041
|
+
throw new Error('deep link must be a scheme://... URL without whitespace or quotes');
|
|
1042
|
+
}
|
|
1043
|
+
const serial = await this._requireSerial();
|
|
1044
|
+
const quoted = `'${target.replace(/'/g, `'\\''`)}'`;
|
|
1045
|
+
const result = await this._adb(
|
|
1046
|
+
['-s', serial, 'shell', `am start -a android.intent.action.VIEW -d ${quoted}`],
|
|
1047
|
+
{ timeoutMs: 15000, maxBytes: 512 * 1024 },
|
|
1048
|
+
);
|
|
1049
|
+
const output = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
1050
|
+
if (result.code !== 0 || /Error:|Exception|not found/i.test(output)) {
|
|
1051
|
+
throw new Error(`deep link failed: ${output.slice(0, 500)}`);
|
|
1052
|
+
}
|
|
1053
|
+
return { ok: true, url: target, output: output.slice(0, 2000) };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Grant or revoke a runtime permission for a package. */
|
|
1057
|
+
async setPermission(packageName, permission, granted = true) {
|
|
1058
|
+
this._assertPackageName(packageName);
|
|
1059
|
+
const name = String(permission || '').trim();
|
|
1060
|
+
if (!/^[A-Za-z0-9_.]+$/.test(name)) {
|
|
1061
|
+
throw new Error(`invalid permission: ${JSON.stringify(permission)}`);
|
|
1062
|
+
}
|
|
1063
|
+
const command = `pm ${granted ? 'grant' : 'revoke'} ${packageName} ${name}`;
|
|
1064
|
+
const result = await this.shell(command, { timeoutMs: 30000 });
|
|
1065
|
+
if (result.ok !== true) throw new Error(`permission change failed: ${command}`);
|
|
1066
|
+
return { ok: true, packageName, permission: name, granted: granted === true };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
975
1069
|
/**
|
|
976
1070
|
* Inject input: { type: 'tap', x, y } | { type: 'swipe', x1, y1, x2, y2, durationMs? }
|
|
977
1071
|
* | { type: 'key', key } | { type: 'text', text }. Resolves { ok: true }.
|
package/lib/har.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HAR (HTTP Archive) parsing helpers for the Network panel.
|
|
3
|
+
*
|
|
4
|
+
* The panel reads a HAR file exported by Chrome DevTools or a capture proxy and
|
|
5
|
+
* shows a normalized, bounded view of every request. This module is read-only:
|
|
6
|
+
* it never replays a request, resolves a URL, or writes to disk. Header values
|
|
7
|
+
* are kept because the panel is a debugging tool, but control characters are
|
|
8
|
+
* stripped and every string is length limited before it can reach the browser
|
|
9
|
+
* or a copied curl command.
|
|
10
|
+
*/
|
|
11
|
+
import { promises as fsp } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const MAX_HAR_BYTES = 20 * 1024 * 1024;
|
|
15
|
+
const MAX_ENTRIES = 5000;
|
|
16
|
+
const MAX_HEADERS = 100;
|
|
17
|
+
const MAX_HEADER_NAME = 128;
|
|
18
|
+
const MAX_HEADER_VALUE = 2048;
|
|
19
|
+
const MAX_URL_LENGTH = 16384;
|
|
20
|
+
const MAX_METHOD_LENGTH = 24;
|
|
21
|
+
const MAX_PAGES = 500;
|
|
22
|
+
const MAX_TEXT = 256;
|
|
23
|
+
const ALLOWED_EXTENSIONS = new Set(['.har', '.json']);
|
|
24
|
+
const UNSAFE_HEADER_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
25
|
+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
26
|
+
const STRIP_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
27
|
+
// Keep tab; replace CR/LF/NUL and other C0/C1 controls so values stay one line.
|
|
28
|
+
const HEADER_CONTROL_RE = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f]+/g;
|
|
29
|
+
const SHELL_QUOTE_ESCAPE = "'\\''";
|
|
30
|
+
|
|
31
|
+
function truncate(value, limit) {
|
|
32
|
+
const text = String(value == null ? '' : value);
|
|
33
|
+
if (text.length <= limit) return text;
|
|
34
|
+
return `${text.slice(0, Math.max(0, limit - 3))}...`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizeText(value, limit = MAX_TEXT) {
|
|
38
|
+
return truncate(String(value == null ? '' : value).replace(HEADER_CONTROL_RE, ' ').trim(), limit);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizeUrl(value) {
|
|
42
|
+
return truncate(String(value == null ? '' : value).replace(STRIP_CONTROL_RE, '').trim(), MAX_URL_LENGTH);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeMethod(value) {
|
|
46
|
+
const method = String(value == null ? '' : value).replace(STRIP_CONTROL_RE, '').trim().toUpperCase();
|
|
47
|
+
if (!method || method.length > MAX_METHOD_LENGTH || !TOKEN_RE.test(method)) return '';
|
|
48
|
+
return method;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sanitizeHeaderName(value) {
|
|
52
|
+
const name = String(value == null ? '' : value).replace(STRIP_CONTROL_RE, '').trim();
|
|
53
|
+
if (!name || name.length > MAX_HEADER_NAME || !TOKEN_RE.test(name)) return '';
|
|
54
|
+
return name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function headerValueText(value) {
|
|
58
|
+
if (Array.isArray(value)) return value.map((item) => headerValueText(item)).join(', ');
|
|
59
|
+
if (value && typeof value === 'object') {
|
|
60
|
+
try {
|
|
61
|
+
const json = JSON.stringify(value);
|
|
62
|
+
return typeof json === 'string' ? json : '';
|
|
63
|
+
} catch {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return String(value == null ? '' : value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sanitizeHeaderValue(value) {
|
|
71
|
+
const text = headerValueText(value).replace(HEADER_CONTROL_RE, ' ').trim();
|
|
72
|
+
return truncate(text, MAX_HEADER_VALUE);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a lower-case header map and merge duplicate names with ", ". Only HTTP
|
|
77
|
+
* token names are kept and prototype keys are ignored so a HAR object can never
|
|
78
|
+
* modify Object.prototype.
|
|
79
|
+
*/
|
|
80
|
+
function normalizeHeaders(source) {
|
|
81
|
+
const out = {};
|
|
82
|
+
const add = (rawName, rawValue) => {
|
|
83
|
+
if (Object.keys(out).length >= MAX_HEADERS) return;
|
|
84
|
+
const name = sanitizeHeaderName(rawName);
|
|
85
|
+
if (!name) return;
|
|
86
|
+
const key = name.toLowerCase();
|
|
87
|
+
if (UNSAFE_HEADER_KEYS.has(key)) return;
|
|
88
|
+
const value = sanitizeHeaderValue(rawValue);
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) {
|
|
90
|
+
out[key] = truncate(`${out[key]}, ${value}`, MAX_HEADER_VALUE);
|
|
91
|
+
} else {
|
|
92
|
+
out[key] = value;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (Array.isArray(source)) {
|
|
96
|
+
for (const header of source) {
|
|
97
|
+
if (!header || typeof header !== 'object') continue;
|
|
98
|
+
add(header.name, header.value);
|
|
99
|
+
}
|
|
100
|
+
} else if (source && typeof source === 'object') {
|
|
101
|
+
for (const [name, value] of Object.entries(source)) add(name, value);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeStatus(value) {
|
|
107
|
+
const status = Math.round(Number(value));
|
|
108
|
+
if (!Number.isFinite(status) || status < 0) return 0;
|
|
109
|
+
return Math.min(status, 999);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeDuration(value) {
|
|
113
|
+
const duration = Math.round(Number(value));
|
|
114
|
+
return Number.isFinite(duration) && duration > 0 ? duration : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeSize(value) {
|
|
118
|
+
const size = Math.round(Number(value));
|
|
119
|
+
return Number.isFinite(size) && size > 0 ? size : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pickSize(content, response) {
|
|
123
|
+
for (const value of [content.size, response.bodySize]) {
|
|
124
|
+
const size = Number(value);
|
|
125
|
+
if (Number.isFinite(size) && size >= 0) return Math.round(size);
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Normalized entries are:
|
|
131
|
+
// { startedDateTime, method, url, status, statusText, durationMs, mimeType,
|
|
132
|
+
// size, requestHeaders, responseHeaders }
|
|
133
|
+
// `size` is the response body in bytes and headers are lower-case name -> value.
|
|
134
|
+
function normalizeEntry(raw) {
|
|
135
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
136
|
+
const request = raw.request && typeof raw.request === 'object' && !Array.isArray(raw.request) ? raw.request : {};
|
|
137
|
+
const response = raw.response && typeof raw.response === 'object' && !Array.isArray(raw.response) ? raw.response : {};
|
|
138
|
+
const content =
|
|
139
|
+
response.content && typeof response.content === 'object' && !Array.isArray(response.content)
|
|
140
|
+
? response.content
|
|
141
|
+
: {};
|
|
142
|
+
return {
|
|
143
|
+
startedDateTime: sanitizeText(raw.startedDateTime, 64),
|
|
144
|
+
method: sanitizeMethod(request.method) || 'GET',
|
|
145
|
+
url: sanitizeUrl(request.url),
|
|
146
|
+
status: normalizeStatus(response.status),
|
|
147
|
+
statusText: sanitizeText(response.statusText, MAX_TEXT),
|
|
148
|
+
durationMs: normalizeDuration(raw.time),
|
|
149
|
+
mimeType: sanitizeText(content.mimeType, MAX_TEXT),
|
|
150
|
+
size: pickSize(content, response),
|
|
151
|
+
requestHeaders: normalizeHeaders(request.headers),
|
|
152
|
+
responseHeaders: normalizeHeaders(response.headers),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeCreator(value) {
|
|
157
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
158
|
+
return {
|
|
159
|
+
name: sanitizeText(value.name, 128),
|
|
160
|
+
version: sanitizeText(value.version, 64),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizePages(value) {
|
|
165
|
+
if (!Array.isArray(value)) return [];
|
|
166
|
+
const pages = [];
|
|
167
|
+
for (const page of value) {
|
|
168
|
+
if (!page || typeof page !== 'object' || Array.isArray(page)) continue;
|
|
169
|
+
pages.push({
|
|
170
|
+
id: sanitizeText(page.id, 128),
|
|
171
|
+
title: sanitizeText(page.title, MAX_TEXT),
|
|
172
|
+
startedDateTime: sanitizeText(page.startedDateTime, 64),
|
|
173
|
+
});
|
|
174
|
+
if (pages.length >= MAX_PAGES) break;
|
|
175
|
+
}
|
|
176
|
+
return pages;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeMaxEntries(value) {
|
|
180
|
+
const number = Math.floor(Number(value));
|
|
181
|
+
if (!Number.isFinite(number) || number <= 0) return MAX_ENTRIES;
|
|
182
|
+
return Math.min(number, MAX_ENTRIES);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeMaxBytes(value) {
|
|
186
|
+
const number = Math.floor(Number(value));
|
|
187
|
+
if (!Number.isFinite(number) || number <= 0) return MAX_HAR_BYTES;
|
|
188
|
+
return Math.min(number, MAX_HAR_BYTES);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toHarObject(source) {
|
|
192
|
+
if (Buffer.isBuffer(source)) source = source.toString('utf8');
|
|
193
|
+
if (typeof source === 'string') {
|
|
194
|
+
const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
|
|
195
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_HAR_BYTES) {
|
|
196
|
+
throw new Error('HAR text is larger than the 20 MB limit');
|
|
197
|
+
}
|
|
198
|
+
if (!text.trim()) throw new Error('HAR input is empty');
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(text);
|
|
202
|
+
} catch {
|
|
203
|
+
// Deliberately do not echo the parser message: a syntax error can contain
|
|
204
|
+
// fragments of captured cookies or authorization headers.
|
|
205
|
+
throw new Error('invalid HAR JSON');
|
|
206
|
+
}
|
|
207
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
208
|
+
throw new Error('HAR root must be an object');
|
|
209
|
+
}
|
|
210
|
+
return parsed;
|
|
211
|
+
}
|
|
212
|
+
if (source && typeof source === 'object' && !Array.isArray(source)) return source;
|
|
213
|
+
throw new Error('HAR input must be JSON text or an object');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse HAR JSON text (or an already parsed object) into bounded, normalized
|
|
218
|
+
* entries. Invalid structure throws; malformed individual entries are skipped.
|
|
219
|
+
*
|
|
220
|
+
* @param {string|Buffer|object} source HAR text or object.
|
|
221
|
+
* @param {{maxEntries?: number}} [options] Defaults to and is capped at 5000.
|
|
222
|
+
* @returns {{ok: true, entries: object[], creator: object|null, pages: object[], total: number, truncated: boolean}}
|
|
223
|
+
*/
|
|
224
|
+
export function parseHar(source, options = {}) {
|
|
225
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
226
|
+
const data = toHarObject(source);
|
|
227
|
+
const log = data.log;
|
|
228
|
+
if (!log || typeof log !== 'object' || Array.isArray(log)) {
|
|
229
|
+
throw new Error('HAR is missing the "log" object');
|
|
230
|
+
}
|
|
231
|
+
if (!Array.isArray(log.entries)) throw new Error('HAR log.entries must be an array');
|
|
232
|
+
|
|
233
|
+
const maxEntries = normalizeMaxEntries(opts.maxEntries);
|
|
234
|
+
const entries = [];
|
|
235
|
+
for (const raw of log.entries) {
|
|
236
|
+
const entry = normalizeEntry(raw);
|
|
237
|
+
if (entry) entries.push(entry);
|
|
238
|
+
if (entries.length >= maxEntries) break;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
entries,
|
|
243
|
+
creator: normalizeCreator(log.creator),
|
|
244
|
+
pages: normalizePages(log.pages),
|
|
245
|
+
total: log.entries.length,
|
|
246
|
+
truncated: entries.length < log.entries.length,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read a .har/.json file as UTF-8 and parse it.
|
|
252
|
+
*
|
|
253
|
+
* A missing, non-regular, wrongly named, or oversized (>20 MB) file is rejected
|
|
254
|
+
* before parsing. This is a convenience reader, not a sandbox: callers that
|
|
255
|
+
* accept a path from the browser must resolve it inside their own upload
|
|
256
|
+
* directory.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} filePath Local path ending in .har or .json.
|
|
259
|
+
* @param {{maxEntries?: number, maxBytes?: number}} [options] maxBytes may only lower the 20 MB limit.
|
|
260
|
+
* @returns {Promise<{ok: true, entries: object[], creator: object|null, pages: object[],
|
|
261
|
+
* total: number, truncated: boolean, file: string, bytes: number}>} Parsed result plus file metadata.
|
|
262
|
+
*/
|
|
263
|
+
export async function readHarFile(filePath, options = {}) {
|
|
264
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
265
|
+
const file = String(filePath == null ? '' : filePath).trim();
|
|
266
|
+
if (!file) throw new Error('readHarFile requires a file path');
|
|
267
|
+
if (!ALLOWED_EXTENSIONS.has(path.extname(file).toLowerCase())) {
|
|
268
|
+
throw new Error('HAR file must use a .har or .json extension');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const maxBytes = normalizeMaxBytes(opts.maxBytes);
|
|
272
|
+
let stat;
|
|
273
|
+
try {
|
|
274
|
+
stat = await fsp.stat(file);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const reason = error && error.code ? error.code : 'stat failed';
|
|
277
|
+
throw new Error(`cannot read HAR file: ${reason}`);
|
|
278
|
+
}
|
|
279
|
+
if (!stat.isFile()) throw new Error('HAR path is not a regular file');
|
|
280
|
+
if (stat.size > maxBytes) throw new Error(`HAR file is too large (${stat.size} bytes, limit ${maxBytes} bytes)`);
|
|
281
|
+
|
|
282
|
+
const buffer = await fsp.readFile(file);
|
|
283
|
+
if (buffer.length > maxBytes) {
|
|
284
|
+
throw new Error(`HAR file is too large (${buffer.length} bytes, limit ${maxBytes} bytes)`);
|
|
285
|
+
}
|
|
286
|
+
const parsed = parseHar(buffer.toString('utf8'), opts);
|
|
287
|
+
return { ...parsed, file, bytes: buffer.length };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function hostFromUrl(value) {
|
|
291
|
+
const url = String(value == null ? '' : value).trim();
|
|
292
|
+
if (!url) return '';
|
|
293
|
+
try {
|
|
294
|
+
return new URL(url).hostname.toLowerCase();
|
|
295
|
+
} catch {
|
|
296
|
+
const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(url);
|
|
297
|
+
if (!match) return '';
|
|
298
|
+
const authority = match[1].replace(/^.*@/, '');
|
|
299
|
+
const host = authority.replace(/:\d+$/, '').replace(/^\[/, '').replace(/\]$/, '');
|
|
300
|
+
return host.toLowerCase();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function searchableText(entry) {
|
|
305
|
+
const headers = [entry.requestHeaders, entry.responseHeaders]
|
|
306
|
+
.map((value) => {
|
|
307
|
+
if (!value || typeof value !== 'object') return '';
|
|
308
|
+
return Object.entries(value)
|
|
309
|
+
.map(([name, headerValue]) => `${name}: ${headerValue}`)
|
|
310
|
+
.join('\n');
|
|
311
|
+
})
|
|
312
|
+
.join('\n');
|
|
313
|
+
return [entry.method, entry.url, entry.status, entry.statusText, entry.mimeType, headers].join('\n').toLowerCase();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function statusMatcher(value) {
|
|
317
|
+
const text = String(value == null ? '' : value).trim().toLowerCase();
|
|
318
|
+
if (!text) return null;
|
|
319
|
+
if (text === 'other') return (status) => status < 200 || status >= 600;
|
|
320
|
+
const klass = /^([1-5])xx$/.exec(text);
|
|
321
|
+
if (klass) {
|
|
322
|
+
const start = Number(klass[1]) * 100;
|
|
323
|
+
return (status) => status >= start && status < start + 100;
|
|
324
|
+
}
|
|
325
|
+
if (/^\d{1,3}$/.test(text)) {
|
|
326
|
+
const wanted = Number(text);
|
|
327
|
+
return (status) => status === wanted;
|
|
328
|
+
}
|
|
329
|
+
return () => false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Aggregate counts and sizes for the panel header.
|
|
334
|
+
*
|
|
335
|
+
* @param {object[]} entries Normalized entries from parseHar.
|
|
336
|
+
* @returns {{total: number, totalBytes: number, statusClasses: object, slowest: object|null, domains: string[]}}
|
|
337
|
+
*/
|
|
338
|
+
export function summarizeHar(entries) {
|
|
339
|
+
const list = Array.isArray(entries) ? entries.filter((entry) => entry && typeof entry === 'object') : [];
|
|
340
|
+
const statusClasses = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, other: 0 };
|
|
341
|
+
const domains = new Set();
|
|
342
|
+
let totalBytes = 0;
|
|
343
|
+
let slowest = null;
|
|
344
|
+
for (const entry of list) {
|
|
345
|
+
const status = normalizeStatus(entry.status);
|
|
346
|
+
const klass = status >= 200 && status < 600 ? `${Math.floor(status / 100)}xx` : 'other';
|
|
347
|
+
if (Object.prototype.hasOwnProperty.call(statusClasses, klass)) statusClasses[klass] += 1;
|
|
348
|
+
else statusClasses.other += 1;
|
|
349
|
+
totalBytes += normalizeSize(entry.size);
|
|
350
|
+
const host = hostFromUrl(entry.url);
|
|
351
|
+
if (host) domains.add(host);
|
|
352
|
+
if (!slowest || normalizeDuration(entry.durationMs) > normalizeDuration(slowest.durationMs)) slowest = entry;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
total: list.length,
|
|
356
|
+
totalBytes,
|
|
357
|
+
statusClasses,
|
|
358
|
+
slowest,
|
|
359
|
+
domains: [...domains].sort(),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Case-insensitive filter over normalized entries. Returns a new array.
|
|
365
|
+
*
|
|
366
|
+
* `status` accepts an exact code (404 or "404"), a class ("4xx") or "other".
|
|
367
|
+
* `domain` matches the URL hostname as a substring, and `search` scans method,
|
|
368
|
+
* URL, status text, MIME type and header names/values.
|
|
369
|
+
*
|
|
370
|
+
* @param {object[]} entries Normalized entries from parseHar.
|
|
371
|
+
* @param {{method?: string, status?: string|number, domain?: string, search?: string}} [filters]
|
|
372
|
+
* @returns {object[]}
|
|
373
|
+
*/
|
|
374
|
+
export function filterHar(entries, filters = {}) {
|
|
375
|
+
const list = Array.isArray(entries) ? entries.filter((entry) => entry && typeof entry === 'object') : [];
|
|
376
|
+
const opts = filters && typeof filters === 'object' && !Array.isArray(filters) ? filters : {};
|
|
377
|
+
const method = String(opts.method == null ? '' : opts.method).trim().toUpperCase();
|
|
378
|
+
const domain = String(opts.domain == null ? '' : opts.domain).trim().toLowerCase();
|
|
379
|
+
const search = String(opts.search == null ? '' : opts.search).trim().toLowerCase();
|
|
380
|
+
const matchesStatus = statusMatcher(opts.status);
|
|
381
|
+
if (!method && !domain && !search && !matchesStatus) return list.slice();
|
|
382
|
+
return list.filter((entry) => {
|
|
383
|
+
if (method && String(entry.method == null ? '' : entry.method).toUpperCase() !== method) return false;
|
|
384
|
+
if (matchesStatus && !matchesStatus(normalizeStatus(entry.status))) return false;
|
|
385
|
+
if (domain && !hostFromUrl(entry.url).includes(domain)) return false;
|
|
386
|
+
if (search && !searchableText(entry).includes(search)) return false;
|
|
387
|
+
return true;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function shellQuote(value) {
|
|
392
|
+
return `'${String(value).replace(/'/g, SHELL_QUOTE_ESCAPE)}'`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build a shell-safe curl command for copying a request.
|
|
397
|
+
*
|
|
398
|
+
* Accepts a normalized entry or a raw HAR entry. Only method, URL and request
|
|
399
|
+
* headers are copied (no body); URL and header values are single-quoted and
|
|
400
|
+
* embedded quotes are escaped. The command is returned as a string and is never
|
|
401
|
+
* executed by the plugin.
|
|
402
|
+
*
|
|
403
|
+
* @param {object} entry Normalized entry or raw HAR entry.
|
|
404
|
+
* @returns {string}
|
|
405
|
+
*/
|
|
406
|
+
export function toCurl(entry) {
|
|
407
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
408
|
+
throw new Error('toCurl requires a HAR entry');
|
|
409
|
+
}
|
|
410
|
+
const request = entry.request && typeof entry.request === 'object' && !Array.isArray(entry.request)
|
|
411
|
+
? entry.request
|
|
412
|
+
: null;
|
|
413
|
+
const url = sanitizeUrl(entry.url != null ? entry.url : request && request.url);
|
|
414
|
+
if (!url) throw new Error('toCurl requires a URL');
|
|
415
|
+
const method = sanitizeMethod(entry.method != null ? entry.method : request && request.method) || 'GET';
|
|
416
|
+
const rawHeaders = entry.requestHeaders != null ? entry.requestHeaders : request && request.headers;
|
|
417
|
+
const headers = normalizeHeaders(rawHeaders);
|
|
418
|
+
const parts = ['curl', '-X', method, shellQuote(url)];
|
|
419
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
420
|
+
parts.push('-H', shellQuote(`${name}: ${value}`));
|
|
421
|
+
}
|
|
422
|
+
return parts.join(' ');
|
|
423
|
+
}
|