@hraness/direct 0.7.6 → 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 +37 -9
- package/dist/tooling/bombadil.js +970 -32
- package/dist/tooling/browser-verification-entry.js +37 -5
- package/package.json +1 -1
- 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
|
@@ -3,6 +3,7 @@ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
parseDirectProbeSnapshot,
|
|
@@ -44,6 +45,12 @@ const TRACE_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
44
45
|
const TRACE_MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
45
46
|
const TRACE_MAX_LINES = 10_000;
|
|
46
47
|
const TRACE_MAX_SNAPSHOTS_PER_LINE = 4_096;
|
|
48
|
+
const TRACE_MAX_NAMED_SNAPSHOT_NAMES = 128;
|
|
49
|
+
const TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME = 1_024;
|
|
50
|
+
const TRACE_MAX_DISTINCT_URLS = 1_024;
|
|
51
|
+
const TRACE_MAX_PROPERTY_NAMES = 128;
|
|
52
|
+
const TRACE_MAX_CANONICAL_SNAPSHOT_BYTES = 2 * 1024 * 1024;
|
|
53
|
+
const TRACE_MAX_JSON_DEPTH = 64;
|
|
47
54
|
const RANDOM_RUN_OVERHEAD_MS = 30_000;
|
|
48
55
|
const REPLAY_WALL_CLOCK_TIMEOUT_MS = MAX_TIME_LIMIT_SECONDS * 1_000 + RANDOM_RUN_OVERHEAD_MS;
|
|
49
56
|
const PROCESS_TERMINATION_GRACE_MS = 5_000;
|
|
@@ -52,6 +59,94 @@ const SERVER_OUTPUT_TIMEOUT_MS = 3_000;
|
|
|
52
59
|
const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2";
|
|
53
60
|
const TRACE_LINE_KEYS = new Set(["action", "snapshots", "state", "timestamp", "violations"]);
|
|
54
61
|
const TRACE_SNAPSHOT_KEYS = new Set(["index", "name", "time", "value"]);
|
|
62
|
+
const TRACE_STATE_KEYS = new Set([
|
|
63
|
+
"hash_current",
|
|
64
|
+
"hash_previous",
|
|
65
|
+
"resources",
|
|
66
|
+
"screenshot",
|
|
67
|
+
"url",
|
|
68
|
+
]);
|
|
69
|
+
const TRACE_RESOURCE_KEYS = new Set([
|
|
70
|
+
"documents",
|
|
71
|
+
"dom_nodes",
|
|
72
|
+
"js_event_listeners",
|
|
73
|
+
"js_heap_total",
|
|
74
|
+
"js_heap_used",
|
|
75
|
+
"layout_objects",
|
|
76
|
+
"script_duration",
|
|
77
|
+
"task_duration",
|
|
78
|
+
"thread_time",
|
|
79
|
+
"timestamp",
|
|
80
|
+
]);
|
|
81
|
+
const TRACE_VIOLATION_KEYS = new Set(["name", "violation"]);
|
|
82
|
+
const TRACE_POINT_KEYS = new Set(["x", "y"]);
|
|
83
|
+
const TRACE_FINGERPRINT_KEYS = new Set([
|
|
84
|
+
"accessible_name",
|
|
85
|
+
"href",
|
|
86
|
+
"id",
|
|
87
|
+
"input_type",
|
|
88
|
+
"name_attr",
|
|
89
|
+
"placeholder",
|
|
90
|
+
"role",
|
|
91
|
+
"structural_path",
|
|
92
|
+
"tag",
|
|
93
|
+
"test_id",
|
|
94
|
+
"text_content",
|
|
95
|
+
]);
|
|
96
|
+
const TRACE_CLICK_ACTION_KEYS = new Set(["fingerprint", "point"]);
|
|
97
|
+
const TRACE_DOUBLE_CLICK_ACTION_KEYS = new Set([
|
|
98
|
+
"delay_millis",
|
|
99
|
+
"fingerprint",
|
|
100
|
+
"point",
|
|
101
|
+
]);
|
|
102
|
+
const TRACE_TYPE_TEXT_ACTION_KEYS = new Set(["delay_millis", "text"]);
|
|
103
|
+
const TRACE_PRESS_KEY_ACTION_KEYS = new Set(["code"]);
|
|
104
|
+
const TRACE_SCROLL_ACTION_KEYS = new Set(["distance", "origin"]);
|
|
105
|
+
const TRACE_FILE_INPUT_ACTION_KEYS = new Set(["files", "selector"]);
|
|
106
|
+
const TRACE_MOUSE_DRAG_ACTION_KEYS = new Set([
|
|
107
|
+
"delay_millis",
|
|
108
|
+
"from",
|
|
109
|
+
"steps",
|
|
110
|
+
"to",
|
|
111
|
+
]);
|
|
112
|
+
const TRACE_VIEWPORT_ACTION_KEYS = new Set(["height", "width"]);
|
|
113
|
+
const VIEWPORT_KEYS = new Set(["deviceScaleFactor", "height", "width"]);
|
|
114
|
+
const EXPLORATION_POLICY_KEYS = new Set([
|
|
115
|
+
"minDistinctNamedSnapshotValues",
|
|
116
|
+
"minNamedSnapshotChangesAfterActionKind",
|
|
117
|
+
"minNamedSnapshotChangesAfterNonWait",
|
|
118
|
+
"minNonWaitActions",
|
|
119
|
+
"requireStableTargetUrl",
|
|
120
|
+
"requiredActionKinds",
|
|
121
|
+
"requiredNamedSnapshots",
|
|
122
|
+
]);
|
|
123
|
+
const DEFAULT_VIEWPORT_WIDTH = 1_024;
|
|
124
|
+
const DEFAULT_VIEWPORT_HEIGHT = 768;
|
|
125
|
+
const DEFAULT_DEVICE_SCALE_FACTOR = 2;
|
|
126
|
+
const SNAPSHOT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u;
|
|
127
|
+
const TARGET_TAG_PATTERN = /^[a-z][a-z0-9-]*$/u;
|
|
128
|
+
const ACTION_KINDS = [
|
|
129
|
+
"Back",
|
|
130
|
+
"Click",
|
|
131
|
+
"DoubleClick",
|
|
132
|
+
"Forward",
|
|
133
|
+
"MouseDrag",
|
|
134
|
+
"PressKey",
|
|
135
|
+
"Reload",
|
|
136
|
+
"ScrollDown",
|
|
137
|
+
"ScrollUp",
|
|
138
|
+
"SetFileInputFiles",
|
|
139
|
+
"SetViewport",
|
|
140
|
+
"TypeText",
|
|
141
|
+
"Wait",
|
|
142
|
+
] as const;
|
|
143
|
+
const ACTION_KIND_SET = new Set<string>(ACTION_KINDS);
|
|
144
|
+
const UNIT_ACTION_KINDS = new Set<DirectBombadilActionKind>([
|
|
145
|
+
"Back",
|
|
146
|
+
"Forward",
|
|
147
|
+
"Reload",
|
|
148
|
+
"Wait",
|
|
149
|
+
]);
|
|
55
150
|
const DIRECT_OBSERVATION_KEYS = new Set([
|
|
56
151
|
"activationHash",
|
|
57
152
|
"activeRoute",
|
|
@@ -76,6 +171,88 @@ export interface DirectBombadilServerConfig {
|
|
|
76
171
|
readonly startupTimeoutMs?: number;
|
|
77
172
|
}
|
|
78
173
|
|
|
174
|
+
export type DirectBombadilActionKind = (typeof ACTION_KINDS)[number];
|
|
175
|
+
|
|
176
|
+
export interface DirectBombadilViewportConfig {
|
|
177
|
+
readonly deviceScaleFactor?: number;
|
|
178
|
+
readonly height?: number;
|
|
179
|
+
readonly width?: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DirectBombadilExplorationPolicy {
|
|
183
|
+
readonly minDistinctNamedSnapshotValues?: Readonly<Record<string, number>>;
|
|
184
|
+
readonly minNamedSnapshotChangesAfterActionKind?: Readonly<Record<
|
|
185
|
+
string,
|
|
186
|
+
Readonly<Partial<Record<DirectBombadilActionKind, number>>>
|
|
187
|
+
>>;
|
|
188
|
+
readonly minNamedSnapshotChangesAfterNonWait?: Readonly<Record<string, number>>;
|
|
189
|
+
readonly minNonWaitActions?: number;
|
|
190
|
+
readonly requireStableTargetUrl?: boolean;
|
|
191
|
+
readonly requiredActionKinds?: readonly DirectBombadilActionKind[];
|
|
192
|
+
readonly requiredNamedSnapshots?: readonly string[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface DirectBombadilExplorationSummary {
|
|
196
|
+
readonly schema: "direct.bombadil-exploration-summary/v2";
|
|
197
|
+
readonly trace: {
|
|
198
|
+
readonly bytes: number;
|
|
199
|
+
readonly lineCount: number;
|
|
200
|
+
readonly sha256: string;
|
|
201
|
+
};
|
|
202
|
+
readonly actions: {
|
|
203
|
+
readonly byKind: Readonly<Partial<Record<DirectBombadilActionKind, number>>>;
|
|
204
|
+
readonly maxWaitStreak: number;
|
|
205
|
+
readonly nonWaitCount: number;
|
|
206
|
+
readonly targetTags: Readonly<Record<string, number>>;
|
|
207
|
+
readonly total: number;
|
|
208
|
+
};
|
|
209
|
+
readonly urls: {
|
|
210
|
+
readonly distinctFingerprintCount: number;
|
|
211
|
+
readonly fingerprintSha256: readonly string[];
|
|
212
|
+
readonly observationCount: number;
|
|
213
|
+
readonly rawDistinctFingerprintCount: number;
|
|
214
|
+
readonly rawFingerprintSha256: readonly string[];
|
|
215
|
+
readonly rawObservationCount: number;
|
|
216
|
+
readonly stableTarget: boolean;
|
|
217
|
+
};
|
|
218
|
+
readonly transitions: {
|
|
219
|
+
readonly distinctNonNullHashCount: number;
|
|
220
|
+
readonly nonNullHashCount: number;
|
|
221
|
+
readonly rawDistinctNonNullHashCount: number;
|
|
222
|
+
readonly rawNonNullHashCount: number;
|
|
223
|
+
};
|
|
224
|
+
readonly namedSnapshots: readonly {
|
|
225
|
+
readonly changeAfterActionKind: Readonly<
|
|
226
|
+
Partial<Record<DirectBombadilActionKind, number>>
|
|
227
|
+
>;
|
|
228
|
+
readonly changeAfterNonWaitCount: number;
|
|
229
|
+
readonly distinctValueCount: number;
|
|
230
|
+
readonly distinctValueSha256: readonly string[];
|
|
231
|
+
readonly name: string;
|
|
232
|
+
readonly observationCount: number;
|
|
233
|
+
}[];
|
|
234
|
+
readonly propertyViolations: {
|
|
235
|
+
readonly byName: Readonly<Record<string, number>>;
|
|
236
|
+
readonly total: number;
|
|
237
|
+
};
|
|
238
|
+
readonly resourceHighWaterMarks: {
|
|
239
|
+
readonly documents: number;
|
|
240
|
+
readonly domNodes: number;
|
|
241
|
+
readonly jsEventListeners: number;
|
|
242
|
+
readonly jsHeapTotalBytes: number;
|
|
243
|
+
readonly jsHeapUsedBytes: number;
|
|
244
|
+
readonly layoutObjects: number;
|
|
245
|
+
readonly scriptDurationSeconds: number;
|
|
246
|
+
readonly taskDurationSeconds: number;
|
|
247
|
+
readonly threadTimeSeconds: number;
|
|
248
|
+
};
|
|
249
|
+
readonly policy: {
|
|
250
|
+
readonly configured: boolean;
|
|
251
|
+
readonly failures: readonly string[];
|
|
252
|
+
readonly satisfied: boolean;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
79
256
|
export interface DirectBombadilFuzzConfig {
|
|
80
257
|
readonly artifactName: string;
|
|
81
258
|
readonly baseUrl: string;
|
|
@@ -86,6 +263,8 @@ export interface DirectBombadilFuzzConfig {
|
|
|
86
263
|
readonly scenario: string;
|
|
87
264
|
readonly specificationPath: string;
|
|
88
265
|
readonly targetQuery?: Readonly<Record<string, string>>;
|
|
266
|
+
readonly explorationPolicy?: DirectBombadilExplorationPolicy;
|
|
267
|
+
readonly viewport?: DirectBombadilViewportConfig;
|
|
89
268
|
readonly server: DirectBombadilServerConfig;
|
|
90
269
|
}
|
|
91
270
|
|
|
@@ -107,6 +286,21 @@ export type DirectBombadilFuzzResult =
|
|
|
107
286
|
readonly status: "passed";
|
|
108
287
|
};
|
|
109
288
|
|
|
289
|
+
export interface DirectBombadilFuzzCampaign {
|
|
290
|
+
readonly config: DirectBombadilFuzzConfig;
|
|
291
|
+
readonly id: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type DirectBombadilFuzzMatrixResult =
|
|
295
|
+
| { readonly kind: "help" }
|
|
296
|
+
| {
|
|
297
|
+
readonly kind: "matrix";
|
|
298
|
+
readonly results: readonly {
|
|
299
|
+
readonly campaignId: string;
|
|
300
|
+
readonly result: Extract<DirectBombadilFuzzResult, { readonly kind: "run" }>;
|
|
301
|
+
}[];
|
|
302
|
+
};
|
|
303
|
+
|
|
110
304
|
export interface DirectBombadilInvocation {
|
|
111
305
|
readonly abortSignal?: AbortSignal;
|
|
112
306
|
readonly command: readonly string[];
|
|
@@ -160,6 +354,7 @@ export interface DirectBombadilTraceAttestation {
|
|
|
160
354
|
|
|
161
355
|
export interface DirectBombadilRunnerDependencies {
|
|
162
356
|
readonly acquireServer: typeof acquireVerificationServer;
|
|
357
|
+
readonly createAbortController?: () => AbortController;
|
|
163
358
|
readonly now: () => Date;
|
|
164
359
|
readonly runBombadil: (
|
|
165
360
|
invocation: DirectBombadilInvocation,
|
|
@@ -184,17 +379,41 @@ interface ProcessSignalEmitter {
|
|
|
184
379
|
) => unknown;
|
|
185
380
|
}
|
|
186
381
|
|
|
187
|
-
|
|
382
|
+
type ValidatedConfig = Omit<
|
|
383
|
+
DirectBombadilFuzzConfig,
|
|
384
|
+
"explorationPolicy" | "server" | "viewport"
|
|
385
|
+
> & {
|
|
188
386
|
readonly artifactRoot: string;
|
|
189
387
|
readonly baseUrl: string;
|
|
190
388
|
readonly bombadilExecutable: string;
|
|
191
389
|
readonly entryPath: `/${string}`;
|
|
390
|
+
readonly explorationPolicy: ValidatedExplorationPolicy | null;
|
|
192
391
|
readonly port: string;
|
|
193
392
|
readonly targetQuery: Readonly<Record<string, string>>;
|
|
393
|
+
readonly viewport: ValidatedViewport;
|
|
194
394
|
readonly server: DirectBombadilServerConfig & {
|
|
195
395
|
readonly readinessPath: `/${string}`;
|
|
196
396
|
readonly startupTimeoutMs: number;
|
|
197
397
|
};
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
interface ValidatedViewport {
|
|
401
|
+
readonly deviceScaleFactor: number;
|
|
402
|
+
readonly height: number;
|
|
403
|
+
readonly width: number;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface ValidatedExplorationPolicy {
|
|
407
|
+
readonly minDistinctNamedSnapshotValues: Readonly<Record<string, number>>;
|
|
408
|
+
readonly minNamedSnapshotChangesAfterActionKind: Readonly<Record<
|
|
409
|
+
string,
|
|
410
|
+
Readonly<Partial<Record<DirectBombadilActionKind, number>>>
|
|
411
|
+
>>;
|
|
412
|
+
readonly minNamedSnapshotChangesAfterNonWait: Readonly<Record<string, number>>;
|
|
413
|
+
readonly minNonWaitActions: number;
|
|
414
|
+
readonly requireStableTargetUrl: boolean;
|
|
415
|
+
readonly requiredActionKinds: readonly DirectBombadilActionKind[];
|
|
416
|
+
readonly requiredNamedSnapshots: readonly string[];
|
|
198
417
|
}
|
|
199
418
|
|
|
200
419
|
function readOptionValue(
|
|
@@ -283,6 +502,12 @@ function hasExactKeys(
|
|
|
283
502
|
return keys.length === expected.size && keys.every((key) => expected.has(key));
|
|
284
503
|
}
|
|
285
504
|
|
|
505
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
506
|
+
if (left < right) return -1;
|
|
507
|
+
if (left > right) return 1;
|
|
508
|
+
return 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
286
511
|
function parseTraceDirectObservation(value: unknown): TraceDirectObservation {
|
|
287
512
|
if (!isRecord(value) || !hasExactKeys(value, DIRECT_OBSERVATION_KEYS)) {
|
|
288
513
|
throw new Error("Bombadil trace has an invalid named direct observation");
|
|
@@ -427,7 +652,325 @@ function exactTraceDirectObservation(
|
|
|
427
652
|
};
|
|
428
653
|
}
|
|
429
654
|
|
|
430
|
-
|
|
655
|
+
interface ParsedTraceAction {
|
|
656
|
+
readonly kind: DirectBombadilActionKind;
|
|
657
|
+
readonly targetTag: string | null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
interface ParsedTraceState {
|
|
661
|
+
readonly currentHash: number | null;
|
|
662
|
+
readonly resources: Readonly<Record<keyof typeof RESOURCE_FIELD_MAP, number>>;
|
|
663
|
+
readonly url: URL;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
interface ParsedTraceLine {
|
|
667
|
+
readonly action: ParsedTraceAction | null;
|
|
668
|
+
readonly directObservation: TraceDirectObservation;
|
|
669
|
+
readonly namedSnapshots: readonly {
|
|
670
|
+
readonly name: string;
|
|
671
|
+
readonly valueSha256: string;
|
|
672
|
+
}[];
|
|
673
|
+
readonly propertyViolationNames: readonly string[];
|
|
674
|
+
readonly state: ParsedTraceState;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
interface ParsedTraceEnvelope {
|
|
678
|
+
readonly action: unknown;
|
|
679
|
+
readonly snapshots: readonly unknown[];
|
|
680
|
+
readonly state: unknown;
|
|
681
|
+
readonly timestamp: number;
|
|
682
|
+
readonly violations: readonly unknown[];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
interface ParsedDirectTraceObservation {
|
|
686
|
+
readonly observation: TraceDirectObservation;
|
|
687
|
+
readonly value: unknown;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const RESOURCE_FIELD_MAP = {
|
|
691
|
+
documents: "documents",
|
|
692
|
+
dom_nodes: "domNodes",
|
|
693
|
+
js_event_listeners: "jsEventListeners",
|
|
694
|
+
js_heap_total: "jsHeapTotalBytes",
|
|
695
|
+
js_heap_used: "jsHeapUsedBytes",
|
|
696
|
+
layout_objects: "layoutObjects",
|
|
697
|
+
script_duration: "scriptDurationSeconds",
|
|
698
|
+
task_duration: "taskDurationSeconds",
|
|
699
|
+
thread_time: "threadTimeSeconds",
|
|
700
|
+
} as const;
|
|
701
|
+
|
|
702
|
+
function canonicalJson(
|
|
703
|
+
value: unknown,
|
|
704
|
+
depth = 0,
|
|
705
|
+
maximumDepth = TRACE_MAX_JSON_DEPTH,
|
|
706
|
+
): string {
|
|
707
|
+
if (depth > maximumDepth) {
|
|
708
|
+
throw new Error(`Bombadil named snapshot exceeds JSON depth ${String(maximumDepth)}`);
|
|
709
|
+
}
|
|
710
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
711
|
+
return JSON.stringify(value);
|
|
712
|
+
}
|
|
713
|
+
if (typeof value === "number") {
|
|
714
|
+
if (!Number.isFinite(value)) throw new Error("Bombadil named snapshot has a non-finite number");
|
|
715
|
+
return JSON.stringify(value);
|
|
716
|
+
}
|
|
717
|
+
if (Array.isArray(value)) {
|
|
718
|
+
return `[${value.map((entry) => canonicalJson(entry, depth + 1, maximumDepth)).join(",")}]`;
|
|
719
|
+
}
|
|
720
|
+
if (!isRecord(value)) throw new Error("Bombadil named snapshot is not JSON");
|
|
721
|
+
const entries = Object.keys(value).sort(compareCodeUnits).map((key) =>
|
|
722
|
+
`${JSON.stringify(key)}:${canonicalJson(value[key], depth + 1, maximumDepth)}`
|
|
723
|
+
);
|
|
724
|
+
return `{${entries.join(",")}}`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function sha256(value: string | Uint8Array): string {
|
|
728
|
+
return createHash("sha256").update(value).digest("hex");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function namedSnapshotValueSha256(
|
|
732
|
+
value: unknown,
|
|
733
|
+
options: {
|
|
734
|
+
readonly maximumBytes?: number;
|
|
735
|
+
readonly maximumDepth?: number;
|
|
736
|
+
} = {},
|
|
737
|
+
): string {
|
|
738
|
+
const maximumBytes = options.maximumBytes ?? TRACE_MAX_CANONICAL_SNAPSHOT_BYTES;
|
|
739
|
+
const canonical = canonicalJson(
|
|
740
|
+
value,
|
|
741
|
+
0,
|
|
742
|
+
options.maximumDepth ?? TRACE_MAX_JSON_DEPTH,
|
|
743
|
+
);
|
|
744
|
+
if (Buffer.byteLength(canonical, "utf8") > maximumBytes) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`Bombadil named snapshot exceeds ${String(maximumBytes)} canonical bytes`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
return sha256(canonical);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function validTracePoint(value: unknown): boolean {
|
|
753
|
+
return isRecord(value)
|
|
754
|
+
&& hasExactKeys(value, TRACE_POINT_KEYS)
|
|
755
|
+
&& typeof value.x === "number"
|
|
756
|
+
&& Number.isFinite(value.x)
|
|
757
|
+
&& typeof value.y === "number"
|
|
758
|
+
&& Number.isFinite(value.y);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function parseTraceFingerprintTag(value: unknown, lineNumber: number): string {
|
|
762
|
+
if (
|
|
763
|
+
!isRecord(value)
|
|
764
|
+
|| !Object.keys(value).every((key) => TRACE_FINGERPRINT_KEYS.has(key))
|
|
765
|
+
) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`Bombadil trace line ${String(lineNumber)} has an invalid action target`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
for (const [key, candidate] of Object.entries(value)) {
|
|
771
|
+
if (key !== "tag" && typeof candidate !== "string") {
|
|
772
|
+
throw new Error(
|
|
773
|
+
`Bombadil trace line ${String(lineNumber)} has an invalid action target`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const tag = value.tag;
|
|
778
|
+
if (
|
|
779
|
+
typeof tag !== "string"
|
|
780
|
+
|| tag.length === 0
|
|
781
|
+
) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`Bombadil trace line ${String(lineNumber)} has an invalid action target tag`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (
|
|
787
|
+
typeof value.structural_path === "string"
|
|
788
|
+
&& Object.keys(value).some((key) => (
|
|
789
|
+
key !== "tag" && key !== "structural_path"
|
|
790
|
+
))
|
|
791
|
+
) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`Bombadil trace line ${String(lineNumber)} has an invalid action target`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
return tag.length <= 64 && TARGET_TAG_PATTERN.test(tag)
|
|
797
|
+
? tag
|
|
798
|
+
: `sha256:${sha256(tag)}`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isSafeIntegerBetween(
|
|
802
|
+
value: unknown,
|
|
803
|
+
minimum: number,
|
|
804
|
+
maximum: number,
|
|
805
|
+
): value is number {
|
|
806
|
+
return typeof value === "number"
|
|
807
|
+
&& Number.isSafeInteger(value)
|
|
808
|
+
&& value >= minimum
|
|
809
|
+
&& value <= maximum;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function invalidTraceAction(lineNumber: number): never {
|
|
813
|
+
throw new Error(
|
|
814
|
+
`Bombadil trace line ${String(lineNumber)} has an invalid action`,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function parseTraceAction(value: unknown, lineNumber: number): ParsedTraceAction | null {
|
|
819
|
+
if (value === null) return null;
|
|
820
|
+
if (typeof value === "string") {
|
|
821
|
+
if (!ACTION_KIND_SET.has(value) || !UNIT_ACTION_KINDS.has(value as DirectBombadilActionKind)) {
|
|
822
|
+
return invalidTraceAction(lineNumber);
|
|
823
|
+
}
|
|
824
|
+
return { kind: value as DirectBombadilActionKind, targetTag: null };
|
|
825
|
+
}
|
|
826
|
+
if (!isRecord(value) || Object.keys(value).length !== 1) {
|
|
827
|
+
return invalidTraceAction(lineNumber);
|
|
828
|
+
}
|
|
829
|
+
const kind = Object.keys(value)[0];
|
|
830
|
+
const payload = kind === undefined ? undefined : value[kind];
|
|
831
|
+
if (
|
|
832
|
+
kind === undefined
|
|
833
|
+
|| !ACTION_KIND_SET.has(kind)
|
|
834
|
+
|| UNIT_ACTION_KINDS.has(kind as DirectBombadilActionKind)
|
|
835
|
+
|| !isRecord(payload)
|
|
836
|
+
) {
|
|
837
|
+
return invalidTraceAction(lineNumber);
|
|
838
|
+
}
|
|
839
|
+
const actionKind = kind as DirectBombadilActionKind;
|
|
840
|
+
let targetTag: string | null = null;
|
|
841
|
+
switch (actionKind) {
|
|
842
|
+
case "Click":
|
|
843
|
+
if (
|
|
844
|
+
!hasExactKeys(payload, TRACE_CLICK_ACTION_KEYS)
|
|
845
|
+
|| !validTracePoint(payload.point)
|
|
846
|
+
) {
|
|
847
|
+
return invalidTraceAction(lineNumber);
|
|
848
|
+
}
|
|
849
|
+
targetTag = parseTraceFingerprintTag(payload.fingerprint, lineNumber);
|
|
850
|
+
break;
|
|
851
|
+
case "DoubleClick":
|
|
852
|
+
if (
|
|
853
|
+
!hasExactKeys(payload, TRACE_DOUBLE_CLICK_ACTION_KEYS)
|
|
854
|
+
|| !isSafeIntegerBetween(payload.delay_millis, 0, 1_000)
|
|
855
|
+
|| !validTracePoint(payload.point)
|
|
856
|
+
) return invalidTraceAction(lineNumber);
|
|
857
|
+
targetTag = parseTraceFingerprintTag(payload.fingerprint, lineNumber);
|
|
858
|
+
break;
|
|
859
|
+
case "TypeText":
|
|
860
|
+
if (
|
|
861
|
+
!hasExactKeys(payload, TRACE_TYPE_TEXT_ACTION_KEYS)
|
|
862
|
+
|| !isSafeIntegerBetween(payload.delay_millis, 0, Number.MAX_SAFE_INTEGER)
|
|
863
|
+
|| typeof payload.text !== "string"
|
|
864
|
+
) return invalidTraceAction(lineNumber);
|
|
865
|
+
break;
|
|
866
|
+
case "PressKey":
|
|
867
|
+
if (
|
|
868
|
+
!hasExactKeys(payload, TRACE_PRESS_KEY_ACTION_KEYS)
|
|
869
|
+
|| !isSafeIntegerBetween(payload.code, 0, 255)
|
|
870
|
+
) {
|
|
871
|
+
return invalidTraceAction(lineNumber);
|
|
872
|
+
}
|
|
873
|
+
break;
|
|
874
|
+
case "ScrollDown":
|
|
875
|
+
case "ScrollUp":
|
|
876
|
+
if (
|
|
877
|
+
!hasExactKeys(payload, TRACE_SCROLL_ACTION_KEYS)
|
|
878
|
+
|| typeof payload.distance !== "number"
|
|
879
|
+
|| !Number.isFinite(payload.distance)
|
|
880
|
+
|| !validTracePoint(payload.origin)
|
|
881
|
+
) return invalidTraceAction(lineNumber);
|
|
882
|
+
break;
|
|
883
|
+
case "SetFileInputFiles":
|
|
884
|
+
if (
|
|
885
|
+
!hasExactKeys(payload, TRACE_FILE_INPUT_ACTION_KEYS)
|
|
886
|
+
|| typeof payload.selector !== "string"
|
|
887
|
+
|| !Array.isArray(payload.files)
|
|
888
|
+
|| !payload.files.every((file) => typeof file === "string")
|
|
889
|
+
) return invalidTraceAction(lineNumber);
|
|
890
|
+
break;
|
|
891
|
+
case "MouseDrag":
|
|
892
|
+
if (
|
|
893
|
+
!hasExactKeys(payload, TRACE_MOUSE_DRAG_ACTION_KEYS)
|
|
894
|
+
|| !isSafeIntegerBetween(payload.delay_millis, 0, 1_000)
|
|
895
|
+
|| !isSafeIntegerBetween(payload.steps, 1, 255)
|
|
896
|
+
|| !validTracePoint(payload.from)
|
|
897
|
+
|| !validTracePoint(payload.to)
|
|
898
|
+
) return invalidTraceAction(lineNumber);
|
|
899
|
+
break;
|
|
900
|
+
case "SetViewport":
|
|
901
|
+
if (
|
|
902
|
+
!hasExactKeys(payload, TRACE_VIEWPORT_ACTION_KEYS)
|
|
903
|
+
|| !isSafeIntegerBetween(payload.height, 1, 10_000)
|
|
904
|
+
|| !isSafeIntegerBetween(payload.width, 1, 10_000)
|
|
905
|
+
) return invalidTraceAction(lineNumber);
|
|
906
|
+
break;
|
|
907
|
+
default:
|
|
908
|
+
return invalidTraceAction(lineNumber);
|
|
909
|
+
}
|
|
910
|
+
return { kind: actionKind, targetTag };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function parseNonNegativeFiniteNumber(
|
|
914
|
+
value: unknown,
|
|
915
|
+
lineNumber: number,
|
|
916
|
+
field: string,
|
|
917
|
+
): number {
|
|
918
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
919
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid ${field}`);
|
|
920
|
+
}
|
|
921
|
+
return value;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function parseTraceState(value: unknown, lineNumber: number): ParsedTraceState {
|
|
925
|
+
if (!isRecord(value) || !hasExactKeys(value, TRACE_STATE_KEYS)) {
|
|
926
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser state`);
|
|
927
|
+
}
|
|
928
|
+
if (typeof value.url !== "string" || value.url.length === 0 || value.url.length > 8_192) {
|
|
929
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL`);
|
|
930
|
+
}
|
|
931
|
+
let url: URL;
|
|
932
|
+
try {
|
|
933
|
+
url = new URL(value.url);
|
|
934
|
+
} catch {
|
|
935
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL`);
|
|
936
|
+
}
|
|
937
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
938
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL protocol`);
|
|
939
|
+
}
|
|
940
|
+
if (typeof value.screenshot !== "string" || value.screenshot.length > 8_192) {
|
|
941
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid screenshot path`);
|
|
942
|
+
}
|
|
943
|
+
for (const field of ["hash_previous", "hash_current"] as const) {
|
|
944
|
+
const hash = value[field];
|
|
945
|
+
if (hash !== null && (
|
|
946
|
+
typeof hash !== "number"
|
|
947
|
+
|| !Number.isFinite(hash)
|
|
948
|
+
|| !Number.isInteger(hash)
|
|
949
|
+
|| hash < 0
|
|
950
|
+
)) {
|
|
951
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid ${field}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (!isRecord(value.resources) || !hasExactKeys(value.resources, TRACE_RESOURCE_KEYS)) {
|
|
955
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid browser resources`);
|
|
956
|
+
}
|
|
957
|
+
const resources: Record<string, number> = {};
|
|
958
|
+
for (const field of Object.keys(RESOURCE_FIELD_MAP) as (keyof typeof RESOURCE_FIELD_MAP)[]) {
|
|
959
|
+
resources[field] = parseNonNegativeFiniteNumber(
|
|
960
|
+
value.resources[field],
|
|
961
|
+
lineNumber,
|
|
962
|
+
`resources.${field}`,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
parseNonNegativeFiniteNumber(value.resources.timestamp, lineNumber, "resources.timestamp");
|
|
966
|
+
return {
|
|
967
|
+
currentHash: value.hash_current as number | null,
|
|
968
|
+
resources: resources as Readonly<Record<keyof typeof RESOURCE_FIELD_MAP, number>>,
|
|
969
|
+
url,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function parseTraceEnvelope(line: string, lineNumber: number): ParsedTraceEnvelope {
|
|
431
974
|
let input: unknown;
|
|
432
975
|
try {
|
|
433
976
|
input = JSON.parse(line) as unknown;
|
|
@@ -447,9 +990,22 @@ function parseTraceLine(line: string, lineNumber: number): TraceDirectObservatio
|
|
|
447
990
|
) {
|
|
448
991
|
throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid state fields`);
|
|
449
992
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
993
|
+
return {
|
|
994
|
+
action: input.action,
|
|
995
|
+
snapshots: input.snapshots as unknown[],
|
|
996
|
+
state: input.state,
|
|
997
|
+
timestamp: input.timestamp,
|
|
998
|
+
violations: input.violations,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function parseDirectTraceObservation(
|
|
1003
|
+
envelope: ParsedTraceEnvelope,
|
|
1004
|
+
lineNumber: number,
|
|
1005
|
+
): ParsedDirectTraceObservation {
|
|
1006
|
+
const directSnapshots = envelope.snapshots.filter(
|
|
1007
|
+
(snapshot): snapshot is Readonly<Record<string, unknown>> =>
|
|
1008
|
+
isRecord(snapshot) && snapshot.name === "direct",
|
|
453
1009
|
);
|
|
454
1010
|
if (directSnapshots.length !== 1) {
|
|
455
1011
|
throw new Error(`Bombadil trace line ${String(lineNumber)} must contain one named direct snapshot`);
|
|
@@ -465,7 +1021,97 @@ function parseTraceLine(line: string, lineNumber: number): TraceDirectObservatio
|
|
|
465
1021
|
) {
|
|
466
1022
|
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid direct snapshot`);
|
|
467
1023
|
}
|
|
468
|
-
return
|
|
1024
|
+
return {
|
|
1025
|
+
observation: parseTraceDirectObservation(snapshot.value),
|
|
1026
|
+
value: snapshot.value,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function parseDirectTraceLine(line: string, lineNumber: number): TraceDirectObservation {
|
|
1031
|
+
return parseDirectTraceObservation(parseTraceEnvelope(line, lineNumber), lineNumber).observation;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function parseTraceLine(
|
|
1035
|
+
line: string,
|
|
1036
|
+
lineNumber: number,
|
|
1037
|
+
strictDiagnosticSnapshotNames: ReadonlySet<string>,
|
|
1038
|
+
): ParsedTraceLine {
|
|
1039
|
+
const envelope = parseTraceEnvelope(line, lineNumber);
|
|
1040
|
+
const state = parseTraceState(envelope.state, lineNumber);
|
|
1041
|
+
const action = parseTraceAction(envelope.action, lineNumber);
|
|
1042
|
+
const snapshots = envelope.snapshots;
|
|
1043
|
+
const direct = parseDirectTraceObservation(envelope, lineNumber);
|
|
1044
|
+
const namedSnapshots: Array<{ readonly name: string; readonly valueSha256: string }> = [{
|
|
1045
|
+
name: "direct",
|
|
1046
|
+
valueSha256: namedSnapshotValueSha256(direct.value, {
|
|
1047
|
+
maximumBytes: TRACE_MAX_LINE_BYTES,
|
|
1048
|
+
maximumDepth: TRACE_MAX_JSON_DEPTH + 4,
|
|
1049
|
+
}),
|
|
1050
|
+
}];
|
|
1051
|
+
const diagnosticSnapshotValues = new Map<string, unknown[]>();
|
|
1052
|
+
for (const snapshotValue of snapshots) {
|
|
1053
|
+
if (
|
|
1054
|
+
!isRecord(snapshotValue)
|
|
1055
|
+
|| !hasExactKeys(snapshotValue, TRACE_SNAPSHOT_KEYS)
|
|
1056
|
+
|| !Number.isSafeInteger(snapshotValue.index)
|
|
1057
|
+
|| !Number.isSafeInteger(snapshotValue.time)
|
|
1058
|
+
|| (snapshotValue.index as number) < 0
|
|
1059
|
+
|| (snapshotValue.time as number) < 0
|
|
1060
|
+
|| (snapshotValue.name !== null && typeof snapshotValue.name !== "string")
|
|
1061
|
+
) {
|
|
1062
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid snapshot`);
|
|
1063
|
+
}
|
|
1064
|
+
if (snapshotValue.name === null || snapshotValue.name === "direct") continue;
|
|
1065
|
+
let name: string;
|
|
1066
|
+
try {
|
|
1067
|
+
name = validateSnapshotName(
|
|
1068
|
+
snapshotValue.name,
|
|
1069
|
+
`Bombadil trace line ${String(lineNumber)} snapshot name`,
|
|
1070
|
+
);
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
if (strictDiagnosticSnapshotNames.has(snapshotValue.name)) throw error;
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
const values = diagnosticSnapshotValues.get(name) ?? [];
|
|
1076
|
+
values.push(snapshotValue.value);
|
|
1077
|
+
diagnosticSnapshotValues.set(name, values);
|
|
1078
|
+
}
|
|
1079
|
+
for (const [name, values] of diagnosticSnapshotValues) {
|
|
1080
|
+
if (values.length !== 1) {
|
|
1081
|
+
if (strictDiagnosticSnapshotNames.has(name)) {
|
|
1082
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} repeats named snapshot ${name}`);
|
|
1083
|
+
}
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
try {
|
|
1087
|
+
namedSnapshots.push({
|
|
1088
|
+
name,
|
|
1089
|
+
valueSha256: namedSnapshotValueSha256(values[0]),
|
|
1090
|
+
});
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
if (strictDiagnosticSnapshotNames.has(name)) throw error;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const propertyViolationNames: string[] = [];
|
|
1096
|
+
for (const violation of envelope.violations) {
|
|
1097
|
+
if (!isRecord(violation) || !hasExactKeys(violation, TRACE_VIOLATION_KEYS)) {
|
|
1098
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid property violation`);
|
|
1099
|
+
}
|
|
1100
|
+
propertyViolationNames.push(validateSnapshotName(
|
|
1101
|
+
violation.name,
|
|
1102
|
+
`Bombadil trace line ${String(lineNumber)} property violation name`,
|
|
1103
|
+
));
|
|
1104
|
+
if (!isRecord(violation.violation) || Object.keys(violation.violation).length !== 1) {
|
|
1105
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid property violation`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
action,
|
|
1110
|
+
directObservation: direct.observation,
|
|
1111
|
+
namedSnapshots,
|
|
1112
|
+
propertyViolationNames,
|
|
1113
|
+
state,
|
|
1114
|
+
};
|
|
469
1115
|
}
|
|
470
1116
|
|
|
471
1117
|
/** Exact post-run proof over Bombadil 0.7.2's bounded JSONL trace. */
|
|
@@ -499,7 +1145,7 @@ export async function attestDirectBombadilTrace(options: {
|
|
|
499
1145
|
if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
|
|
500
1146
|
throw new Error(`Bombadil trace line ${String(observationCount)} is too large`);
|
|
501
1147
|
}
|
|
502
|
-
const observation =
|
|
1148
|
+
const observation = parseDirectTraceLine(line, observationCount);
|
|
503
1149
|
const exact = exactTraceDirectObservation(observation);
|
|
504
1150
|
if (exact === null) {
|
|
505
1151
|
if (initial !== null) {
|
|
@@ -577,6 +1223,332 @@ export async function attestDirectBombadilTrace(options: {
|
|
|
577
1223
|
};
|
|
578
1224
|
}
|
|
579
1225
|
|
|
1226
|
+
function sortedCountRecord<K extends string>(
|
|
1227
|
+
values: ReadonlyMap<K, number>,
|
|
1228
|
+
): Readonly<Partial<Record<K, number>>> {
|
|
1229
|
+
return Object.freeze(Object.fromEntries(
|
|
1230
|
+
[...values.entries()].sort(([left], [right]) => compareCodeUnits(left, right)),
|
|
1231
|
+
) as Partial<Record<K, number>>);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Derives bounded diagnostic exploration metadata from the raw Bombadil trace.
|
|
1236
|
+
* The hashes and counts are navigation aids, not Direct coverage evidence.
|
|
1237
|
+
*/
|
|
1238
|
+
export async function summarizeDirectBombadilTrace(options: {
|
|
1239
|
+
readonly explorationPolicy?: DirectBombadilExplorationPolicy;
|
|
1240
|
+
readonly targetUrl: string;
|
|
1241
|
+
readonly tracePath: string;
|
|
1242
|
+
}): Promise<DirectBombadilExplorationSummary> {
|
|
1243
|
+
const metadata = await stat(options.tracePath).catch(() => null);
|
|
1244
|
+
if (metadata === null || !metadata.isFile() || metadata.size === 0) {
|
|
1245
|
+
throw new Error("Bombadil did not produce a nonempty trace.jsonl");
|
|
1246
|
+
}
|
|
1247
|
+
if (metadata.size > TRACE_MAX_BYTES) {
|
|
1248
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`);
|
|
1249
|
+
}
|
|
1250
|
+
let targetUrl: URL;
|
|
1251
|
+
try {
|
|
1252
|
+
targetUrl = new URL(options.targetUrl);
|
|
1253
|
+
} catch {
|
|
1254
|
+
throw new Error("targetUrl must be an absolute URL");
|
|
1255
|
+
}
|
|
1256
|
+
const policy = validateExplorationPolicy(options.explorationPolicy);
|
|
1257
|
+
const strictDiagnosticSnapshotNames = explorationPolicySnapshotNames(policy);
|
|
1258
|
+
const actionCounts = new Map<DirectBombadilActionKind, number>();
|
|
1259
|
+
const targetTags = new Map<string, number>();
|
|
1260
|
+
const urlFingerprints = new Set<string>();
|
|
1261
|
+
const rawUrlFingerprints = new Set<string>();
|
|
1262
|
+
const transitionHashes = new Set<string>();
|
|
1263
|
+
const rawTransitionHashes = new Set<string>();
|
|
1264
|
+
const snapshots = new Map<string, {
|
|
1265
|
+
readonly changeAfterActionKind: Map<DirectBombadilActionKind, number>;
|
|
1266
|
+
changeAfterNonWaitCount: number;
|
|
1267
|
+
lastObservationIndex: number | null;
|
|
1268
|
+
lastValueSha256: string | null;
|
|
1269
|
+
observationCount: number;
|
|
1270
|
+
readonly values: Set<string>;
|
|
1271
|
+
}>();
|
|
1272
|
+
const propertyViolations = new Map<string, number>();
|
|
1273
|
+
const resources = {
|
|
1274
|
+
documents: 0,
|
|
1275
|
+
domNodes: 0,
|
|
1276
|
+
jsEventListeners: 0,
|
|
1277
|
+
jsHeapTotalBytes: 0,
|
|
1278
|
+
jsHeapUsedBytes: 0,
|
|
1279
|
+
layoutObjects: 0,
|
|
1280
|
+
scriptDurationSeconds: 0,
|
|
1281
|
+
taskDurationSeconds: 0,
|
|
1282
|
+
threadTimeSeconds: 0,
|
|
1283
|
+
};
|
|
1284
|
+
let lineCount = 0;
|
|
1285
|
+
let totalActions = 0;
|
|
1286
|
+
let nonWaitCount = 0;
|
|
1287
|
+
let waitStreak = 0;
|
|
1288
|
+
let maxWaitStreak = 0;
|
|
1289
|
+
let nonNullHashCount = 0;
|
|
1290
|
+
let rawNonNullHashCount = 0;
|
|
1291
|
+
let policyObservationCount = 0;
|
|
1292
|
+
let previousObservationWasExact = false;
|
|
1293
|
+
let stableTarget = true;
|
|
1294
|
+
let trackedUnrelatedSnapshotNameCount = 0;
|
|
1295
|
+
const unrelatedSnapshotNameLimit = Math.max(
|
|
1296
|
+
0,
|
|
1297
|
+
TRACE_MAX_NAMED_SNAPSHOT_NAMES - strictDiagnosticSnapshotNames.size,
|
|
1298
|
+
);
|
|
1299
|
+
const stream = createReadStream(options.tracePath, { encoding: "utf8" });
|
|
1300
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
1301
|
+
try {
|
|
1302
|
+
for await (const line of lines) {
|
|
1303
|
+
lineCount += 1;
|
|
1304
|
+
if (lineCount > TRACE_MAX_LINES) {
|
|
1305
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`);
|
|
1306
|
+
}
|
|
1307
|
+
if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
|
|
1308
|
+
throw new Error(`Bombadil trace line ${String(lineCount)} is too large`);
|
|
1309
|
+
}
|
|
1310
|
+
const parsed = parseTraceLine(line, lineCount, strictDiagnosticSnapshotNames);
|
|
1311
|
+
const rawRelativeUrl =
|
|
1312
|
+
`${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`;
|
|
1313
|
+
rawUrlFingerprints.add(sha256(rawRelativeUrl));
|
|
1314
|
+
if (rawUrlFingerprints.size > TRACE_MAX_DISTINCT_URLS) {
|
|
1315
|
+
throw new Error(
|
|
1316
|
+
`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct raw URL fingerprints`,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
if (parsed.state.currentHash !== null) {
|
|
1320
|
+
rawNonNullHashCount += 1;
|
|
1321
|
+
rawTransitionHashes.add(String(parsed.state.currentHash));
|
|
1322
|
+
}
|
|
1323
|
+
for (const name of parsed.propertyViolationNames) {
|
|
1324
|
+
if (!propertyViolations.has(name) && propertyViolations.size >= TRACE_MAX_PROPERTY_NAMES) {
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
`Bombadil trace exceeds ${String(TRACE_MAX_PROPERTY_NAMES)} property names`,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
propertyViolations.set(name, (propertyViolations.get(name) ?? 0) + 1);
|
|
1330
|
+
}
|
|
1331
|
+
for (const [sourceName, outputName] of Object.entries(RESOURCE_FIELD_MAP)) {
|
|
1332
|
+
resources[outputName] = Math.max(
|
|
1333
|
+
resources[outputName],
|
|
1334
|
+
parsed.state.resources[sourceName as keyof typeof RESOURCE_FIELD_MAP],
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
const currentObservationIsExact =
|
|
1338
|
+
exactTraceDirectObservation(parsed.directObservation) !== null;
|
|
1339
|
+
if (!currentObservationIsExact) {
|
|
1340
|
+
previousObservationWasExact = false;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
policyObservationCount += 1;
|
|
1344
|
+
const actionFollowsExactObservation = previousObservationWasExact;
|
|
1345
|
+
const recordedActionKind = actionFollowsExactObservation
|
|
1346
|
+
? parsed.action?.kind ?? null
|
|
1347
|
+
: null;
|
|
1348
|
+
|
|
1349
|
+
if (actionFollowsExactObservation && parsed.action !== null) {
|
|
1350
|
+
totalActions += 1;
|
|
1351
|
+
actionCounts.set(parsed.action.kind, (actionCounts.get(parsed.action.kind) ?? 0) + 1);
|
|
1352
|
+
if (parsed.action.kind === "Wait") {
|
|
1353
|
+
waitStreak += 1;
|
|
1354
|
+
maxWaitStreak = Math.max(maxWaitStreak, waitStreak);
|
|
1355
|
+
} else {
|
|
1356
|
+
nonWaitCount += 1;
|
|
1357
|
+
waitStreak = 0;
|
|
1358
|
+
}
|
|
1359
|
+
if (parsed.action.targetTag !== null) {
|
|
1360
|
+
if (!targetTags.has(parsed.action.targetTag) && targetTags.size >= 128) {
|
|
1361
|
+
throw new Error("Bombadil trace exceeds 128 distinct action target tags");
|
|
1362
|
+
}
|
|
1363
|
+
targetTags.set(
|
|
1364
|
+
parsed.action.targetTag,
|
|
1365
|
+
(targetTags.get(parsed.action.targetTag) ?? 0) + 1,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
} else if (actionFollowsExactObservation) {
|
|
1369
|
+
waitStreak = 0;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const relativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`;
|
|
1373
|
+
urlFingerprints.add(sha256(relativeUrl));
|
|
1374
|
+
if (urlFingerprints.size > TRACE_MAX_DISTINCT_URLS) {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
`Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct URL fingerprints`,
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
stableTarget &&= parsed.state.url.href === targetUrl.href;
|
|
1380
|
+
if (parsed.state.currentHash !== null) {
|
|
1381
|
+
nonNullHashCount += 1;
|
|
1382
|
+
transitionHashes.add(String(parsed.state.currentHash));
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
for (const snapshot of parsed.namedSnapshots) {
|
|
1386
|
+
let entry = snapshots.get(snapshot.name);
|
|
1387
|
+
if (entry === undefined) {
|
|
1388
|
+
const isStrictSnapshot = snapshot.name === "direct"
|
|
1389
|
+
|| strictDiagnosticSnapshotNames.has(snapshot.name);
|
|
1390
|
+
if (!isStrictSnapshot && trackedUnrelatedSnapshotNameCount >= unrelatedSnapshotNameLimit) {
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (snapshots.size >= TRACE_MAX_NAMED_SNAPSHOT_NAMES) {
|
|
1394
|
+
throw new Error(
|
|
1395
|
+
`Bombadil trace exceeds ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES)} named snapshots`,
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
entry = {
|
|
1399
|
+
changeAfterActionKind: new Map<DirectBombadilActionKind, number>(),
|
|
1400
|
+
changeAfterNonWaitCount: 0,
|
|
1401
|
+
lastObservationIndex: null,
|
|
1402
|
+
lastValueSha256: null,
|
|
1403
|
+
observationCount: 0,
|
|
1404
|
+
values: new Set<string>(),
|
|
1405
|
+
};
|
|
1406
|
+
snapshots.set(snapshot.name, entry);
|
|
1407
|
+
if (!isStrictSnapshot) trackedUnrelatedSnapshotNameCount += 1;
|
|
1408
|
+
}
|
|
1409
|
+
if (
|
|
1410
|
+
!entry.values.has(snapshot.valueSha256)
|
|
1411
|
+
&& entry.values.size >= TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME
|
|
1412
|
+
) {
|
|
1413
|
+
if (
|
|
1414
|
+
snapshot.name === "direct"
|
|
1415
|
+
|| strictDiagnosticSnapshotNames.has(snapshot.name)
|
|
1416
|
+
) {
|
|
1417
|
+
throw new Error(
|
|
1418
|
+
`Bombadil trace named snapshot ${snapshot.name} exceeds ${String(TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME)} distinct values`,
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const changedAfterRecordedAction =
|
|
1424
|
+
recordedActionKind !== null
|
|
1425
|
+
&& entry.lastObservationIndex === policyObservationCount - 1
|
|
1426
|
+
&& entry.lastValueSha256 !== null
|
|
1427
|
+
&& entry.lastValueSha256 !== snapshot.valueSha256;
|
|
1428
|
+
if (changedAfterRecordedAction) {
|
|
1429
|
+
entry.changeAfterActionKind.set(
|
|
1430
|
+
recordedActionKind,
|
|
1431
|
+
(entry.changeAfterActionKind.get(recordedActionKind) ?? 0) + 1,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
if (changedAfterRecordedAction && recordedActionKind !== "Wait") {
|
|
1435
|
+
entry.changeAfterNonWaitCount += 1;
|
|
1436
|
+
}
|
|
1437
|
+
entry.lastObservationIndex = policyObservationCount;
|
|
1438
|
+
entry.lastValueSha256 = snapshot.valueSha256;
|
|
1439
|
+
entry.observationCount += 1;
|
|
1440
|
+
entry.values.add(snapshot.valueSha256);
|
|
1441
|
+
}
|
|
1442
|
+
previousObservationWasExact = true;
|
|
1443
|
+
}
|
|
1444
|
+
} finally {
|
|
1445
|
+
lines.close();
|
|
1446
|
+
stream.destroy();
|
|
1447
|
+
}
|
|
1448
|
+
if (lineCount === 0) throw new Error("Bombadil did not produce a nonempty trace.jsonl");
|
|
1449
|
+
|
|
1450
|
+
const policyFailures: string[] = [];
|
|
1451
|
+
if (policy !== null) {
|
|
1452
|
+
if (nonWaitCount < policy.minNonWaitActions) {
|
|
1453
|
+
policyFailures.push("minimum non-Wait action count was not reached");
|
|
1454
|
+
}
|
|
1455
|
+
for (const kind of policy.requiredActionKinds) {
|
|
1456
|
+
if ((actionCounts.get(kind) ?? 0) === 0) {
|
|
1457
|
+
policyFailures.push(`required action kind ${kind} was not observed`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
for (const name of policy.requiredNamedSnapshots) {
|
|
1461
|
+
if (!snapshots.has(name)) {
|
|
1462
|
+
policyFailures.push(`required named snapshot ${name} was not observed`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
for (const [name, minimum] of Object.entries(policy.minDistinctNamedSnapshotValues)) {
|
|
1466
|
+
if ((snapshots.get(name)?.values.size ?? 0) < minimum) {
|
|
1467
|
+
policyFailures.push(`named snapshot ${name} did not reach its distinct-value minimum`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
for (const [name, minimum] of Object.entries(
|
|
1471
|
+
policy.minNamedSnapshotChangesAfterNonWait,
|
|
1472
|
+
)) {
|
|
1473
|
+
if ((snapshots.get(name)?.changeAfterNonWaitCount ?? 0) < minimum) {
|
|
1474
|
+
policyFailures.push(
|
|
1475
|
+
`named snapshot ${name} did not reach its post-non-Wait change minimum`,
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
for (const [name, minimumByKind] of Object.entries(
|
|
1480
|
+
policy.minNamedSnapshotChangesAfterActionKind,
|
|
1481
|
+
)) {
|
|
1482
|
+
for (const [kind, minimum] of Object.entries(minimumByKind) as [
|
|
1483
|
+
DirectBombadilActionKind,
|
|
1484
|
+
number,
|
|
1485
|
+
][]) {
|
|
1486
|
+
if ((snapshots.get(name)?.changeAfterActionKind.get(kind) ?? 0) < minimum) {
|
|
1487
|
+
policyFailures.push(
|
|
1488
|
+
`named snapshot ${name} did not reach its post-${kind} change minimum`,
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (policy.requireStableTargetUrl && !stableTarget) {
|
|
1494
|
+
policyFailures.push("the browser did not remain on the exact target URL");
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const traceBytes = await readFile(options.tracePath);
|
|
1498
|
+
return Object.freeze({
|
|
1499
|
+
schema: "direct.bombadil-exploration-summary/v2",
|
|
1500
|
+
trace: Object.freeze({
|
|
1501
|
+
bytes: metadata.size,
|
|
1502
|
+
lineCount,
|
|
1503
|
+
sha256: sha256(traceBytes),
|
|
1504
|
+
}),
|
|
1505
|
+
actions: Object.freeze({
|
|
1506
|
+
byKind: sortedCountRecord(actionCounts),
|
|
1507
|
+
maxWaitStreak,
|
|
1508
|
+
nonWaitCount,
|
|
1509
|
+
targetTags: sortedCountRecord(targetTags) as Readonly<Record<string, number>>,
|
|
1510
|
+
total: totalActions,
|
|
1511
|
+
}),
|
|
1512
|
+
urls: Object.freeze({
|
|
1513
|
+
distinctFingerprintCount: urlFingerprints.size,
|
|
1514
|
+
fingerprintSha256: Object.freeze([...urlFingerprints].sort(compareCodeUnits)),
|
|
1515
|
+
observationCount: policyObservationCount,
|
|
1516
|
+
rawDistinctFingerprintCount: rawUrlFingerprints.size,
|
|
1517
|
+
rawFingerprintSha256: Object.freeze(
|
|
1518
|
+
[...rawUrlFingerprints].sort(compareCodeUnits),
|
|
1519
|
+
),
|
|
1520
|
+
rawObservationCount: lineCount,
|
|
1521
|
+
stableTarget,
|
|
1522
|
+
}),
|
|
1523
|
+
transitions: Object.freeze({
|
|
1524
|
+
distinctNonNullHashCount: transitionHashes.size,
|
|
1525
|
+
nonNullHashCount,
|
|
1526
|
+
rawDistinctNonNullHashCount: rawTransitionHashes.size,
|
|
1527
|
+
rawNonNullHashCount,
|
|
1528
|
+
}),
|
|
1529
|
+
namedSnapshots: Object.freeze([...snapshots.entries()]
|
|
1530
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
1531
|
+
.map(([name, entry]) => Object.freeze({
|
|
1532
|
+
changeAfterActionKind: sortedCountRecord(entry.changeAfterActionKind),
|
|
1533
|
+
changeAfterNonWaitCount: entry.changeAfterNonWaitCount,
|
|
1534
|
+
distinctValueCount: entry.values.size,
|
|
1535
|
+
distinctValueSha256: Object.freeze([...entry.values].sort(compareCodeUnits)),
|
|
1536
|
+
name,
|
|
1537
|
+
observationCount: entry.observationCount,
|
|
1538
|
+
}))),
|
|
1539
|
+
propertyViolations: Object.freeze({
|
|
1540
|
+
byName: sortedCountRecord(propertyViolations) as Readonly<Record<string, number>>,
|
|
1541
|
+
total: [...propertyViolations.values()].reduce((total, value) => total + value, 0),
|
|
1542
|
+
}),
|
|
1543
|
+
resourceHighWaterMarks: Object.freeze(resources),
|
|
1544
|
+
policy: Object.freeze({
|
|
1545
|
+
configured: policy !== null,
|
|
1546
|
+
failures: Object.freeze(policyFailures),
|
|
1547
|
+
satisfied: policyFailures.length === 0,
|
|
1548
|
+
}),
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
580
1552
|
export function parseDirectBombadilFuzzArguments(
|
|
581
1553
|
arguments_: readonly string[],
|
|
582
1554
|
defaultBaseUrl: string,
|
|
@@ -689,7 +1661,7 @@ function validateTargetQuery(value: unknown): Readonly<Record<string, string>> {
|
|
|
689
1661
|
}
|
|
690
1662
|
const validated: Record<string, string> = {};
|
|
691
1663
|
for (const [name, queryValue] of [...entries].sort(([left], [right]) =>
|
|
692
|
-
left
|
|
1664
|
+
compareCodeUnits(left, right)
|
|
693
1665
|
)) {
|
|
694
1666
|
if (
|
|
695
1667
|
name.length === 0
|
|
@@ -714,6 +1686,227 @@ function validateTargetQuery(value: unknown): Readonly<Record<string, string>> {
|
|
|
714
1686
|
return Object.freeze(validated);
|
|
715
1687
|
}
|
|
716
1688
|
|
|
1689
|
+
function validateSnapshotName(value: unknown, label: string): string {
|
|
1690
|
+
if (
|
|
1691
|
+
typeof value !== "string"
|
|
1692
|
+
|| value.length === 0
|
|
1693
|
+
|| value.length > 128
|
|
1694
|
+
|| !SNAPSHOT_NAME_PATTERN.test(value)
|
|
1695
|
+
|| PROTOTYPE_PROPERTY_NAMES.has(value)
|
|
1696
|
+
|| hasControlCharacters(value)
|
|
1697
|
+
) {
|
|
1698
|
+
throw new Error(`${label} must be a safe bounded snapshot name`);
|
|
1699
|
+
}
|
|
1700
|
+
return value;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function validateViewport(value: unknown): ValidatedViewport {
|
|
1704
|
+
if (value === undefined) {
|
|
1705
|
+
return Object.freeze({
|
|
1706
|
+
deviceScaleFactor: DEFAULT_DEVICE_SCALE_FACTOR,
|
|
1707
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
1708
|
+
width: DEFAULT_VIEWPORT_WIDTH,
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
if (!isRecord(value) || !Object.keys(value).every((key) => VIEWPORT_KEYS.has(key))) {
|
|
1712
|
+
throw new Error("viewport must contain only width, height, and deviceScaleFactor");
|
|
1713
|
+
}
|
|
1714
|
+
const validateDimension = (name: "height" | "width", input: unknown): number => {
|
|
1715
|
+
if (
|
|
1716
|
+
typeof input !== "number"
|
|
1717
|
+
|| !Number.isSafeInteger(input)
|
|
1718
|
+
|| input < 1
|
|
1719
|
+
|| input > 65_535
|
|
1720
|
+
) {
|
|
1721
|
+
throw new Error(`viewport.${name} must be an integer between 1 and 65535`);
|
|
1722
|
+
}
|
|
1723
|
+
return input;
|
|
1724
|
+
};
|
|
1725
|
+
const width = validateDimension("width", value.width ?? DEFAULT_VIEWPORT_WIDTH);
|
|
1726
|
+
const height = validateDimension("height", value.height ?? DEFAULT_VIEWPORT_HEIGHT);
|
|
1727
|
+
const deviceScaleFactor = value.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE_FACTOR;
|
|
1728
|
+
if (
|
|
1729
|
+
typeof deviceScaleFactor !== "number"
|
|
1730
|
+
|| !Number.isFinite(deviceScaleFactor)
|
|
1731
|
+
|| deviceScaleFactor < 0.1
|
|
1732
|
+
|| deviceScaleFactor > 10
|
|
1733
|
+
) {
|
|
1734
|
+
throw new Error("viewport.deviceScaleFactor must be a finite number between 0.1 and 10");
|
|
1735
|
+
}
|
|
1736
|
+
return Object.freeze({ deviceScaleFactor, height, width });
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function validateSnapshotMinimumMap(options: {
|
|
1740
|
+
readonly label: string;
|
|
1741
|
+
readonly maximum: number;
|
|
1742
|
+
readonly value: unknown;
|
|
1743
|
+
}): Readonly<Record<string, number>> {
|
|
1744
|
+
if (!isRecord(options.value) || Object.keys(options.value).length > 32) {
|
|
1745
|
+
throw new Error(`${options.label} must be a bounded object`);
|
|
1746
|
+
}
|
|
1747
|
+
const validated: Record<string, number> = {};
|
|
1748
|
+
for (const [rawName, minimum] of Object.entries(options.value).sort(([left], [right]) =>
|
|
1749
|
+
compareCodeUnits(left, right)
|
|
1750
|
+
)) {
|
|
1751
|
+
const name = validateSnapshotName(rawName, `${options.label} key`);
|
|
1752
|
+
if (
|
|
1753
|
+
typeof minimum !== "number"
|
|
1754
|
+
|| !Number.isSafeInteger(minimum)
|
|
1755
|
+
|| minimum < 1
|
|
1756
|
+
|| minimum > options.maximum
|
|
1757
|
+
) {
|
|
1758
|
+
throw new Error(
|
|
1759
|
+
`${options.label} ${name} must be an integer between 1 and ${String(options.maximum)}`,
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
validated[name] = minimum;
|
|
1763
|
+
}
|
|
1764
|
+
return Object.freeze(validated);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function validateSnapshotActionMinimumMap(options: {
|
|
1768
|
+
readonly label: string;
|
|
1769
|
+
readonly value: unknown;
|
|
1770
|
+
}): Readonly<Record<
|
|
1771
|
+
string,
|
|
1772
|
+
Readonly<Partial<Record<DirectBombadilActionKind, number>>>
|
|
1773
|
+
>> {
|
|
1774
|
+
if (!isRecord(options.value) || Object.keys(options.value).length > 32) {
|
|
1775
|
+
throw new Error(`${options.label} must be a bounded object`);
|
|
1776
|
+
}
|
|
1777
|
+
const validated: Record<
|
|
1778
|
+
string,
|
|
1779
|
+
Readonly<Partial<Record<DirectBombadilActionKind, number>>>
|
|
1780
|
+
> = {};
|
|
1781
|
+
for (const [rawName, rawMinimumByKind] of Object.entries(options.value)
|
|
1782
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1783
|
+
const name = validateSnapshotName(rawName, `${options.label} key`);
|
|
1784
|
+
if (
|
|
1785
|
+
!isRecord(rawMinimumByKind)
|
|
1786
|
+
|| Object.keys(rawMinimumByKind).length === 0
|
|
1787
|
+
|| Object.keys(rawMinimumByKind).length > ACTION_KINDS.length
|
|
1788
|
+
) {
|
|
1789
|
+
throw new Error(`${options.label} ${name} must be a bounded action map`);
|
|
1790
|
+
}
|
|
1791
|
+
const minimumByKind: Partial<Record<DirectBombadilActionKind, number>> = {};
|
|
1792
|
+
for (const [rawKind, minimum] of Object.entries(rawMinimumByKind)
|
|
1793
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1794
|
+
if (!ACTION_KIND_SET.has(rawKind)) {
|
|
1795
|
+
throw new Error(`${options.label} ${name} contains an unknown action kind`);
|
|
1796
|
+
}
|
|
1797
|
+
if (
|
|
1798
|
+
typeof minimum !== "number"
|
|
1799
|
+
|| !Number.isSafeInteger(minimum)
|
|
1800
|
+
|| minimum < 1
|
|
1801
|
+
|| minimum > TRACE_MAX_LINES
|
|
1802
|
+
) {
|
|
1803
|
+
throw new Error(
|
|
1804
|
+
`${options.label} ${name}.${rawKind} must be an integer between 1 and ${String(TRACE_MAX_LINES)}`,
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
minimumByKind[rawKind as DirectBombadilActionKind] = minimum;
|
|
1808
|
+
}
|
|
1809
|
+
validated[name] = Object.freeze(minimumByKind);
|
|
1810
|
+
}
|
|
1811
|
+
return Object.freeze(validated);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function explorationPolicySnapshotNames(
|
|
1815
|
+
policy: ValidatedExplorationPolicy | null,
|
|
1816
|
+
): ReadonlySet<string> {
|
|
1817
|
+
const names = new Set<string>(["direct"]);
|
|
1818
|
+
if (policy === null) return names;
|
|
1819
|
+
for (const name of policy.requiredNamedSnapshots) names.add(name);
|
|
1820
|
+
for (const name of Object.keys(policy.minDistinctNamedSnapshotValues)) names.add(name);
|
|
1821
|
+
for (const name of Object.keys(policy.minNamedSnapshotChangesAfterNonWait)) names.add(name);
|
|
1822
|
+
for (const name of Object.keys(policy.minNamedSnapshotChangesAfterActionKind)) names.add(name);
|
|
1823
|
+
return names;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function validateExplorationPolicy(
|
|
1827
|
+
value: unknown,
|
|
1828
|
+
): ValidatedExplorationPolicy | null {
|
|
1829
|
+
if (value === undefined) return null;
|
|
1830
|
+
if (
|
|
1831
|
+
!isRecord(value)
|
|
1832
|
+
|| !Object.keys(value).every((key) => EXPLORATION_POLICY_KEYS.has(key))
|
|
1833
|
+
) {
|
|
1834
|
+
throw new Error("explorationPolicy contains an unknown field");
|
|
1835
|
+
}
|
|
1836
|
+
const minNonWaitActions = value.minNonWaitActions ?? 0;
|
|
1837
|
+
if (
|
|
1838
|
+
typeof minNonWaitActions !== "number"
|
|
1839
|
+
|| !Number.isSafeInteger(minNonWaitActions)
|
|
1840
|
+
|| minNonWaitActions < 0
|
|
1841
|
+
|| minNonWaitActions > TRACE_MAX_LINES
|
|
1842
|
+
) {
|
|
1843
|
+
throw new Error(
|
|
1844
|
+
`explorationPolicy.minNonWaitActions must be an integer between 0 and ${String(TRACE_MAX_LINES)}`,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
const requiredActionKindsInput = value.requiredActionKinds ?? [];
|
|
1848
|
+
if (!Array.isArray(requiredActionKindsInput) || requiredActionKindsInput.length > ACTION_KINDS.length) {
|
|
1849
|
+
throw new Error("explorationPolicy.requiredActionKinds must be a bounded array");
|
|
1850
|
+
}
|
|
1851
|
+
const requiredActionKinds = [...requiredActionKindsInput];
|
|
1852
|
+
if (
|
|
1853
|
+
!requiredActionKinds.every((kind): kind is DirectBombadilActionKind =>
|
|
1854
|
+
typeof kind === "string" && ACTION_KIND_SET.has(kind)
|
|
1855
|
+
)
|
|
1856
|
+
|| new Set(requiredActionKinds).size !== requiredActionKinds.length
|
|
1857
|
+
) {
|
|
1858
|
+
throw new Error("explorationPolicy.requiredActionKinds contains an unknown or duplicate kind");
|
|
1859
|
+
}
|
|
1860
|
+
requiredActionKinds.sort(compareCodeUnits);
|
|
1861
|
+
|
|
1862
|
+
const requiredNamedSnapshotsInput = value.requiredNamedSnapshots ?? [];
|
|
1863
|
+
if (!Array.isArray(requiredNamedSnapshotsInput) || requiredNamedSnapshotsInput.length > 32) {
|
|
1864
|
+
throw new Error("explorationPolicy.requiredNamedSnapshots must be a bounded array");
|
|
1865
|
+
}
|
|
1866
|
+
const requiredNamedSnapshots = requiredNamedSnapshotsInput.map((name) =>
|
|
1867
|
+
validateSnapshotName(name, "explorationPolicy.requiredNamedSnapshots entry")
|
|
1868
|
+
);
|
|
1869
|
+
if (new Set(requiredNamedSnapshots).size !== requiredNamedSnapshots.length) {
|
|
1870
|
+
throw new Error("explorationPolicy.requiredNamedSnapshots contains a duplicate name");
|
|
1871
|
+
}
|
|
1872
|
+
requiredNamedSnapshots.sort(compareCodeUnits);
|
|
1873
|
+
|
|
1874
|
+
const minDistinctNamedSnapshotValues = validateSnapshotMinimumMap({
|
|
1875
|
+
label: "explorationPolicy.minDistinctNamedSnapshotValues",
|
|
1876
|
+
maximum: TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME,
|
|
1877
|
+
value: value.minDistinctNamedSnapshotValues ?? {},
|
|
1878
|
+
});
|
|
1879
|
+
const minNamedSnapshotChangesAfterActionKind =
|
|
1880
|
+
validateSnapshotActionMinimumMap({
|
|
1881
|
+
label: "explorationPolicy.minNamedSnapshotChangesAfterActionKind",
|
|
1882
|
+
value: value.minNamedSnapshotChangesAfterActionKind ?? {},
|
|
1883
|
+
});
|
|
1884
|
+
const minNamedSnapshotChangesAfterNonWait = validateSnapshotMinimumMap({
|
|
1885
|
+
label: "explorationPolicy.minNamedSnapshotChangesAfterNonWait",
|
|
1886
|
+
maximum: TRACE_MAX_LINES,
|
|
1887
|
+
value: value.minNamedSnapshotChangesAfterNonWait ?? {},
|
|
1888
|
+
});
|
|
1889
|
+
const requireStableTargetUrl = value.requireStableTargetUrl ?? false;
|
|
1890
|
+
if (typeof requireStableTargetUrl !== "boolean") {
|
|
1891
|
+
throw new Error("explorationPolicy.requireStableTargetUrl must be a boolean");
|
|
1892
|
+
}
|
|
1893
|
+
const validated: ValidatedExplorationPolicy = Object.freeze({
|
|
1894
|
+
minDistinctNamedSnapshotValues,
|
|
1895
|
+
minNamedSnapshotChangesAfterActionKind,
|
|
1896
|
+
minNamedSnapshotChangesAfterNonWait,
|
|
1897
|
+
minNonWaitActions,
|
|
1898
|
+
requireStableTargetUrl,
|
|
1899
|
+
requiredActionKinds: Object.freeze(requiredActionKinds),
|
|
1900
|
+
requiredNamedSnapshots: Object.freeze(requiredNamedSnapshots),
|
|
1901
|
+
});
|
|
1902
|
+
if (explorationPolicySnapshotNames(validated).size > TRACE_MAX_NAMED_SNAPSHOT_NAMES) {
|
|
1903
|
+
throw new Error(
|
|
1904
|
+
`explorationPolicy may reference at most ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES - 1)} distinct non-Direct snapshots`,
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
return validated;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
717
1910
|
export function validateDirectBombadilFuzzConfig(
|
|
718
1911
|
config: DirectBombadilFuzzConfig,
|
|
719
1912
|
baseUrlOverride?: string,
|
|
@@ -782,6 +1975,8 @@ export function validateDirectBombadilFuzzConfig(
|
|
|
782
1975
|
const entryPath = config.entryPath ?? "/";
|
|
783
1976
|
validateEntryPath(entryPath);
|
|
784
1977
|
const targetQuery = validateTargetQuery(config.targetQuery ?? {});
|
|
1978
|
+
const viewport = validateViewport(config.viewport);
|
|
1979
|
+
const explorationPolicy = validateExplorationPolicy(config.explorationPolicy);
|
|
785
1980
|
const startupTimeoutMs = config.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
786
1981
|
if (
|
|
787
1982
|
!Number.isSafeInteger(startupTimeoutMs)
|
|
@@ -803,8 +1998,10 @@ export function validateDirectBombadilFuzzConfig(
|
|
|
803
1998
|
artifactRoot: join(repositoryRoot, "artifacts", "direct-bombadil", config.artifactName),
|
|
804
1999
|
bombadilExecutable: bombadilNativeBinary(repositoryRoot),
|
|
805
2000
|
entryPath,
|
|
2001
|
+
explorationPolicy,
|
|
806
2002
|
port,
|
|
807
2003
|
targetQuery,
|
|
2004
|
+
viewport,
|
|
808
2005
|
server: {
|
|
809
2006
|
...config.server,
|
|
810
2007
|
cwd: serverCwd,
|
|
@@ -834,7 +2031,9 @@ export function createDirectBombadilInvocation(options: {
|
|
|
834
2031
|
readonly specificationPath: string;
|
|
835
2032
|
readonly targetQuery?: Readonly<Record<string, string>>;
|
|
836
2033
|
readonly timeLimitSeconds: number;
|
|
2034
|
+
readonly viewport?: DirectBombadilViewportConfig;
|
|
837
2035
|
}): DirectBombadilInvocation {
|
|
2036
|
+
const viewport = validateViewport(options.viewport);
|
|
838
2037
|
const target = new URL(options.entryPath ?? "/", `${options.baseUrl}/`);
|
|
839
2038
|
target.searchParams.set(SCENARIO_QUERY_KEY, options.scenario);
|
|
840
2039
|
for (const [name, value] of Object.entries(options.targetQuery ?? {})) {
|
|
@@ -850,6 +2049,12 @@ export function createDirectBombadilInvocation(options: {
|
|
|
850
2049
|
options.outputPath,
|
|
851
2050
|
"--headless",
|
|
852
2051
|
"--instrument-javascript=",
|
|
2052
|
+
"--width",
|
|
2053
|
+
String(viewport.width),
|
|
2054
|
+
"--height",
|
|
2055
|
+
String(viewport.height),
|
|
2056
|
+
"--device-scale-factor",
|
|
2057
|
+
String(viewport.deviceScaleFactor),
|
|
853
2058
|
];
|
|
854
2059
|
if (options.replayPath === null) {
|
|
855
2060
|
command.push(
|
|
@@ -1007,6 +2212,7 @@ export async function runBombadilNativeProcess(
|
|
|
1007
2212
|
|
|
1008
2213
|
const defaultDependencies: DirectBombadilRunnerDependencies = {
|
|
1009
2214
|
acquireServer: acquireVerificationServer,
|
|
2215
|
+
createAbortController: () => new AbortController(),
|
|
1010
2216
|
now: () => new Date(),
|
|
1011
2217
|
runBombadil: runBombadilNativeProcess,
|
|
1012
2218
|
serverOutputTimeoutMs: SERVER_OUTPUT_TIMEOUT_MS,
|
|
@@ -1181,6 +2387,113 @@ function helpText(defaultBaseUrl: string): string {
|
|
|
1181
2387
|
].join("\n");
|
|
1182
2388
|
}
|
|
1183
2389
|
|
|
2390
|
+
function parseMatrixCampaignArgument(arguments_: readonly string[]): {
|
|
2391
|
+
readonly arguments: readonly string[];
|
|
2392
|
+
readonly campaignId: string | null;
|
|
2393
|
+
readonly help: boolean;
|
|
2394
|
+
} {
|
|
2395
|
+
const forwarded: string[] = [];
|
|
2396
|
+
let campaignId: string | null = null;
|
|
2397
|
+
let help = false;
|
|
2398
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
2399
|
+
const argument = arguments_[index];
|
|
2400
|
+
if (argument === undefined) continue;
|
|
2401
|
+
if (argument === "--help" || argument === "-h") help = true;
|
|
2402
|
+
if (argument === "--campaign" || argument.startsWith("--campaign=")) {
|
|
2403
|
+
if (campaignId !== null) throw new Error("--campaign may be provided only once");
|
|
2404
|
+
if (argument === "--campaign") {
|
|
2405
|
+
const next = readOptionValue(arguments_, index, "--campaign");
|
|
2406
|
+
campaignId = next.value;
|
|
2407
|
+
index = next.index;
|
|
2408
|
+
} else {
|
|
2409
|
+
campaignId = argument.slice("--campaign=".length);
|
|
2410
|
+
}
|
|
2411
|
+
if (campaignId.length === 0) throw new Error("--campaign requires a value");
|
|
2412
|
+
continue;
|
|
2413
|
+
}
|
|
2414
|
+
forwarded.push(argument);
|
|
2415
|
+
}
|
|
2416
|
+
return { arguments: Object.freeze(forwarded), campaignId, help };
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function validateCampaignMatrix(
|
|
2420
|
+
campaigns: readonly DirectBombadilFuzzCampaign[],
|
|
2421
|
+
): readonly DirectBombadilFuzzCampaign[] {
|
|
2422
|
+
if (campaigns.length === 0 || campaigns.length > 32) {
|
|
2423
|
+
throw new Error("Bombadil campaign matrix must contain 1-32 campaigns");
|
|
2424
|
+
}
|
|
2425
|
+
const ids = new Set<string>();
|
|
2426
|
+
for (const campaign of campaigns) {
|
|
2427
|
+
if (!ARTIFACT_NAME_PATTERN.test(campaign.id) || ids.has(campaign.id)) {
|
|
2428
|
+
throw new Error("Bombadil campaign IDs must be unique lowercase kebab identifiers");
|
|
2429
|
+
}
|
|
2430
|
+
ids.add(campaign.id);
|
|
2431
|
+
}
|
|
2432
|
+
return campaigns;
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/** Runs a bounded product-owned campaign matrix serially. */
|
|
2436
|
+
export async function runDirectBombadilFuzzMatrix(
|
|
2437
|
+
campaignsInput: readonly DirectBombadilFuzzCampaign[],
|
|
2438
|
+
arguments_: readonly string[] = process.argv.slice(2),
|
|
2439
|
+
dependencyOverrides: Partial<DirectBombadilRunnerDependencies> = {},
|
|
2440
|
+
): Promise<DirectBombadilFuzzMatrixResult> {
|
|
2441
|
+
const campaigns = validateCampaignMatrix(campaignsInput);
|
|
2442
|
+
const parsed = parseMatrixCampaignArgument(arguments_);
|
|
2443
|
+
if (parsed.help) {
|
|
2444
|
+
process.stdout.write(`${[
|
|
2445
|
+
helpText(campaigns[0]?.config.baseUrl ?? ""),
|
|
2446
|
+
" --campaign <id> Run one campaign; required with --replay",
|
|
2447
|
+
"",
|
|
2448
|
+
`Campaigns: ${campaigns.map((campaign) => campaign.id).join(", ")}`,
|
|
2449
|
+
].join("\n")}\n`);
|
|
2450
|
+
return { kind: "help" };
|
|
2451
|
+
}
|
|
2452
|
+
const selected = parsed.campaignId === null
|
|
2453
|
+
? campaigns
|
|
2454
|
+
: campaigns.filter((campaign) => campaign.id === parsed.campaignId);
|
|
2455
|
+
if (selected.length === 0) {
|
|
2456
|
+
throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`);
|
|
2457
|
+
}
|
|
2458
|
+
if (
|
|
2459
|
+
parsed.campaignId === null
|
|
2460
|
+
&& parsed.arguments.some((argument) =>
|
|
2461
|
+
argument === "--replay" || argument.startsWith("--replay=")
|
|
2462
|
+
)
|
|
2463
|
+
) {
|
|
2464
|
+
throw new Error("--replay requires exactly one --campaign in matrix mode");
|
|
2465
|
+
}
|
|
2466
|
+
const results: Array<{
|
|
2467
|
+
readonly campaignId: string;
|
|
2468
|
+
readonly result: Extract<DirectBombadilFuzzResult, { readonly kind: "run" }>;
|
|
2469
|
+
}> = [];
|
|
2470
|
+
for (const campaign of selected) {
|
|
2471
|
+
const result = await runDirectBombadilFuzz(
|
|
2472
|
+
campaign.config,
|
|
2473
|
+
parsed.arguments,
|
|
2474
|
+
dependencyOverrides,
|
|
2475
|
+
);
|
|
2476
|
+
if (result.kind !== "run") {
|
|
2477
|
+
throw new Error("Bombadil campaign unexpectedly returned help during matrix execution");
|
|
2478
|
+
}
|
|
2479
|
+
results.push({ campaignId: campaign.id, result });
|
|
2480
|
+
}
|
|
2481
|
+
return { kind: "matrix", results: Object.freeze(results) };
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function throwIfBombadilRunAborted(signal: AbortSignal): void {
|
|
2485
|
+
if (signal.aborted) throw new Error("Bombadil fuzzing was interrupted");
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function terminateAbortedOwnedServer(
|
|
2489
|
+
signal: AbortSignal,
|
|
2490
|
+
server: ManagedVerificationServer,
|
|
2491
|
+
): void {
|
|
2492
|
+
if (!signal.aborted) return;
|
|
2493
|
+
if (server.exitCode() === null) server.terminate();
|
|
2494
|
+
throwIfBombadilRunAborted(signal);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
1184
2497
|
/** Runs one bounded diagnostic Bombadil campaign and always releases its server lease. */
|
|
1185
2498
|
export async function runDirectBombadilFuzz(
|
|
1186
2499
|
config: DirectBombadilFuzzConfig,
|
|
@@ -1212,7 +2525,7 @@ export async function runDirectBombadilFuzz(
|
|
|
1212
2525
|
});
|
|
1213
2526
|
const outputPath = join(artifactRun.runDirectory, "bombadil");
|
|
1214
2527
|
const tracePath = join(outputPath, "trace.jsonl");
|
|
1215
|
-
const abortController = new AbortController();
|
|
2528
|
+
const abortController = dependencies.createAbortController?.() ?? new AbortController();
|
|
1216
2529
|
const invocation = createDirectBombadilInvocation({
|
|
1217
2530
|
baseUrl: validated.baseUrl,
|
|
1218
2531
|
bombadilExecutable: validated.bombadilExecutable,
|
|
@@ -1224,6 +2537,7 @@ export async function runDirectBombadilFuzz(
|
|
|
1224
2537
|
specificationPath: validated.specificationPath,
|
|
1225
2538
|
targetQuery: validated.targetQuery,
|
|
1226
2539
|
timeLimitSeconds: parsed.timeLimitSeconds,
|
|
2540
|
+
viewport: validated.viewport,
|
|
1227
2541
|
});
|
|
1228
2542
|
const abortableInvocation = { ...invocation, abortSignal: abortController.signal };
|
|
1229
2543
|
const serverCommand = validated.server.command.map((argument) =>
|
|
@@ -1236,6 +2550,8 @@ export async function runDirectBombadilFuzz(
|
|
|
1236
2550
|
let processResult: BombadilProcessResult | null = null;
|
|
1237
2551
|
let attestation: DirectBombadilTraceAttestation | null = null;
|
|
1238
2552
|
let attestationFailure: unknown = null;
|
|
2553
|
+
let explorationSummary: DirectBombadilExplorationSummary | null = null;
|
|
2554
|
+
let explorationSummaryFailure: unknown = null;
|
|
1239
2555
|
let rawTracePath: string | null = null;
|
|
1240
2556
|
let serverOutput = "";
|
|
1241
2557
|
let serverOutputFailure: unknown = null;
|
|
@@ -1255,23 +2571,36 @@ export async function runDirectBombadilFuzz(
|
|
|
1255
2571
|
try {
|
|
1256
2572
|
await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable");
|
|
1257
2573
|
bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot);
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
2574
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2575
|
+
|
|
2576
|
+
try {
|
|
2577
|
+
lease = await dependencies.acquireServer({
|
|
2578
|
+
abortSignal: abortController.signal,
|
|
2579
|
+
baseUrl: validated.baseUrl,
|
|
2580
|
+
label: validated.label,
|
|
2581
|
+
readinessPath: validated.server.readinessPath,
|
|
2582
|
+
reuseExistingLocalServer: false,
|
|
2583
|
+
startupTimeoutMs: validated.server.startupTimeoutMs,
|
|
2584
|
+
startServer: () => {
|
|
2585
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2586
|
+
ownedServer = dependencies.spawnServer({
|
|
2587
|
+
command: serverCommand,
|
|
2588
|
+
cwd: validated.server.cwd,
|
|
2589
|
+
...(validated.server.env === undefined ? {} : { env: validated.server.env }),
|
|
2590
|
+
});
|
|
2591
|
+
terminateAbortedOwnedServer(abortController.signal, ownedServer);
|
|
2592
|
+
return ownedServer;
|
|
2593
|
+
},
|
|
2594
|
+
});
|
|
2595
|
+
} catch (error) {
|
|
2596
|
+
if (abortController.signal.aborted) throwIfBombadilRunAborted(abortController.signal);
|
|
2597
|
+
throw error;
|
|
2598
|
+
}
|
|
2599
|
+
if (abortController.signal.aborted) {
|
|
2600
|
+
const acquiredOwnedServer = ownedServer as ManagedVerificationServer | null;
|
|
2601
|
+
if (acquiredOwnedServer?.exitCode() === null) acquiredOwnedServer.terminate();
|
|
2602
|
+
throwIfBombadilRunAborted(abortController.signal);
|
|
2603
|
+
}
|
|
1275
2604
|
let processFailure: unknown = null;
|
|
1276
2605
|
try {
|
|
1277
2606
|
processResult = await dependencies.runBombadil(abortableInvocation);
|
|
@@ -1291,6 +2620,17 @@ export async function runDirectBombadilFuzz(
|
|
|
1291
2620
|
} catch (error) {
|
|
1292
2621
|
attestationFailure = error;
|
|
1293
2622
|
}
|
|
2623
|
+
try {
|
|
2624
|
+
explorationSummary = await summarizeDirectBombadilTrace({
|
|
2625
|
+
...(validated.explorationPolicy === null
|
|
2626
|
+
? {}
|
|
2627
|
+
: { explorationPolicy: validated.explorationPolicy }),
|
|
2628
|
+
targetUrl: invocation.targetUrl,
|
|
2629
|
+
tracePath,
|
|
2630
|
+
});
|
|
2631
|
+
} catch (error) {
|
|
2632
|
+
explorationSummaryFailure = error;
|
|
2633
|
+
}
|
|
1294
2634
|
if (processFailure !== null) {
|
|
1295
2635
|
throw processFailure instanceof Error
|
|
1296
2636
|
? processFailure
|
|
@@ -1313,6 +2653,16 @@ export async function runDirectBombadilFuzz(
|
|
|
1313
2653
|
? attestationFailure
|
|
1314
2654
|
: new Error(renderUnknown(attestationFailure));
|
|
1315
2655
|
}
|
|
2656
|
+
if (explorationSummaryFailure !== null) {
|
|
2657
|
+
throw explorationSummaryFailure instanceof Error
|
|
2658
|
+
? explorationSummaryFailure
|
|
2659
|
+
: new Error(renderUnknown(explorationSummaryFailure));
|
|
2660
|
+
}
|
|
2661
|
+
if (explorationSummary?.policy.satisfied !== true) {
|
|
2662
|
+
throw new Error(
|
|
2663
|
+
`Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`,
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
1316
2666
|
} catch (error) {
|
|
1317
2667
|
failure = error;
|
|
1318
2668
|
}
|
|
@@ -1351,6 +2701,10 @@ export async function runDirectBombadilFuzz(
|
|
|
1351
2701
|
const status = failure === null ? "passed" : "failed";
|
|
1352
2702
|
const logPath = join(artifactRun.runDirectory, "bombadil.log");
|
|
1353
2703
|
const serverLogPath = join(artifactRun.runDirectory, "server.log");
|
|
2704
|
+
const explorationSummaryPath = join(
|
|
2705
|
+
artifactRun.runDirectory,
|
|
2706
|
+
"exploration-summary.json",
|
|
2707
|
+
);
|
|
1354
2708
|
const record = {
|
|
1355
2709
|
schema: ARTIFACT_SCHEMA,
|
|
1356
2710
|
evidenceClass: "diagnostic-fuzz",
|
|
@@ -1366,6 +2720,8 @@ export async function runDirectBombadilFuzz(
|
|
|
1366
2720
|
entryPath: validated.entryPath,
|
|
1367
2721
|
targetQuery: validated.targetQuery,
|
|
1368
2722
|
targetUrl: invocation.targetUrl,
|
|
2723
|
+
viewport: validated.viewport,
|
|
2724
|
+
explorationPolicy: validated.explorationPolicy,
|
|
1369
2725
|
specificationPath: validated.specificationPath,
|
|
1370
2726
|
replayPath,
|
|
1371
2727
|
timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null,
|
|
@@ -1387,6 +2743,11 @@ export async function runDirectBombadilFuzz(
|
|
|
1387
2743
|
},
|
|
1388
2744
|
attestation,
|
|
1389
2745
|
attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure),
|
|
2746
|
+
explorationSummary,
|
|
2747
|
+
explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath,
|
|
2748
|
+
explorationSummaryFailure: explorationSummaryFailure === null
|
|
2749
|
+
? null
|
|
2750
|
+
: renderUnknown(explorationSummaryFailure),
|
|
1390
2751
|
initialDirect: attestation?.initial ?? null,
|
|
1391
2752
|
interruptedSignal: capturedSignal,
|
|
1392
2753
|
failure: failure === null ? null : renderUnknown(failure),
|
|
@@ -1401,10 +2762,28 @@ export async function runDirectBombadilFuzz(
|
|
|
1401
2762
|
`${serverOutput}${serverOutput.length > 0 ? "\n" : ""}`,
|
|
1402
2763
|
"utf8",
|
|
1403
2764
|
);
|
|
2765
|
+
if (explorationSummary !== null) {
|
|
2766
|
+
await writeJsonAtomically(explorationSummaryPath, explorationSummary);
|
|
2767
|
+
}
|
|
1404
2768
|
await writeJsonAtomically(join(artifactRun.runDirectory, "run.json"), record);
|
|
1405
2769
|
await writeJsonAtomically(artifactRun.manifestPath, record);
|
|
1406
2770
|
|
|
1407
|
-
const
|
|
2771
|
+
const exploration = explorationSummary === null
|
|
2772
|
+
? "exploration=unavailable"
|
|
2773
|
+
: [
|
|
2774
|
+
`nonWait=${String(explorationSummary.actions.nonWaitCount)}`,
|
|
2775
|
+
`maxWaitStreak=${String(explorationSummary.actions.maxWaitStreak)}`,
|
|
2776
|
+
`namedChanges=${explorationSummary.namedSnapshots
|
|
2777
|
+
.map((snapshot) => `${snapshot.name}:${String(snapshot.changeAfterNonWaitCount)}`)
|
|
2778
|
+
.join(",") || "none"}`,
|
|
2779
|
+
`policy=${explorationSummary.policy.satisfied ? "satisfied" : "failed"}`,
|
|
2780
|
+
].join("; ");
|
|
2781
|
+
const summary = [
|
|
2782
|
+
`${status === "passed" ? "PASS" : "FAIL"} ${validated.label}`,
|
|
2783
|
+
exploration,
|
|
2784
|
+
`artifacts: ${artifactRun.runDirectory}`,
|
|
2785
|
+
`log: ${logPath}`,
|
|
2786
|
+
].join("; ");
|
|
1408
2787
|
(status === "passed" ? process.stdout : process.stderr).write(`${summary}\n`);
|
|
1409
2788
|
|
|
1410
2789
|
if (failure !== null) {
|