@hraness/direct 0.7.5 → 0.7.7
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/README.md +40 -9
- package/dist/tooling/bombadil.js +970 -32
- package/dist/tooling/browser-verification-entry.js +37 -5
- package/package.json +8 -6
- package/skills/direct/references/install.md +3 -3
- package/src/tooling/bombadil-campaign.ts +260 -30
- package/src/tooling/bombadil-runner.ts +1406 -27
- package/src/tooling/bombadil.ts +21 -0
- package/src/tooling/browser-verification.ts +55 -5
package/dist/tooling/bombadil.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFile, realpath, stat, writeFile as writeFile2 } from "fs/promises";
|
|
|
5
5
|
import { isAbsolute, join as join2, relative, resolve } from "path";
|
|
6
6
|
import process2 from "process";
|
|
7
7
|
import { createInterface } from "readline";
|
|
8
|
+
import { createHash } from "crypto";
|
|
8
9
|
|
|
9
10
|
// src/core/result.ts
|
|
10
11
|
function ok(value) {
|
|
@@ -1045,23 +1046,54 @@ async function stopVerificationServerWithOutput(server, stopTimeoutMs = DEFAULT_
|
|
|
1045
1046
|
async function stopVerificationServer(server, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
|
|
1046
1047
|
await stopVerificationServerWithOutput(server, stopTimeoutMs);
|
|
1047
1048
|
}
|
|
1049
|
+
function verificationServerAcquisitionAbortError() {
|
|
1050
|
+
return new Error("Verification server acquisition was aborted");
|
|
1051
|
+
}
|
|
1052
|
+
function throwIfVerificationServerAcquisitionAborted(signal) {
|
|
1053
|
+
if (signal?.aborted === true)
|
|
1054
|
+
throw verificationServerAcquisitionAbortError();
|
|
1055
|
+
}
|
|
1056
|
+
async function waitForVerificationServerAcquisitionStep(promise, signal) {
|
|
1057
|
+
if (signal === undefined)
|
|
1058
|
+
return await promise;
|
|
1059
|
+
throwIfVerificationServerAcquisitionAborted(signal);
|
|
1060
|
+
let abortListener;
|
|
1061
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
1062
|
+
abortListener = () => reject(verificationServerAcquisitionAbortError());
|
|
1063
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
1064
|
+
if (signal.aborted)
|
|
1065
|
+
abortListener();
|
|
1066
|
+
});
|
|
1067
|
+
let value;
|
|
1068
|
+
try {
|
|
1069
|
+
value = await Promise.race([promise, aborted]);
|
|
1070
|
+
} finally {
|
|
1071
|
+
if (abortListener !== undefined)
|
|
1072
|
+
signal.removeEventListener("abort", abortListener);
|
|
1073
|
+
}
|
|
1074
|
+
throwIfVerificationServerAcquisitionAborted(signal);
|
|
1075
|
+
return value;
|
|
1076
|
+
}
|
|
1048
1077
|
async function acquireVerificationServer(options) {
|
|
1049
1078
|
const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
1050
1079
|
const readinessPath = options.readinessPath ?? "/";
|
|
1051
1080
|
const isReachable = options.isReachable ?? serverIsReachable;
|
|
1052
1081
|
const canStartLocally = canAutomaticallyStartLocalServer(options.baseUrl, options.localHosts);
|
|
1053
|
-
|
|
1082
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1083
|
+
if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
|
|
1054
1084
|
if (canStartLocally && options.reuseExistingLocalServer === false) {
|
|
1055
1085
|
throw new Error(`A local server is already reachable at ${options.baseUrl}; ` + "verification will not reuse a server whose worktree ownership is unknown");
|
|
1056
1086
|
}
|
|
1057
|
-
await Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS);
|
|
1058
|
-
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
1087
|
+
await waitForVerificationServerAcquisitionStep(Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS), options.abortSignal);
|
|
1088
|
+
if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
|
|
1089
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1059
1090
|
return { source: "reused" };
|
|
1060
1091
|
}
|
|
1061
1092
|
}
|
|
1062
1093
|
if (!canStartLocally) {
|
|
1063
1094
|
throw new Error(`No server is reachable at ${options.baseUrl}; automatic startup is limited to local HTTP URLs`);
|
|
1064
1095
|
}
|
|
1096
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1065
1097
|
const server = options.startServer();
|
|
1066
1098
|
let exitedWithCode = null;
|
|
1067
1099
|
try {
|
|
@@ -1072,10 +1104,11 @@ async function acquireVerificationServer(options) {
|
|
|
1072
1104
|
exitedWithCode = exitCode;
|
|
1073
1105
|
break;
|
|
1074
1106
|
}
|
|
1075
|
-
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
1107
|
+
if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
|
|
1108
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1076
1109
|
return { source: "started", server };
|
|
1077
1110
|
}
|
|
1078
|
-
await Bun.sleep(options.pollIntervalMs ?? 200);
|
|
1111
|
+
await waitForVerificationServerAcquisitionStep(Bun.sleep(options.pollIntervalMs ?? 200), options.abortSignal);
|
|
1079
1112
|
}
|
|
1080
1113
|
} catch (error) {
|
|
1081
1114
|
await stopVerificationServer(server);
|
|
@@ -1134,6 +1167,12 @@ var TRACE_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
1134
1167
|
var TRACE_MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
1135
1168
|
var TRACE_MAX_LINES = 1e4;
|
|
1136
1169
|
var TRACE_MAX_SNAPSHOTS_PER_LINE = 4096;
|
|
1170
|
+
var TRACE_MAX_NAMED_SNAPSHOT_NAMES = 128;
|
|
1171
|
+
var TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME = 1024;
|
|
1172
|
+
var TRACE_MAX_DISTINCT_URLS = 1024;
|
|
1173
|
+
var TRACE_MAX_PROPERTY_NAMES = 128;
|
|
1174
|
+
var TRACE_MAX_CANONICAL_SNAPSHOT_BYTES = 2 * 1024 * 1024;
|
|
1175
|
+
var TRACE_MAX_JSON_DEPTH = 64;
|
|
1137
1176
|
var RANDOM_RUN_OVERHEAD_MS = 30000;
|
|
1138
1177
|
var REPLAY_WALL_CLOCK_TIMEOUT_MS = MAX_TIME_LIMIT_SECONDS * 1000 + RANDOM_RUN_OVERHEAD_MS;
|
|
1139
1178
|
var PROCESS_TERMINATION_GRACE_MS = 5000;
|
|
@@ -1142,6 +1181,94 @@ var SERVER_OUTPUT_TIMEOUT_MS = 3000;
|
|
|
1142
1181
|
var DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2";
|
|
1143
1182
|
var TRACE_LINE_KEYS = new Set(["action", "snapshots", "state", "timestamp", "violations"]);
|
|
1144
1183
|
var TRACE_SNAPSHOT_KEYS = new Set(["index", "name", "time", "value"]);
|
|
1184
|
+
var TRACE_STATE_KEYS = new Set([
|
|
1185
|
+
"hash_current",
|
|
1186
|
+
"hash_previous",
|
|
1187
|
+
"resources",
|
|
1188
|
+
"screenshot",
|
|
1189
|
+
"url"
|
|
1190
|
+
]);
|
|
1191
|
+
var TRACE_RESOURCE_KEYS = new Set([
|
|
1192
|
+
"documents",
|
|
1193
|
+
"dom_nodes",
|
|
1194
|
+
"js_event_listeners",
|
|
1195
|
+
"js_heap_total",
|
|
1196
|
+
"js_heap_used",
|
|
1197
|
+
"layout_objects",
|
|
1198
|
+
"script_duration",
|
|
1199
|
+
"task_duration",
|
|
1200
|
+
"thread_time",
|
|
1201
|
+
"timestamp"
|
|
1202
|
+
]);
|
|
1203
|
+
var TRACE_VIOLATION_KEYS = new Set(["name", "violation"]);
|
|
1204
|
+
var TRACE_POINT_KEYS = new Set(["x", "y"]);
|
|
1205
|
+
var TRACE_FINGERPRINT_KEYS = new Set([
|
|
1206
|
+
"accessible_name",
|
|
1207
|
+
"href",
|
|
1208
|
+
"id",
|
|
1209
|
+
"input_type",
|
|
1210
|
+
"name_attr",
|
|
1211
|
+
"placeholder",
|
|
1212
|
+
"role",
|
|
1213
|
+
"structural_path",
|
|
1214
|
+
"tag",
|
|
1215
|
+
"test_id",
|
|
1216
|
+
"text_content"
|
|
1217
|
+
]);
|
|
1218
|
+
var TRACE_CLICK_ACTION_KEYS = new Set(["fingerprint", "point"]);
|
|
1219
|
+
var TRACE_DOUBLE_CLICK_ACTION_KEYS = new Set([
|
|
1220
|
+
"delay_millis",
|
|
1221
|
+
"fingerprint",
|
|
1222
|
+
"point"
|
|
1223
|
+
]);
|
|
1224
|
+
var TRACE_TYPE_TEXT_ACTION_KEYS = new Set(["delay_millis", "text"]);
|
|
1225
|
+
var TRACE_PRESS_KEY_ACTION_KEYS = new Set(["code"]);
|
|
1226
|
+
var TRACE_SCROLL_ACTION_KEYS = new Set(["distance", "origin"]);
|
|
1227
|
+
var TRACE_FILE_INPUT_ACTION_KEYS = new Set(["files", "selector"]);
|
|
1228
|
+
var TRACE_MOUSE_DRAG_ACTION_KEYS = new Set([
|
|
1229
|
+
"delay_millis",
|
|
1230
|
+
"from",
|
|
1231
|
+
"steps",
|
|
1232
|
+
"to"
|
|
1233
|
+
]);
|
|
1234
|
+
var TRACE_VIEWPORT_ACTION_KEYS = new Set(["height", "width"]);
|
|
1235
|
+
var VIEWPORT_KEYS = new Set(["deviceScaleFactor", "height", "width"]);
|
|
1236
|
+
var EXPLORATION_POLICY_KEYS = new Set([
|
|
1237
|
+
"minDistinctNamedSnapshotValues",
|
|
1238
|
+
"minNamedSnapshotChangesAfterActionKind",
|
|
1239
|
+
"minNamedSnapshotChangesAfterNonWait",
|
|
1240
|
+
"minNonWaitActions",
|
|
1241
|
+
"requireStableTargetUrl",
|
|
1242
|
+
"requiredActionKinds",
|
|
1243
|
+
"requiredNamedSnapshots"
|
|
1244
|
+
]);
|
|
1245
|
+
var DEFAULT_VIEWPORT_WIDTH = 1024;
|
|
1246
|
+
var DEFAULT_VIEWPORT_HEIGHT = 768;
|
|
1247
|
+
var DEFAULT_DEVICE_SCALE_FACTOR = 2;
|
|
1248
|
+
var SNAPSHOT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u;
|
|
1249
|
+
var TARGET_TAG_PATTERN = /^[a-z][a-z0-9-]*$/u;
|
|
1250
|
+
var ACTION_KINDS = [
|
|
1251
|
+
"Back",
|
|
1252
|
+
"Click",
|
|
1253
|
+
"DoubleClick",
|
|
1254
|
+
"Forward",
|
|
1255
|
+
"MouseDrag",
|
|
1256
|
+
"PressKey",
|
|
1257
|
+
"Reload",
|
|
1258
|
+
"ScrollDown",
|
|
1259
|
+
"ScrollUp",
|
|
1260
|
+
"SetFileInputFiles",
|
|
1261
|
+
"SetViewport",
|
|
1262
|
+
"TypeText",
|
|
1263
|
+
"Wait"
|
|
1264
|
+
];
|
|
1265
|
+
var ACTION_KIND_SET = new Set(ACTION_KINDS);
|
|
1266
|
+
var UNIT_ACTION_KINDS = new Set([
|
|
1267
|
+
"Back",
|
|
1268
|
+
"Forward",
|
|
1269
|
+
"Reload",
|
|
1270
|
+
"Wait"
|
|
1271
|
+
]);
|
|
1145
1272
|
var DIRECT_OBSERVATION_KEYS = new Set([
|
|
1146
1273
|
"activationHash",
|
|
1147
1274
|
"activeRoute",
|
|
@@ -1217,6 +1344,13 @@ function hasExactKeys(value, expected) {
|
|
|
1217
1344
|
const keys = Object.keys(value);
|
|
1218
1345
|
return keys.length === expected.size && keys.every((key) => expected.has(key));
|
|
1219
1346
|
}
|
|
1347
|
+
function compareCodeUnits(left, right) {
|
|
1348
|
+
if (left < right)
|
|
1349
|
+
return -1;
|
|
1350
|
+
if (left > right)
|
|
1351
|
+
return 1;
|
|
1352
|
+
return 0;
|
|
1353
|
+
}
|
|
1220
1354
|
function parseTraceDirectObservation(value) {
|
|
1221
1355
|
if (!isRecord2(value) || !hasExactKeys(value, DIRECT_OBSERVATION_KEYS)) {
|
|
1222
1356
|
throw new Error("Bombadil trace has an invalid named direct observation");
|
|
@@ -1311,7 +1445,183 @@ function exactTraceDirectObservation(observation) {
|
|
|
1311
1445
|
isQuiescent: probe2.value.isQuiescent
|
|
1312
1446
|
};
|
|
1313
1447
|
}
|
|
1314
|
-
|
|
1448
|
+
var RESOURCE_FIELD_MAP = {
|
|
1449
|
+
documents: "documents",
|
|
1450
|
+
dom_nodes: "domNodes",
|
|
1451
|
+
js_event_listeners: "jsEventListeners",
|
|
1452
|
+
js_heap_total: "jsHeapTotalBytes",
|
|
1453
|
+
js_heap_used: "jsHeapUsedBytes",
|
|
1454
|
+
layout_objects: "layoutObjects",
|
|
1455
|
+
script_duration: "scriptDurationSeconds",
|
|
1456
|
+
task_duration: "taskDurationSeconds",
|
|
1457
|
+
thread_time: "threadTimeSeconds"
|
|
1458
|
+
};
|
|
1459
|
+
function canonicalJson2(value, depth = 0, maximumDepth = TRACE_MAX_JSON_DEPTH) {
|
|
1460
|
+
if (depth > maximumDepth) {
|
|
1461
|
+
throw new Error(`Bombadil named snapshot exceeds JSON depth ${String(maximumDepth)}`);
|
|
1462
|
+
}
|
|
1463
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
1464
|
+
return JSON.stringify(value);
|
|
1465
|
+
}
|
|
1466
|
+
if (typeof value === "number") {
|
|
1467
|
+
if (!Number.isFinite(value))
|
|
1468
|
+
throw new Error("Bombadil named snapshot has a non-finite number");
|
|
1469
|
+
return JSON.stringify(value);
|
|
1470
|
+
}
|
|
1471
|
+
if (Array.isArray(value)) {
|
|
1472
|
+
return `[${value.map((entry) => canonicalJson2(entry, depth + 1, maximumDepth)).join(",")}]`;
|
|
1473
|
+
}
|
|
1474
|
+
if (!isRecord2(value))
|
|
1475
|
+
throw new Error("Bombadil named snapshot is not JSON");
|
|
1476
|
+
const entries = Object.keys(value).sort(compareCodeUnits).map((key) => `${JSON.stringify(key)}:${canonicalJson2(value[key], depth + 1, maximumDepth)}`);
|
|
1477
|
+
return `{${entries.join(",")}}`;
|
|
1478
|
+
}
|
|
1479
|
+
function sha256(value) {
|
|
1480
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1481
|
+
}
|
|
1482
|
+
function namedSnapshotValueSha256(value, options = {}) {
|
|
1483
|
+
const maximumBytes = options.maximumBytes ?? TRACE_MAX_CANONICAL_SNAPSHOT_BYTES;
|
|
1484
|
+
const canonical = canonicalJson2(value, 0, options.maximumDepth ?? TRACE_MAX_JSON_DEPTH);
|
|
1485
|
+
if (Buffer.byteLength(canonical, "utf8") > maximumBytes) {
|
|
1486
|
+
throw new Error(`Bombadil named snapshot exceeds ${String(maximumBytes)} canonical bytes`);
|
|
1487
|
+
}
|
|
1488
|
+
return sha256(canonical);
|
|
1489
|
+
}
|
|
1490
|
+
function validTracePoint(value) {
|
|
1491
|
+
return isRecord2(value) && hasExactKeys(value, TRACE_POINT_KEYS) && typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y);
|
|
1492
|
+
}
|
|
1493
|
+
function parseTraceFingerprintTag(value, lineNumber) {
|
|
1494
|
+
if (!isRecord2(value) || !Object.keys(value).every((key) => TRACE_FINGERPRINT_KEYS.has(key))) {
|
|
1495
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid action target`);
|
|
1496
|
+
}
|
|
1497
|
+
for (const [key, candidate] of Object.entries(value)) {
|
|
1498
|
+
if (key !== "tag" && typeof candidate !== "string") {
|
|
1499
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid action target`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const tag = value.tag;
|
|
1503
|
+
if (typeof tag !== "string" || tag.length === 0) {
|
|
1504
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid action target tag`);
|
|
1505
|
+
}
|
|
1506
|
+
if (typeof value.structural_path === "string" && Object.keys(value).some((key) => key !== "tag" && key !== "structural_path")) {
|
|
1507
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid action target`);
|
|
1508
|
+
}
|
|
1509
|
+
return tag.length <= 64 && TARGET_TAG_PATTERN.test(tag) ? tag : `sha256:${sha256(tag)}`;
|
|
1510
|
+
}
|
|
1511
|
+
function isSafeIntegerBetween(value, minimum, maximum) {
|
|
1512
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
|
|
1513
|
+
}
|
|
1514
|
+
function invalidTraceAction(lineNumber) {
|
|
1515
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid action`);
|
|
1516
|
+
}
|
|
1517
|
+
function parseTraceAction(value, lineNumber) {
|
|
1518
|
+
if (value === null)
|
|
1519
|
+
return null;
|
|
1520
|
+
if (typeof value === "string") {
|
|
1521
|
+
if (!ACTION_KIND_SET.has(value) || !UNIT_ACTION_KINDS.has(value)) {
|
|
1522
|
+
return invalidTraceAction(lineNumber);
|
|
1523
|
+
}
|
|
1524
|
+
return { kind: value, targetTag: null };
|
|
1525
|
+
}
|
|
1526
|
+
if (!isRecord2(value) || Object.keys(value).length !== 1) {
|
|
1527
|
+
return invalidTraceAction(lineNumber);
|
|
1528
|
+
}
|
|
1529
|
+
const kind = Object.keys(value)[0];
|
|
1530
|
+
const payload = kind === undefined ? undefined : value[kind];
|
|
1531
|
+
if (kind === undefined || !ACTION_KIND_SET.has(kind) || UNIT_ACTION_KINDS.has(kind) || !isRecord2(payload)) {
|
|
1532
|
+
return invalidTraceAction(lineNumber);
|
|
1533
|
+
}
|
|
1534
|
+
const actionKind = kind;
|
|
1535
|
+
let targetTag = null;
|
|
1536
|
+
switch (actionKind) {
|
|
1537
|
+
case "Click":
|
|
1538
|
+
if (!hasExactKeys(payload, TRACE_CLICK_ACTION_KEYS) || !validTracePoint(payload.point)) {
|
|
1539
|
+
return invalidTraceAction(lineNumber);
|
|
1540
|
+
}
|
|
1541
|
+
targetTag = parseTraceFingerprintTag(payload.fingerprint, lineNumber);
|
|
1542
|
+
break;
|
|
1543
|
+
case "DoubleClick":
|
|
1544
|
+
if (!hasExactKeys(payload, TRACE_DOUBLE_CLICK_ACTION_KEYS) || !isSafeIntegerBetween(payload.delay_millis, 0, 1000) || !validTracePoint(payload.point))
|
|
1545
|
+
return invalidTraceAction(lineNumber);
|
|
1546
|
+
targetTag = parseTraceFingerprintTag(payload.fingerprint, lineNumber);
|
|
1547
|
+
break;
|
|
1548
|
+
case "TypeText":
|
|
1549
|
+
if (!hasExactKeys(payload, TRACE_TYPE_TEXT_ACTION_KEYS) || !isSafeIntegerBetween(payload.delay_millis, 0, Number.MAX_SAFE_INTEGER) || typeof payload.text !== "string")
|
|
1550
|
+
return invalidTraceAction(lineNumber);
|
|
1551
|
+
break;
|
|
1552
|
+
case "PressKey":
|
|
1553
|
+
if (!hasExactKeys(payload, TRACE_PRESS_KEY_ACTION_KEYS) || !isSafeIntegerBetween(payload.code, 0, 255)) {
|
|
1554
|
+
return invalidTraceAction(lineNumber);
|
|
1555
|
+
}
|
|
1556
|
+
break;
|
|
1557
|
+
case "ScrollDown":
|
|
1558
|
+
case "ScrollUp":
|
|
1559
|
+
if (!hasExactKeys(payload, TRACE_SCROLL_ACTION_KEYS) || typeof payload.distance !== "number" || !Number.isFinite(payload.distance) || !validTracePoint(payload.origin))
|
|
1560
|
+
return invalidTraceAction(lineNumber);
|
|
1561
|
+
break;
|
|
1562
|
+
case "SetFileInputFiles":
|
|
1563
|
+
if (!hasExactKeys(payload, TRACE_FILE_INPUT_ACTION_KEYS) || typeof payload.selector !== "string" || !Array.isArray(payload.files) || !payload.files.every((file) => typeof file === "string"))
|
|
1564
|
+
return invalidTraceAction(lineNumber);
|
|
1565
|
+
break;
|
|
1566
|
+
case "MouseDrag":
|
|
1567
|
+
if (!hasExactKeys(payload, TRACE_MOUSE_DRAG_ACTION_KEYS) || !isSafeIntegerBetween(payload.delay_millis, 0, 1000) || !isSafeIntegerBetween(payload.steps, 1, 255) || !validTracePoint(payload.from) || !validTracePoint(payload.to))
|
|
1568
|
+
return invalidTraceAction(lineNumber);
|
|
1569
|
+
break;
|
|
1570
|
+
case "SetViewport":
|
|
1571
|
+
if (!hasExactKeys(payload, TRACE_VIEWPORT_ACTION_KEYS) || !isSafeIntegerBetween(payload.height, 1, 1e4) || !isSafeIntegerBetween(payload.width, 1, 1e4))
|
|
1572
|
+
return invalidTraceAction(lineNumber);
|
|
1573
|
+
break;
|
|
1574
|
+
default:
|
|
1575
|
+
return invalidTraceAction(lineNumber);
|
|
1576
|
+
}
|
|
1577
|
+
return { kind: actionKind, targetTag };
|
|
1578
|
+
}
|
|
1579
|
+
function parseNonNegativeFiniteNumber(value, lineNumber, field) {
|
|
1580
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
1581
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid ${field}`);
|
|
1582
|
+
}
|
|
1583
|
+
return value;
|
|
1584
|
+
}
|
|
1585
|
+
function parseTraceState(value, lineNumber) {
|
|
1586
|
+
if (!isRecord2(value) || !hasExactKeys(value, TRACE_STATE_KEYS)) {
|
|
1587
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser state`);
|
|
1588
|
+
}
|
|
1589
|
+
if (typeof value.url !== "string" || value.url.length === 0 || value.url.length > 8192) {
|
|
1590
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL`);
|
|
1591
|
+
}
|
|
1592
|
+
let url;
|
|
1593
|
+
try {
|
|
1594
|
+
url = new URL(value.url);
|
|
1595
|
+
} catch {
|
|
1596
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL`);
|
|
1597
|
+
}
|
|
1598
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1599
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL protocol`);
|
|
1600
|
+
}
|
|
1601
|
+
if (typeof value.screenshot !== "string" || value.screenshot.length > 8192) {
|
|
1602
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid screenshot path`);
|
|
1603
|
+
}
|
|
1604
|
+
for (const field of ["hash_previous", "hash_current"]) {
|
|
1605
|
+
const hash = value[field];
|
|
1606
|
+
if (hash !== null && (typeof hash !== "number" || !Number.isFinite(hash) || !Number.isInteger(hash) || hash < 0)) {
|
|
1607
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid ${field}`);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (!isRecord2(value.resources) || !hasExactKeys(value.resources, TRACE_RESOURCE_KEYS)) {
|
|
1611
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid browser resources`);
|
|
1612
|
+
}
|
|
1613
|
+
const resources = {};
|
|
1614
|
+
for (const field of Object.keys(RESOURCE_FIELD_MAP)) {
|
|
1615
|
+
resources[field] = parseNonNegativeFiniteNumber(value.resources[field], lineNumber, `resources.${field}`);
|
|
1616
|
+
}
|
|
1617
|
+
parseNonNegativeFiniteNumber(value.resources.timestamp, lineNumber, "resources.timestamp");
|
|
1618
|
+
return {
|
|
1619
|
+
currentHash: value.hash_current,
|
|
1620
|
+
resources,
|
|
1621
|
+
url
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
function parseTraceEnvelope(line, lineNumber) {
|
|
1315
1625
|
let input;
|
|
1316
1626
|
try {
|
|
1317
1627
|
input = JSON.parse(line);
|
|
@@ -1324,8 +1634,16 @@ function parseTraceLine(line, lineNumber) {
|
|
|
1324
1634
|
if (!Number.isSafeInteger(input.timestamp) || typeof input.timestamp !== "number" || input.timestamp < 0 || !Array.isArray(input.snapshots) || input.snapshots.length > TRACE_MAX_SNAPSHOTS_PER_LINE || !Array.isArray(input.violations)) {
|
|
1325
1635
|
throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid state fields`);
|
|
1326
1636
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1637
|
+
return {
|
|
1638
|
+
action: input.action,
|
|
1639
|
+
snapshots: input.snapshots,
|
|
1640
|
+
state: input.state,
|
|
1641
|
+
timestamp: input.timestamp,
|
|
1642
|
+
violations: input.violations
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
function parseDirectTraceObservation(envelope, lineNumber) {
|
|
1646
|
+
const directSnapshots = envelope.snapshots.filter((snapshot2) => isRecord2(snapshot2) && snapshot2.name === "direct");
|
|
1329
1647
|
if (directSnapshots.length !== 1) {
|
|
1330
1648
|
throw new Error(`Bombadil trace line ${String(lineNumber)} must contain one named direct snapshot`);
|
|
1331
1649
|
}
|
|
@@ -1333,7 +1651,80 @@ function parseTraceLine(line, lineNumber) {
|
|
|
1333
1651
|
if (snapshot === undefined || !hasExactKeys(snapshot, TRACE_SNAPSHOT_KEYS) || !Number.isSafeInteger(snapshot.index) || !Number.isSafeInteger(snapshot.time) || snapshot.index < 0 || snapshot.time < 0) {
|
|
1334
1652
|
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid direct snapshot`);
|
|
1335
1653
|
}
|
|
1336
|
-
return
|
|
1654
|
+
return {
|
|
1655
|
+
observation: parseTraceDirectObservation(snapshot.value),
|
|
1656
|
+
value: snapshot.value
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
function parseDirectTraceLine(line, lineNumber) {
|
|
1660
|
+
return parseDirectTraceObservation(parseTraceEnvelope(line, lineNumber), lineNumber).observation;
|
|
1661
|
+
}
|
|
1662
|
+
function parseTraceLine(line, lineNumber, strictDiagnosticSnapshotNames) {
|
|
1663
|
+
const envelope = parseTraceEnvelope(line, lineNumber);
|
|
1664
|
+
const state = parseTraceState(envelope.state, lineNumber);
|
|
1665
|
+
const action = parseTraceAction(envelope.action, lineNumber);
|
|
1666
|
+
const snapshots = envelope.snapshots;
|
|
1667
|
+
const direct = parseDirectTraceObservation(envelope, lineNumber);
|
|
1668
|
+
const namedSnapshots = [{
|
|
1669
|
+
name: "direct",
|
|
1670
|
+
valueSha256: namedSnapshotValueSha256(direct.value, {
|
|
1671
|
+
maximumBytes: TRACE_MAX_LINE_BYTES,
|
|
1672
|
+
maximumDepth: TRACE_MAX_JSON_DEPTH + 4
|
|
1673
|
+
})
|
|
1674
|
+
}];
|
|
1675
|
+
const diagnosticSnapshotValues = new Map;
|
|
1676
|
+
for (const snapshotValue of snapshots) {
|
|
1677
|
+
if (!isRecord2(snapshotValue) || !hasExactKeys(snapshotValue, TRACE_SNAPSHOT_KEYS) || !Number.isSafeInteger(snapshotValue.index) || !Number.isSafeInteger(snapshotValue.time) || snapshotValue.index < 0 || snapshotValue.time < 0 || snapshotValue.name !== null && typeof snapshotValue.name !== "string") {
|
|
1678
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid snapshot`);
|
|
1679
|
+
}
|
|
1680
|
+
if (snapshotValue.name === null || snapshotValue.name === "direct")
|
|
1681
|
+
continue;
|
|
1682
|
+
let name;
|
|
1683
|
+
try {
|
|
1684
|
+
name = validateSnapshotName(snapshotValue.name, `Bombadil trace line ${String(lineNumber)} snapshot name`);
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
if (strictDiagnosticSnapshotNames.has(snapshotValue.name))
|
|
1687
|
+
throw error;
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
const values = diagnosticSnapshotValues.get(name) ?? [];
|
|
1691
|
+
values.push(snapshotValue.value);
|
|
1692
|
+
diagnosticSnapshotValues.set(name, values);
|
|
1693
|
+
}
|
|
1694
|
+
for (const [name, values] of diagnosticSnapshotValues) {
|
|
1695
|
+
if (values.length !== 1) {
|
|
1696
|
+
if (strictDiagnosticSnapshotNames.has(name)) {
|
|
1697
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} repeats named snapshot ${name}`);
|
|
1698
|
+
}
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
try {
|
|
1702
|
+
namedSnapshots.push({
|
|
1703
|
+
name,
|
|
1704
|
+
valueSha256: namedSnapshotValueSha256(values[0])
|
|
1705
|
+
});
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if (strictDiagnosticSnapshotNames.has(name))
|
|
1708
|
+
throw error;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const propertyViolationNames = [];
|
|
1712
|
+
for (const violation of envelope.violations) {
|
|
1713
|
+
if (!isRecord2(violation) || !hasExactKeys(violation, TRACE_VIOLATION_KEYS)) {
|
|
1714
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid property violation`);
|
|
1715
|
+
}
|
|
1716
|
+
propertyViolationNames.push(validateSnapshotName(violation.name, `Bombadil trace line ${String(lineNumber)} property violation name`));
|
|
1717
|
+
if (!isRecord2(violation.violation) || Object.keys(violation.violation).length !== 1) {
|
|
1718
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid property violation`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return {
|
|
1722
|
+
action,
|
|
1723
|
+
directObservation: direct.observation,
|
|
1724
|
+
namedSnapshots,
|
|
1725
|
+
propertyViolationNames,
|
|
1726
|
+
state
|
|
1727
|
+
};
|
|
1337
1728
|
}
|
|
1338
1729
|
async function attestDirectBombadilTrace(options) {
|
|
1339
1730
|
const metadata = await stat(options.tracePath).catch(() => null);
|
|
@@ -1360,7 +1751,7 @@ async function attestDirectBombadilTrace(options) {
|
|
|
1360
1751
|
if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
|
|
1361
1752
|
throw new Error(`Bombadil trace line ${String(observationCount)} is too large`);
|
|
1362
1753
|
}
|
|
1363
|
-
const observation =
|
|
1754
|
+
const observation = parseDirectTraceLine(line, observationCount);
|
|
1364
1755
|
const exact = exactTraceDirectObservation(observation);
|
|
1365
1756
|
if (exact === null) {
|
|
1366
1757
|
if (initial !== null) {
|
|
@@ -1428,6 +1819,257 @@ async function attestDirectBombadilTrace(options) {
|
|
|
1428
1819
|
validObservationCount
|
|
1429
1820
|
};
|
|
1430
1821
|
}
|
|
1822
|
+
function sortedCountRecord(values) {
|
|
1823
|
+
return Object.freeze(Object.fromEntries([...values.entries()].sort(([left], [right]) => compareCodeUnits(left, right))));
|
|
1824
|
+
}
|
|
1825
|
+
async function summarizeDirectBombadilTrace(options) {
|
|
1826
|
+
const metadata = await stat(options.tracePath).catch(() => null);
|
|
1827
|
+
if (metadata === null || !metadata.isFile() || metadata.size === 0) {
|
|
1828
|
+
throw new Error("Bombadil did not produce a nonempty trace.jsonl");
|
|
1829
|
+
}
|
|
1830
|
+
if (metadata.size > TRACE_MAX_BYTES) {
|
|
1831
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`);
|
|
1832
|
+
}
|
|
1833
|
+
let targetUrl;
|
|
1834
|
+
try {
|
|
1835
|
+
targetUrl = new URL(options.targetUrl);
|
|
1836
|
+
} catch {
|
|
1837
|
+
throw new Error("targetUrl must be an absolute URL");
|
|
1838
|
+
}
|
|
1839
|
+
const policy = validateExplorationPolicy(options.explorationPolicy);
|
|
1840
|
+
const strictDiagnosticSnapshotNames = explorationPolicySnapshotNames(policy);
|
|
1841
|
+
const actionCounts = new Map;
|
|
1842
|
+
const targetTags = new Map;
|
|
1843
|
+
const urlFingerprints = new Set;
|
|
1844
|
+
const rawUrlFingerprints = new Set;
|
|
1845
|
+
const transitionHashes = new Set;
|
|
1846
|
+
const rawTransitionHashes = new Set;
|
|
1847
|
+
const snapshots = new Map;
|
|
1848
|
+
const propertyViolations = new Map;
|
|
1849
|
+
const resources = {
|
|
1850
|
+
documents: 0,
|
|
1851
|
+
domNodes: 0,
|
|
1852
|
+
jsEventListeners: 0,
|
|
1853
|
+
jsHeapTotalBytes: 0,
|
|
1854
|
+
jsHeapUsedBytes: 0,
|
|
1855
|
+
layoutObjects: 0,
|
|
1856
|
+
scriptDurationSeconds: 0,
|
|
1857
|
+
taskDurationSeconds: 0,
|
|
1858
|
+
threadTimeSeconds: 0
|
|
1859
|
+
};
|
|
1860
|
+
let lineCount = 0;
|
|
1861
|
+
let totalActions = 0;
|
|
1862
|
+
let nonWaitCount = 0;
|
|
1863
|
+
let waitStreak = 0;
|
|
1864
|
+
let maxWaitStreak = 0;
|
|
1865
|
+
let nonNullHashCount = 0;
|
|
1866
|
+
let rawNonNullHashCount = 0;
|
|
1867
|
+
let policyObservationCount = 0;
|
|
1868
|
+
let previousObservationWasExact = false;
|
|
1869
|
+
let stableTarget = true;
|
|
1870
|
+
let trackedUnrelatedSnapshotNameCount = 0;
|
|
1871
|
+
const unrelatedSnapshotNameLimit = Math.max(0, TRACE_MAX_NAMED_SNAPSHOT_NAMES - strictDiagnosticSnapshotNames.size);
|
|
1872
|
+
const stream = createReadStream(options.tracePath, { encoding: "utf8" });
|
|
1873
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
1874
|
+
try {
|
|
1875
|
+
for await (const line of lines) {
|
|
1876
|
+
lineCount += 1;
|
|
1877
|
+
if (lineCount > TRACE_MAX_LINES) {
|
|
1878
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`);
|
|
1879
|
+
}
|
|
1880
|
+
if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
|
|
1881
|
+
throw new Error(`Bombadil trace line ${String(lineCount)} is too large`);
|
|
1882
|
+
}
|
|
1883
|
+
const parsed = parseTraceLine(line, lineCount, strictDiagnosticSnapshotNames);
|
|
1884
|
+
const rawRelativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`;
|
|
1885
|
+
rawUrlFingerprints.add(sha256(rawRelativeUrl));
|
|
1886
|
+
if (rawUrlFingerprints.size > TRACE_MAX_DISTINCT_URLS) {
|
|
1887
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct raw URL fingerprints`);
|
|
1888
|
+
}
|
|
1889
|
+
if (parsed.state.currentHash !== null) {
|
|
1890
|
+
rawNonNullHashCount += 1;
|
|
1891
|
+
rawTransitionHashes.add(String(parsed.state.currentHash));
|
|
1892
|
+
}
|
|
1893
|
+
for (const name of parsed.propertyViolationNames) {
|
|
1894
|
+
if (!propertyViolations.has(name) && propertyViolations.size >= TRACE_MAX_PROPERTY_NAMES) {
|
|
1895
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_PROPERTY_NAMES)} property names`);
|
|
1896
|
+
}
|
|
1897
|
+
propertyViolations.set(name, (propertyViolations.get(name) ?? 0) + 1);
|
|
1898
|
+
}
|
|
1899
|
+
for (const [sourceName, outputName] of Object.entries(RESOURCE_FIELD_MAP)) {
|
|
1900
|
+
resources[outputName] = Math.max(resources[outputName], parsed.state.resources[sourceName]);
|
|
1901
|
+
}
|
|
1902
|
+
const currentObservationIsExact = exactTraceDirectObservation(parsed.directObservation) !== null;
|
|
1903
|
+
if (!currentObservationIsExact) {
|
|
1904
|
+
previousObservationWasExact = false;
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
policyObservationCount += 1;
|
|
1908
|
+
const actionFollowsExactObservation = previousObservationWasExact;
|
|
1909
|
+
const recordedActionKind = actionFollowsExactObservation ? parsed.action?.kind ?? null : null;
|
|
1910
|
+
if (actionFollowsExactObservation && parsed.action !== null) {
|
|
1911
|
+
totalActions += 1;
|
|
1912
|
+
actionCounts.set(parsed.action.kind, (actionCounts.get(parsed.action.kind) ?? 0) + 1);
|
|
1913
|
+
if (parsed.action.kind === "Wait") {
|
|
1914
|
+
waitStreak += 1;
|
|
1915
|
+
maxWaitStreak = Math.max(maxWaitStreak, waitStreak);
|
|
1916
|
+
} else {
|
|
1917
|
+
nonWaitCount += 1;
|
|
1918
|
+
waitStreak = 0;
|
|
1919
|
+
}
|
|
1920
|
+
if (parsed.action.targetTag !== null) {
|
|
1921
|
+
if (!targetTags.has(parsed.action.targetTag) && targetTags.size >= 128) {
|
|
1922
|
+
throw new Error("Bombadil trace exceeds 128 distinct action target tags");
|
|
1923
|
+
}
|
|
1924
|
+
targetTags.set(parsed.action.targetTag, (targetTags.get(parsed.action.targetTag) ?? 0) + 1);
|
|
1925
|
+
}
|
|
1926
|
+
} else if (actionFollowsExactObservation) {
|
|
1927
|
+
waitStreak = 0;
|
|
1928
|
+
}
|
|
1929
|
+
const relativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`;
|
|
1930
|
+
urlFingerprints.add(sha256(relativeUrl));
|
|
1931
|
+
if (urlFingerprints.size > TRACE_MAX_DISTINCT_URLS) {
|
|
1932
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct URL fingerprints`);
|
|
1933
|
+
}
|
|
1934
|
+
stableTarget &&= parsed.state.url.href === targetUrl.href;
|
|
1935
|
+
if (parsed.state.currentHash !== null) {
|
|
1936
|
+
nonNullHashCount += 1;
|
|
1937
|
+
transitionHashes.add(String(parsed.state.currentHash));
|
|
1938
|
+
}
|
|
1939
|
+
for (const snapshot of parsed.namedSnapshots) {
|
|
1940
|
+
let entry = snapshots.get(snapshot.name);
|
|
1941
|
+
if (entry === undefined) {
|
|
1942
|
+
const isStrictSnapshot = snapshot.name === "direct" || strictDiagnosticSnapshotNames.has(snapshot.name);
|
|
1943
|
+
if (!isStrictSnapshot && trackedUnrelatedSnapshotNameCount >= unrelatedSnapshotNameLimit) {
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
if (snapshots.size >= TRACE_MAX_NAMED_SNAPSHOT_NAMES) {
|
|
1947
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES)} named snapshots`);
|
|
1948
|
+
}
|
|
1949
|
+
entry = {
|
|
1950
|
+
changeAfterActionKind: new Map,
|
|
1951
|
+
changeAfterNonWaitCount: 0,
|
|
1952
|
+
lastObservationIndex: null,
|
|
1953
|
+
lastValueSha256: null,
|
|
1954
|
+
observationCount: 0,
|
|
1955
|
+
values: new Set
|
|
1956
|
+
};
|
|
1957
|
+
snapshots.set(snapshot.name, entry);
|
|
1958
|
+
if (!isStrictSnapshot)
|
|
1959
|
+
trackedUnrelatedSnapshotNameCount += 1;
|
|
1960
|
+
}
|
|
1961
|
+
if (!entry.values.has(snapshot.valueSha256) && entry.values.size >= TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME) {
|
|
1962
|
+
if (snapshot.name === "direct" || strictDiagnosticSnapshotNames.has(snapshot.name)) {
|
|
1963
|
+
throw new Error(`Bombadil trace named snapshot ${snapshot.name} exceeds ${String(TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME)} distinct values`);
|
|
1964
|
+
}
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
const changedAfterRecordedAction = recordedActionKind !== null && entry.lastObservationIndex === policyObservationCount - 1 && entry.lastValueSha256 !== null && entry.lastValueSha256 !== snapshot.valueSha256;
|
|
1968
|
+
if (changedAfterRecordedAction) {
|
|
1969
|
+
entry.changeAfterActionKind.set(recordedActionKind, (entry.changeAfterActionKind.get(recordedActionKind) ?? 0) + 1);
|
|
1970
|
+
}
|
|
1971
|
+
if (changedAfterRecordedAction && recordedActionKind !== "Wait") {
|
|
1972
|
+
entry.changeAfterNonWaitCount += 1;
|
|
1973
|
+
}
|
|
1974
|
+
entry.lastObservationIndex = policyObservationCount;
|
|
1975
|
+
entry.lastValueSha256 = snapshot.valueSha256;
|
|
1976
|
+
entry.observationCount += 1;
|
|
1977
|
+
entry.values.add(snapshot.valueSha256);
|
|
1978
|
+
}
|
|
1979
|
+
previousObservationWasExact = true;
|
|
1980
|
+
}
|
|
1981
|
+
} finally {
|
|
1982
|
+
lines.close();
|
|
1983
|
+
stream.destroy();
|
|
1984
|
+
}
|
|
1985
|
+
if (lineCount === 0)
|
|
1986
|
+
throw new Error("Bombadil did not produce a nonempty trace.jsonl");
|
|
1987
|
+
const policyFailures = [];
|
|
1988
|
+
if (policy !== null) {
|
|
1989
|
+
if (nonWaitCount < policy.minNonWaitActions) {
|
|
1990
|
+
policyFailures.push("minimum non-Wait action count was not reached");
|
|
1991
|
+
}
|
|
1992
|
+
for (const kind of policy.requiredActionKinds) {
|
|
1993
|
+
if ((actionCounts.get(kind) ?? 0) === 0) {
|
|
1994
|
+
policyFailures.push(`required action kind ${kind} was not observed`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
for (const name of policy.requiredNamedSnapshots) {
|
|
1998
|
+
if (!snapshots.has(name)) {
|
|
1999
|
+
policyFailures.push(`required named snapshot ${name} was not observed`);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
for (const [name, minimum] of Object.entries(policy.minDistinctNamedSnapshotValues)) {
|
|
2003
|
+
if ((snapshots.get(name)?.values.size ?? 0) < minimum) {
|
|
2004
|
+
policyFailures.push(`named snapshot ${name} did not reach its distinct-value minimum`);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
for (const [name, minimum] of Object.entries(policy.minNamedSnapshotChangesAfterNonWait)) {
|
|
2008
|
+
if ((snapshots.get(name)?.changeAfterNonWaitCount ?? 0) < minimum) {
|
|
2009
|
+
policyFailures.push(`named snapshot ${name} did not reach its post-non-Wait change minimum`);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
for (const [name, minimumByKind] of Object.entries(policy.minNamedSnapshotChangesAfterActionKind)) {
|
|
2013
|
+
for (const [kind, minimum] of Object.entries(minimumByKind)) {
|
|
2014
|
+
if ((snapshots.get(name)?.changeAfterActionKind.get(kind) ?? 0) < minimum) {
|
|
2015
|
+
policyFailures.push(`named snapshot ${name} did not reach its post-${kind} change minimum`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (policy.requireStableTargetUrl && !stableTarget) {
|
|
2020
|
+
policyFailures.push("the browser did not remain on the exact target URL");
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
const traceBytes = await readFile(options.tracePath);
|
|
2024
|
+
return Object.freeze({
|
|
2025
|
+
schema: "direct.bombadil-exploration-summary/v2",
|
|
2026
|
+
trace: Object.freeze({
|
|
2027
|
+
bytes: metadata.size,
|
|
2028
|
+
lineCount,
|
|
2029
|
+
sha256: sha256(traceBytes)
|
|
2030
|
+
}),
|
|
2031
|
+
actions: Object.freeze({
|
|
2032
|
+
byKind: sortedCountRecord(actionCounts),
|
|
2033
|
+
maxWaitStreak,
|
|
2034
|
+
nonWaitCount,
|
|
2035
|
+
targetTags: sortedCountRecord(targetTags),
|
|
2036
|
+
total: totalActions
|
|
2037
|
+
}),
|
|
2038
|
+
urls: Object.freeze({
|
|
2039
|
+
distinctFingerprintCount: urlFingerprints.size,
|
|
2040
|
+
fingerprintSha256: Object.freeze([...urlFingerprints].sort(compareCodeUnits)),
|
|
2041
|
+
observationCount: policyObservationCount,
|
|
2042
|
+
rawDistinctFingerprintCount: rawUrlFingerprints.size,
|
|
2043
|
+
rawFingerprintSha256: Object.freeze([...rawUrlFingerprints].sort(compareCodeUnits)),
|
|
2044
|
+
rawObservationCount: lineCount,
|
|
2045
|
+
stableTarget
|
|
2046
|
+
}),
|
|
2047
|
+
transitions: Object.freeze({
|
|
2048
|
+
distinctNonNullHashCount: transitionHashes.size,
|
|
2049
|
+
nonNullHashCount,
|
|
2050
|
+
rawDistinctNonNullHashCount: rawTransitionHashes.size,
|
|
2051
|
+
rawNonNullHashCount
|
|
2052
|
+
}),
|
|
2053
|
+
namedSnapshots: Object.freeze([...snapshots.entries()].sort(([left], [right]) => compareCodeUnits(left, right)).map(([name, entry]) => Object.freeze({
|
|
2054
|
+
changeAfterActionKind: sortedCountRecord(entry.changeAfterActionKind),
|
|
2055
|
+
changeAfterNonWaitCount: entry.changeAfterNonWaitCount,
|
|
2056
|
+
distinctValueCount: entry.values.size,
|
|
2057
|
+
distinctValueSha256: Object.freeze([...entry.values].sort(compareCodeUnits)),
|
|
2058
|
+
name,
|
|
2059
|
+
observationCount: entry.observationCount
|
|
2060
|
+
}))),
|
|
2061
|
+
propertyViolations: Object.freeze({
|
|
2062
|
+
byName: sortedCountRecord(propertyViolations),
|
|
2063
|
+
total: [...propertyViolations.values()].reduce((total, value) => total + value, 0)
|
|
2064
|
+
}),
|
|
2065
|
+
resourceHighWaterMarks: Object.freeze(resources),
|
|
2066
|
+
policy: Object.freeze({
|
|
2067
|
+
configured: policy !== null,
|
|
2068
|
+
failures: Object.freeze(policyFailures),
|
|
2069
|
+
satisfied: policyFailures.length === 0
|
|
2070
|
+
})
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
1431
2073
|
function parseDirectBombadilFuzzArguments(arguments_, defaultBaseUrl) {
|
|
1432
2074
|
let baseUrl = defaultBaseUrl;
|
|
1433
2075
|
let timeLimitSeconds = DEFAULT_TIME_LIMIT_SECONDS;
|
|
@@ -1527,7 +2169,7 @@ function validateTargetQuery(value) {
|
|
|
1527
2169
|
throw new Error("targetQuery may contain at most 16 parameters");
|
|
1528
2170
|
}
|
|
1529
2171
|
const validated = {};
|
|
1530
|
-
for (const [name, queryValue] of [...entries].sort(([left], [right]) => left
|
|
2172
|
+
for (const [name, queryValue] of [...entries].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1531
2173
|
if (name.length === 0 || name.length > 128 || !QUERY_PARAMETER_NAME_PATTERN.test(name) || PROTOTYPE_PROPERTY_NAMES.has(name) || hasControlCharacters3(name) || name === SCENARIO_QUERY_KEY2 || name === FIXTURE_QUERY_KEY2) {
|
|
1532
2174
|
throw new Error("targetQuery contains an invalid or reserved parameter name");
|
|
1533
2175
|
}
|
|
@@ -1538,6 +2180,149 @@ function validateTargetQuery(value) {
|
|
|
1538
2180
|
}
|
|
1539
2181
|
return Object.freeze(validated);
|
|
1540
2182
|
}
|
|
2183
|
+
function validateSnapshotName(value, label) {
|
|
2184
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 128 || !SNAPSHOT_NAME_PATTERN.test(value) || PROTOTYPE_PROPERTY_NAMES.has(value) || hasControlCharacters3(value)) {
|
|
2185
|
+
throw new Error(`${label} must be a safe bounded snapshot name`);
|
|
2186
|
+
}
|
|
2187
|
+
return value;
|
|
2188
|
+
}
|
|
2189
|
+
function validateViewport(value) {
|
|
2190
|
+
if (value === undefined) {
|
|
2191
|
+
return Object.freeze({
|
|
2192
|
+
deviceScaleFactor: DEFAULT_DEVICE_SCALE_FACTOR,
|
|
2193
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
2194
|
+
width: DEFAULT_VIEWPORT_WIDTH
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
if (!isRecord2(value) || !Object.keys(value).every((key) => VIEWPORT_KEYS.has(key))) {
|
|
2198
|
+
throw new Error("viewport must contain only width, height, and deviceScaleFactor");
|
|
2199
|
+
}
|
|
2200
|
+
const validateDimension = (name, input) => {
|
|
2201
|
+
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 1 || input > 65535) {
|
|
2202
|
+
throw new Error(`viewport.${name} must be an integer between 1 and 65535`);
|
|
2203
|
+
}
|
|
2204
|
+
return input;
|
|
2205
|
+
};
|
|
2206
|
+
const width = validateDimension("width", value.width ?? DEFAULT_VIEWPORT_WIDTH);
|
|
2207
|
+
const height = validateDimension("height", value.height ?? DEFAULT_VIEWPORT_HEIGHT);
|
|
2208
|
+
const deviceScaleFactor = value.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE_FACTOR;
|
|
2209
|
+
if (typeof deviceScaleFactor !== "number" || !Number.isFinite(deviceScaleFactor) || deviceScaleFactor < 0.1 || deviceScaleFactor > 10) {
|
|
2210
|
+
throw new Error("viewport.deviceScaleFactor must be a finite number between 0.1 and 10");
|
|
2211
|
+
}
|
|
2212
|
+
return Object.freeze({ deviceScaleFactor, height, width });
|
|
2213
|
+
}
|
|
2214
|
+
function validateSnapshotMinimumMap(options) {
|
|
2215
|
+
if (!isRecord2(options.value) || Object.keys(options.value).length > 32) {
|
|
2216
|
+
throw new Error(`${options.label} must be a bounded object`);
|
|
2217
|
+
}
|
|
2218
|
+
const validated = {};
|
|
2219
|
+
for (const [rawName, minimum] of Object.entries(options.value).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
2220
|
+
const name = validateSnapshotName(rawName, `${options.label} key`);
|
|
2221
|
+
if (typeof minimum !== "number" || !Number.isSafeInteger(minimum) || minimum < 1 || minimum > options.maximum) {
|
|
2222
|
+
throw new Error(`${options.label} ${name} must be an integer between 1 and ${String(options.maximum)}`);
|
|
2223
|
+
}
|
|
2224
|
+
validated[name] = minimum;
|
|
2225
|
+
}
|
|
2226
|
+
return Object.freeze(validated);
|
|
2227
|
+
}
|
|
2228
|
+
function validateSnapshotActionMinimumMap(options) {
|
|
2229
|
+
if (!isRecord2(options.value) || Object.keys(options.value).length > 32) {
|
|
2230
|
+
throw new Error(`${options.label} must be a bounded object`);
|
|
2231
|
+
}
|
|
2232
|
+
const validated = {};
|
|
2233
|
+
for (const [rawName, rawMinimumByKind] of Object.entries(options.value).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
2234
|
+
const name = validateSnapshotName(rawName, `${options.label} key`);
|
|
2235
|
+
if (!isRecord2(rawMinimumByKind) || Object.keys(rawMinimumByKind).length === 0 || Object.keys(rawMinimumByKind).length > ACTION_KINDS.length) {
|
|
2236
|
+
throw new Error(`${options.label} ${name} must be a bounded action map`);
|
|
2237
|
+
}
|
|
2238
|
+
const minimumByKind = {};
|
|
2239
|
+
for (const [rawKind, minimum] of Object.entries(rawMinimumByKind).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
2240
|
+
if (!ACTION_KIND_SET.has(rawKind)) {
|
|
2241
|
+
throw new Error(`${options.label} ${name} contains an unknown action kind`);
|
|
2242
|
+
}
|
|
2243
|
+
if (typeof minimum !== "number" || !Number.isSafeInteger(minimum) || minimum < 1 || minimum > TRACE_MAX_LINES) {
|
|
2244
|
+
throw new Error(`${options.label} ${name}.${rawKind} must be an integer between 1 and ${String(TRACE_MAX_LINES)}`);
|
|
2245
|
+
}
|
|
2246
|
+
minimumByKind[rawKind] = minimum;
|
|
2247
|
+
}
|
|
2248
|
+
validated[name] = Object.freeze(minimumByKind);
|
|
2249
|
+
}
|
|
2250
|
+
return Object.freeze(validated);
|
|
2251
|
+
}
|
|
2252
|
+
function explorationPolicySnapshotNames(policy) {
|
|
2253
|
+
const names = new Set(["direct"]);
|
|
2254
|
+
if (policy === null)
|
|
2255
|
+
return names;
|
|
2256
|
+
for (const name of policy.requiredNamedSnapshots)
|
|
2257
|
+
names.add(name);
|
|
2258
|
+
for (const name of Object.keys(policy.minDistinctNamedSnapshotValues))
|
|
2259
|
+
names.add(name);
|
|
2260
|
+
for (const name of Object.keys(policy.minNamedSnapshotChangesAfterNonWait))
|
|
2261
|
+
names.add(name);
|
|
2262
|
+
for (const name of Object.keys(policy.minNamedSnapshotChangesAfterActionKind))
|
|
2263
|
+
names.add(name);
|
|
2264
|
+
return names;
|
|
2265
|
+
}
|
|
2266
|
+
function validateExplorationPolicy(value) {
|
|
2267
|
+
if (value === undefined)
|
|
2268
|
+
return null;
|
|
2269
|
+
if (!isRecord2(value) || !Object.keys(value).every((key) => EXPLORATION_POLICY_KEYS.has(key))) {
|
|
2270
|
+
throw new Error("explorationPolicy contains an unknown field");
|
|
2271
|
+
}
|
|
2272
|
+
const minNonWaitActions = value.minNonWaitActions ?? 0;
|
|
2273
|
+
if (typeof minNonWaitActions !== "number" || !Number.isSafeInteger(minNonWaitActions) || minNonWaitActions < 0 || minNonWaitActions > TRACE_MAX_LINES) {
|
|
2274
|
+
throw new Error(`explorationPolicy.minNonWaitActions must be an integer between 0 and ${String(TRACE_MAX_LINES)}`);
|
|
2275
|
+
}
|
|
2276
|
+
const requiredActionKindsInput = value.requiredActionKinds ?? [];
|
|
2277
|
+
if (!Array.isArray(requiredActionKindsInput) || requiredActionKindsInput.length > ACTION_KINDS.length) {
|
|
2278
|
+
throw new Error("explorationPolicy.requiredActionKinds must be a bounded array");
|
|
2279
|
+
}
|
|
2280
|
+
const requiredActionKinds = [...requiredActionKindsInput];
|
|
2281
|
+
if (!requiredActionKinds.every((kind) => typeof kind === "string" && ACTION_KIND_SET.has(kind)) || new Set(requiredActionKinds).size !== requiredActionKinds.length) {
|
|
2282
|
+
throw new Error("explorationPolicy.requiredActionKinds contains an unknown or duplicate kind");
|
|
2283
|
+
}
|
|
2284
|
+
requiredActionKinds.sort(compareCodeUnits);
|
|
2285
|
+
const requiredNamedSnapshotsInput = value.requiredNamedSnapshots ?? [];
|
|
2286
|
+
if (!Array.isArray(requiredNamedSnapshotsInput) || requiredNamedSnapshotsInput.length > 32) {
|
|
2287
|
+
throw new Error("explorationPolicy.requiredNamedSnapshots must be a bounded array");
|
|
2288
|
+
}
|
|
2289
|
+
const requiredNamedSnapshots = requiredNamedSnapshotsInput.map((name) => validateSnapshotName(name, "explorationPolicy.requiredNamedSnapshots entry"));
|
|
2290
|
+
if (new Set(requiredNamedSnapshots).size !== requiredNamedSnapshots.length) {
|
|
2291
|
+
throw new Error("explorationPolicy.requiredNamedSnapshots contains a duplicate name");
|
|
2292
|
+
}
|
|
2293
|
+
requiredNamedSnapshots.sort(compareCodeUnits);
|
|
2294
|
+
const minDistinctNamedSnapshotValues = validateSnapshotMinimumMap({
|
|
2295
|
+
label: "explorationPolicy.minDistinctNamedSnapshotValues",
|
|
2296
|
+
maximum: TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME,
|
|
2297
|
+
value: value.minDistinctNamedSnapshotValues ?? {}
|
|
2298
|
+
});
|
|
2299
|
+
const minNamedSnapshotChangesAfterActionKind = validateSnapshotActionMinimumMap({
|
|
2300
|
+
label: "explorationPolicy.minNamedSnapshotChangesAfterActionKind",
|
|
2301
|
+
value: value.minNamedSnapshotChangesAfterActionKind ?? {}
|
|
2302
|
+
});
|
|
2303
|
+
const minNamedSnapshotChangesAfterNonWait = validateSnapshotMinimumMap({
|
|
2304
|
+
label: "explorationPolicy.minNamedSnapshotChangesAfterNonWait",
|
|
2305
|
+
maximum: TRACE_MAX_LINES,
|
|
2306
|
+
value: value.minNamedSnapshotChangesAfterNonWait ?? {}
|
|
2307
|
+
});
|
|
2308
|
+
const requireStableTargetUrl = value.requireStableTargetUrl ?? false;
|
|
2309
|
+
if (typeof requireStableTargetUrl !== "boolean") {
|
|
2310
|
+
throw new Error("explorationPolicy.requireStableTargetUrl must be a boolean");
|
|
2311
|
+
}
|
|
2312
|
+
const validated = Object.freeze({
|
|
2313
|
+
minDistinctNamedSnapshotValues,
|
|
2314
|
+
minNamedSnapshotChangesAfterActionKind,
|
|
2315
|
+
minNamedSnapshotChangesAfterNonWait,
|
|
2316
|
+
minNonWaitActions,
|
|
2317
|
+
requireStableTargetUrl,
|
|
2318
|
+
requiredActionKinds: Object.freeze(requiredActionKinds),
|
|
2319
|
+
requiredNamedSnapshots: Object.freeze(requiredNamedSnapshots)
|
|
2320
|
+
});
|
|
2321
|
+
if (explorationPolicySnapshotNames(validated).size > TRACE_MAX_NAMED_SNAPSHOT_NAMES) {
|
|
2322
|
+
throw new Error(`explorationPolicy may reference at most ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES - 1)} distinct non-Direct snapshots`);
|
|
2323
|
+
}
|
|
2324
|
+
return validated;
|
|
2325
|
+
}
|
|
1541
2326
|
function validateDirectBombadilFuzzConfig(config, baseUrlOverride) {
|
|
1542
2327
|
const repositoryRoot = resolve(config.repositoryRoot);
|
|
1543
2328
|
if (!isAbsolute(config.repositoryRoot) || repositoryRoot !== config.repositoryRoot) {
|
|
@@ -1590,6 +2375,8 @@ function validateDirectBombadilFuzzConfig(config, baseUrlOverride) {
|
|
|
1590
2375
|
const entryPath = config.entryPath ?? "/";
|
|
1591
2376
|
validateEntryPath(entryPath);
|
|
1592
2377
|
const targetQuery = validateTargetQuery(config.targetQuery ?? {});
|
|
2378
|
+
const viewport = validateViewport(config.viewport);
|
|
2379
|
+
const explorationPolicy = validateExplorationPolicy(config.explorationPolicy);
|
|
1593
2380
|
const startupTimeoutMs = config.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
1594
2381
|
if (!Number.isSafeInteger(startupTimeoutMs) || startupTimeoutMs < 1000 || startupTimeoutMs > MAX_STARTUP_TIMEOUT_MS) {
|
|
1595
2382
|
throw new Error(`server.startupTimeoutMs must be an integer between 1000 and ${String(MAX_STARTUP_TIMEOUT_MS)}`);
|
|
@@ -1604,8 +2391,10 @@ function validateDirectBombadilFuzzConfig(config, baseUrlOverride) {
|
|
|
1604
2391
|
artifactRoot: join2(repositoryRoot, "artifacts", "direct-bombadil", config.artifactName),
|
|
1605
2392
|
bombadilExecutable: bombadilNativeBinary(repositoryRoot),
|
|
1606
2393
|
entryPath,
|
|
2394
|
+
explorationPolicy,
|
|
1607
2395
|
port,
|
|
1608
2396
|
targetQuery,
|
|
2397
|
+
viewport,
|
|
1609
2398
|
server: {
|
|
1610
2399
|
...config.server,
|
|
1611
2400
|
cwd: serverCwd,
|
|
@@ -1624,6 +2413,7 @@ function resolveReplayPath(repositoryRoot, replayPath) {
|
|
|
1624
2413
|
return resolved;
|
|
1625
2414
|
}
|
|
1626
2415
|
function createDirectBombadilInvocation(options) {
|
|
2416
|
+
const viewport = validateViewport(options.viewport);
|
|
1627
2417
|
const target = new URL(options.entryPath ?? "/", `${options.baseUrl}/`);
|
|
1628
2418
|
target.searchParams.set(SCENARIO_QUERY_KEY2, options.scenario);
|
|
1629
2419
|
for (const [name, value] of Object.entries(options.targetQuery ?? {})) {
|
|
@@ -1638,7 +2428,13 @@ function createDirectBombadilInvocation(options) {
|
|
|
1638
2428
|
"--output-path",
|
|
1639
2429
|
options.outputPath,
|
|
1640
2430
|
"--headless",
|
|
1641
|
-
"--instrument-javascript="
|
|
2431
|
+
"--instrument-javascript=",
|
|
2432
|
+
"--width",
|
|
2433
|
+
String(viewport.width),
|
|
2434
|
+
"--height",
|
|
2435
|
+
String(viewport.height),
|
|
2436
|
+
"--device-scale-factor",
|
|
2437
|
+
String(viewport.deviceScaleFactor)
|
|
1642
2438
|
];
|
|
1643
2439
|
if (options.replayPath === null) {
|
|
1644
2440
|
command.push("--exit-on-violation", "--time-limit", `${String(options.timeLimitSeconds)}s`);
|
|
@@ -1762,6 +2558,7 @@ async function runBombadilNativeProcess(invocation) {
|
|
|
1762
2558
|
}
|
|
1763
2559
|
var defaultDependencies = {
|
|
1764
2560
|
acquireServer: acquireVerificationServer,
|
|
2561
|
+
createAbortController: () => new AbortController,
|
|
1765
2562
|
now: () => new Date,
|
|
1766
2563
|
runBombadil: runBombadilNativeProcess,
|
|
1767
2564
|
serverOutputTimeoutMs: SERVER_OUTPUT_TIMEOUT_MS,
|
|
@@ -1892,6 +2689,89 @@ function helpText(defaultBaseUrl) {
|
|
|
1892
2689
|
].join(`
|
|
1893
2690
|
`);
|
|
1894
2691
|
}
|
|
2692
|
+
function parseMatrixCampaignArgument(arguments_) {
|
|
2693
|
+
const forwarded = [];
|
|
2694
|
+
let campaignId = null;
|
|
2695
|
+
let help = false;
|
|
2696
|
+
for (let index = 0;index < arguments_.length; index += 1) {
|
|
2697
|
+
const argument = arguments_[index];
|
|
2698
|
+
if (argument === undefined)
|
|
2699
|
+
continue;
|
|
2700
|
+
if (argument === "--help" || argument === "-h")
|
|
2701
|
+
help = true;
|
|
2702
|
+
if (argument === "--campaign" || argument.startsWith("--campaign=")) {
|
|
2703
|
+
if (campaignId !== null)
|
|
2704
|
+
throw new Error("--campaign may be provided only once");
|
|
2705
|
+
if (argument === "--campaign") {
|
|
2706
|
+
const next = readOptionValue(arguments_, index, "--campaign");
|
|
2707
|
+
campaignId = next.value;
|
|
2708
|
+
index = next.index;
|
|
2709
|
+
} else {
|
|
2710
|
+
campaignId = argument.slice("--campaign=".length);
|
|
2711
|
+
}
|
|
2712
|
+
if (campaignId.length === 0)
|
|
2713
|
+
throw new Error("--campaign requires a value");
|
|
2714
|
+
continue;
|
|
2715
|
+
}
|
|
2716
|
+
forwarded.push(argument);
|
|
2717
|
+
}
|
|
2718
|
+
return { arguments: Object.freeze(forwarded), campaignId, help };
|
|
2719
|
+
}
|
|
2720
|
+
function validateCampaignMatrix(campaigns) {
|
|
2721
|
+
if (campaigns.length === 0 || campaigns.length > 32) {
|
|
2722
|
+
throw new Error("Bombadil campaign matrix must contain 1-32 campaigns");
|
|
2723
|
+
}
|
|
2724
|
+
const ids = new Set;
|
|
2725
|
+
for (const campaign of campaigns) {
|
|
2726
|
+
if (!ARTIFACT_NAME_PATTERN.test(campaign.id) || ids.has(campaign.id)) {
|
|
2727
|
+
throw new Error("Bombadil campaign IDs must be unique lowercase kebab identifiers");
|
|
2728
|
+
}
|
|
2729
|
+
ids.add(campaign.id);
|
|
2730
|
+
}
|
|
2731
|
+
return campaigns;
|
|
2732
|
+
}
|
|
2733
|
+
async function runDirectBombadilFuzzMatrix(campaignsInput, arguments_ = process2.argv.slice(2), dependencyOverrides = {}) {
|
|
2734
|
+
const campaigns = validateCampaignMatrix(campaignsInput);
|
|
2735
|
+
const parsed = parseMatrixCampaignArgument(arguments_);
|
|
2736
|
+
if (parsed.help) {
|
|
2737
|
+
process2.stdout.write(`${[
|
|
2738
|
+
helpText(campaigns[0]?.config.baseUrl ?? ""),
|
|
2739
|
+
" --campaign <id> Run one campaign; required with --replay",
|
|
2740
|
+
"",
|
|
2741
|
+
`Campaigns: ${campaigns.map((campaign) => campaign.id).join(", ")}`
|
|
2742
|
+
].join(`
|
|
2743
|
+
`)}
|
|
2744
|
+
`);
|
|
2745
|
+
return { kind: "help" };
|
|
2746
|
+
}
|
|
2747
|
+
const selected = parsed.campaignId === null ? campaigns : campaigns.filter((campaign) => campaign.id === parsed.campaignId);
|
|
2748
|
+
if (selected.length === 0) {
|
|
2749
|
+
throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`);
|
|
2750
|
+
}
|
|
2751
|
+
if (parsed.campaignId === null && parsed.arguments.some((argument) => argument === "--replay" || argument.startsWith("--replay="))) {
|
|
2752
|
+
throw new Error("--replay requires exactly one --campaign in matrix mode");
|
|
2753
|
+
}
|
|
2754
|
+
const results = [];
|
|
2755
|
+
for (const campaign of selected) {
|
|
2756
|
+
const result = await runDirectBombadilFuzz(campaign.config, parsed.arguments, dependencyOverrides);
|
|
2757
|
+
if (result.kind !== "run") {
|
|
2758
|
+
throw new Error("Bombadil campaign unexpectedly returned help during matrix execution");
|
|
2759
|
+
}
|
|
2760
|
+
results.push({ campaignId: campaign.id, result });
|
|
2761
|
+
}
|
|
2762
|
+
return { kind: "matrix", results: Object.freeze(results) };
|
|
2763
|
+
}
|
|
2764
|
+
function throwIfBombadilRunAborted(signal) {
|
|
2765
|
+
if (signal.aborted)
|
|
2766
|
+
throw new Error("Bombadil fuzzing was interrupted");
|
|
2767
|
+
}
|
|
2768
|
+
function terminateAbortedOwnedServer(signal, server) {
|
|
2769
|
+
if (!signal.aborted)
|
|
2770
|
+
return;
|
|
2771
|
+
if (server.exitCode() === null)
|
|
2772
|
+
server.terminate();
|
|
2773
|
+
throwIfBombadilRunAborted(signal);
|
|
2774
|
+
}
|
|
1895
2775
|
async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2), dependencyOverrides = {}) {
|
|
1896
2776
|
const parsed = parseDirectBombadilFuzzArguments(arguments_, config.baseUrl);
|
|
1897
2777
|
if (parsed.kind === "help") {
|
|
@@ -1912,7 +2792,7 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
1912
2792
|
});
|
|
1913
2793
|
const outputPath = join2(artifactRun.runDirectory, "bombadil");
|
|
1914
2794
|
const tracePath = join2(outputPath, "trace.jsonl");
|
|
1915
|
-
const abortController = new AbortController;
|
|
2795
|
+
const abortController = dependencies.createAbortController?.() ?? new AbortController;
|
|
1916
2796
|
const invocation = createDirectBombadilInvocation({
|
|
1917
2797
|
baseUrl: validated.baseUrl,
|
|
1918
2798
|
bombadilExecutable: validated.bombadilExecutable,
|
|
@@ -1923,7 +2803,8 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
1923
2803
|
scenario: validated.scenario,
|
|
1924
2804
|
specificationPath: validated.specificationPath,
|
|
1925
2805
|
targetQuery: validated.targetQuery,
|
|
1926
|
-
timeLimitSeconds: parsed.timeLimitSeconds
|
|
2806
|
+
timeLimitSeconds: parsed.timeLimitSeconds,
|
|
2807
|
+
viewport: validated.viewport
|
|
1927
2808
|
});
|
|
1928
2809
|
const abortableInvocation = { ...invocation, abortSignal: abortController.signal };
|
|
1929
2810
|
const serverCommand = validated.server.command.map((argument) => argument === "{port}" ? validated.port : argument);
|
|
@@ -1933,6 +2814,8 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
1933
2814
|
let processResult = null;
|
|
1934
2815
|
let attestation = null;
|
|
1935
2816
|
let attestationFailure = null;
|
|
2817
|
+
let explorationSummary = null;
|
|
2818
|
+
let explorationSummaryFailure = null;
|
|
1936
2819
|
let rawTracePath = null;
|
|
1937
2820
|
let serverOutput = "";
|
|
1938
2821
|
let serverOutputFailure = null;
|
|
@@ -1952,23 +2835,37 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
1952
2835
|
try {
|
|
1953
2836
|
await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable");
|
|
1954
2837
|
bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot);
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2838
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2839
|
+
try {
|
|
2840
|
+
lease = await dependencies.acquireServer({
|
|
2841
|
+
abortSignal: abortController.signal,
|
|
2842
|
+
baseUrl: validated.baseUrl,
|
|
2843
|
+
label: validated.label,
|
|
2844
|
+
readinessPath: validated.server.readinessPath,
|
|
2845
|
+
reuseExistingLocalServer: false,
|
|
2846
|
+
startupTimeoutMs: validated.server.startupTimeoutMs,
|
|
2847
|
+
startServer: () => {
|
|
2848
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2849
|
+
ownedServer = dependencies.spawnServer({
|
|
2850
|
+
command: serverCommand,
|
|
2851
|
+
cwd: validated.server.cwd,
|
|
2852
|
+
...validated.server.env === undefined ? {} : { env: validated.server.env }
|
|
2853
|
+
});
|
|
2854
|
+
terminateAbortedOwnedServer(abortController.signal, ownedServer);
|
|
2855
|
+
return ownedServer;
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
if (abortController.signal.aborted)
|
|
2860
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2861
|
+
throw error;
|
|
2862
|
+
}
|
|
2863
|
+
if (abortController.signal.aborted) {
|
|
2864
|
+
const acquiredOwnedServer = ownedServer;
|
|
2865
|
+
if (acquiredOwnedServer?.exitCode() === null)
|
|
2866
|
+
acquiredOwnedServer.terminate();
|
|
2867
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2868
|
+
}
|
|
1972
2869
|
let processFailure = null;
|
|
1973
2870
|
try {
|
|
1974
2871
|
processResult = await dependencies.runBombadil(abortableInvocation);
|
|
@@ -1988,6 +2885,15 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
1988
2885
|
} catch (error) {
|
|
1989
2886
|
attestationFailure = error;
|
|
1990
2887
|
}
|
|
2888
|
+
try {
|
|
2889
|
+
explorationSummary = await summarizeDirectBombadilTrace({
|
|
2890
|
+
...validated.explorationPolicy === null ? {} : { explorationPolicy: validated.explorationPolicy },
|
|
2891
|
+
targetUrl: invocation.targetUrl,
|
|
2892
|
+
tracePath
|
|
2893
|
+
});
|
|
2894
|
+
} catch (error) {
|
|
2895
|
+
explorationSummaryFailure = error;
|
|
2896
|
+
}
|
|
1991
2897
|
if (processFailure !== null) {
|
|
1992
2898
|
throw processFailure instanceof Error ? processFailure : new Error(renderUnknown(processFailure));
|
|
1993
2899
|
}
|
|
@@ -2005,6 +2911,12 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
2005
2911
|
if (attestationFailure !== null) {
|
|
2006
2912
|
throw attestationFailure instanceof Error ? attestationFailure : new Error(renderUnknown(attestationFailure));
|
|
2007
2913
|
}
|
|
2914
|
+
if (explorationSummaryFailure !== null) {
|
|
2915
|
+
throw explorationSummaryFailure instanceof Error ? explorationSummaryFailure : new Error(renderUnknown(explorationSummaryFailure));
|
|
2916
|
+
}
|
|
2917
|
+
if (explorationSummary?.policy.satisfied !== true) {
|
|
2918
|
+
throw new Error(`Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`);
|
|
2919
|
+
}
|
|
2008
2920
|
} catch (error) {
|
|
2009
2921
|
failure = error;
|
|
2010
2922
|
}
|
|
@@ -2038,6 +2950,7 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
2038
2950
|
const status = failure === null ? "passed" : "failed";
|
|
2039
2951
|
const logPath = join2(artifactRun.runDirectory, "bombadil.log");
|
|
2040
2952
|
const serverLogPath = join2(artifactRun.runDirectory, "server.log");
|
|
2953
|
+
const explorationSummaryPath = join2(artifactRun.runDirectory, "exploration-summary.json");
|
|
2041
2954
|
const record = {
|
|
2042
2955
|
schema: ARTIFACT_SCHEMA,
|
|
2043
2956
|
evidenceClass: "diagnostic-fuzz",
|
|
@@ -2053,6 +2966,8 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
2053
2966
|
entryPath: validated.entryPath,
|
|
2054
2967
|
targetQuery: validated.targetQuery,
|
|
2055
2968
|
targetUrl: invocation.targetUrl,
|
|
2969
|
+
viewport: validated.viewport,
|
|
2970
|
+
explorationPolicy: validated.explorationPolicy,
|
|
2056
2971
|
specificationPath: validated.specificationPath,
|
|
2057
2972
|
replayPath,
|
|
2058
2973
|
timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null,
|
|
@@ -2074,6 +2989,9 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
2074
2989
|
},
|
|
2075
2990
|
attestation,
|
|
2076
2991
|
attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure),
|
|
2992
|
+
explorationSummary,
|
|
2993
|
+
explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath,
|
|
2994
|
+
explorationSummaryFailure: explorationSummaryFailure === null ? null : renderUnknown(explorationSummaryFailure),
|
|
2077
2995
|
initialDirect: attestation?.initial ?? null,
|
|
2078
2996
|
interruptedSignal: capturedSignal,
|
|
2079
2997
|
failure: failure === null ? null : renderUnknown(failure)
|
|
@@ -2085,9 +3003,23 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
2085
3003
|
` : ""}`, "utf8");
|
|
2086
3004
|
await writeFile2(serverLogPath, `${serverOutput}${serverOutput.length > 0 ? `
|
|
2087
3005
|
` : ""}`, "utf8");
|
|
3006
|
+
if (explorationSummary !== null) {
|
|
3007
|
+
await writeJsonAtomically(explorationSummaryPath, explorationSummary);
|
|
3008
|
+
}
|
|
2088
3009
|
await writeJsonAtomically(join2(artifactRun.runDirectory, "run.json"), record);
|
|
2089
3010
|
await writeJsonAtomically(artifactRun.manifestPath, record);
|
|
2090
|
-
const
|
|
3011
|
+
const exploration = explorationSummary === null ? "exploration=unavailable" : [
|
|
3012
|
+
`nonWait=${String(explorationSummary.actions.nonWaitCount)}`,
|
|
3013
|
+
`maxWaitStreak=${String(explorationSummary.actions.maxWaitStreak)}`,
|
|
3014
|
+
`namedChanges=${explorationSummary.namedSnapshots.map((snapshot) => `${snapshot.name}:${String(snapshot.changeAfterNonWaitCount)}`).join(",") || "none"}`,
|
|
3015
|
+
`policy=${explorationSummary.policy.satisfied ? "satisfied" : "failed"}`
|
|
3016
|
+
].join("; ");
|
|
3017
|
+
const summary = [
|
|
3018
|
+
`${status === "passed" ? "PASS" : "FAIL"} ${validated.label}`,
|
|
3019
|
+
exploration,
|
|
3020
|
+
`artifacts: ${artifactRun.runDirectory}`,
|
|
3021
|
+
`log: ${logPath}`
|
|
3022
|
+
].join("; ");
|
|
2091
3023
|
(status === "passed" ? process2.stdout : process2.stderr).write(`${summary}
|
|
2092
3024
|
`);
|
|
2093
3025
|
if (failure !== null) {
|
|
@@ -2108,10 +3040,16 @@ async function runDirectBombadilFuzz(config, arguments_ = process2.argv.slice(2)
|
|
|
2108
3040
|
|
|
2109
3041
|
// src/tooling/bombadil.ts
|
|
2110
3042
|
var attestDirectBombadilTrace2 = attestDirectBombadilTrace;
|
|
3043
|
+
var summarizeDirectBombadilTrace2 = summarizeDirectBombadilTrace;
|
|
2111
3044
|
function runDirectBombadilFuzz2(config, arguments_) {
|
|
2112
3045
|
return arguments_ === undefined ? runDirectBombadilFuzz(config) : runDirectBombadilFuzz(config, arguments_);
|
|
2113
3046
|
}
|
|
3047
|
+
function runDirectBombadilFuzzMatrix2(campaigns, arguments_) {
|
|
3048
|
+
return arguments_ === undefined ? runDirectBombadilFuzzMatrix(campaigns) : runDirectBombadilFuzzMatrix(campaigns, arguments_);
|
|
3049
|
+
}
|
|
2114
3050
|
export {
|
|
3051
|
+
summarizeDirectBombadilTrace2 as summarizeDirectBombadilTrace,
|
|
3052
|
+
runDirectBombadilFuzzMatrix2 as runDirectBombadilFuzzMatrix,
|
|
2115
3053
|
runDirectBombadilFuzz2 as runDirectBombadilFuzz,
|
|
2116
3054
|
attestDirectBombadilTrace2 as attestDirectBombadilTrace
|
|
2117
3055
|
};
|