@hraness/direct 0.7.6 → 0.7.8

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.
@@ -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
- if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
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
- function parseTraceLine(line, lineNumber) {
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
- const snapshots = input.snapshots;
1328
- const directSnapshots = snapshots.filter((snapshot2) => isRecord2(snapshot2) && snapshot2.name === "direct");
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 parseTraceDirectObservation(snapshot.value);
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 = parseTraceLine(line, observationCount);
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.localeCompare(right))) {
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
- if (abortController.signal.aborted)
1956
- throw new Error("Bombadil fuzzing was interrupted");
1957
- lease = await dependencies.acquireServer({
1958
- baseUrl: validated.baseUrl,
1959
- label: validated.label,
1960
- readinessPath: validated.server.readinessPath,
1961
- reuseExistingLocalServer: false,
1962
- startupTimeoutMs: validated.server.startupTimeoutMs,
1963
- startServer: () => {
1964
- ownedServer = dependencies.spawnServer({
1965
- command: serverCommand,
1966
- cwd: validated.server.cwd,
1967
- ...validated.server.env === undefined ? {} : { env: validated.server.env }
1968
- });
1969
- return ownedServer;
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 summary = `${status === "passed" ? "PASS" : "FAIL"} ${validated.label}; artifacts: ${artifactRun.runDirectory}; log: ${logPath}`;
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
  };