@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
|
@@ -1393,23 +1393,54 @@ async function stopVerificationServerWithOutput(server, stopTimeoutMs = DEFAULT_
|
|
|
1393
1393
|
async function stopVerificationServer(server, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
|
|
1394
1394
|
await stopVerificationServerWithOutput(server, stopTimeoutMs);
|
|
1395
1395
|
}
|
|
1396
|
+
function verificationServerAcquisitionAbortError() {
|
|
1397
|
+
return new Error("Verification server acquisition was aborted");
|
|
1398
|
+
}
|
|
1399
|
+
function throwIfVerificationServerAcquisitionAborted(signal) {
|
|
1400
|
+
if (signal?.aborted === true)
|
|
1401
|
+
throw verificationServerAcquisitionAbortError();
|
|
1402
|
+
}
|
|
1403
|
+
async function waitForVerificationServerAcquisitionStep(promise, signal) {
|
|
1404
|
+
if (signal === undefined)
|
|
1405
|
+
return await promise;
|
|
1406
|
+
throwIfVerificationServerAcquisitionAborted(signal);
|
|
1407
|
+
let abortListener;
|
|
1408
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
1409
|
+
abortListener = () => reject(verificationServerAcquisitionAbortError());
|
|
1410
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
1411
|
+
if (signal.aborted)
|
|
1412
|
+
abortListener();
|
|
1413
|
+
});
|
|
1414
|
+
let value;
|
|
1415
|
+
try {
|
|
1416
|
+
value = await Promise.race([promise, aborted]);
|
|
1417
|
+
} finally {
|
|
1418
|
+
if (abortListener !== undefined)
|
|
1419
|
+
signal.removeEventListener("abort", abortListener);
|
|
1420
|
+
}
|
|
1421
|
+
throwIfVerificationServerAcquisitionAborted(signal);
|
|
1422
|
+
return value;
|
|
1423
|
+
}
|
|
1396
1424
|
async function acquireVerificationServer(options) {
|
|
1397
1425
|
const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
1398
1426
|
const readinessPath = options.readinessPath ?? "/";
|
|
1399
1427
|
const isReachable = options.isReachable ?? serverIsReachable;
|
|
1400
1428
|
const canStartLocally = canAutomaticallyStartLocalServer(options.baseUrl, options.localHosts);
|
|
1401
|
-
|
|
1429
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1430
|
+
if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
|
|
1402
1431
|
if (canStartLocally && options.reuseExistingLocalServer === false) {
|
|
1403
1432
|
throw new Error(`A local server is already reachable at ${options.baseUrl}; ` + "verification will not reuse a server whose worktree ownership is unknown");
|
|
1404
1433
|
}
|
|
1405
|
-
await Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS);
|
|
1406
|
-
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
1434
|
+
await waitForVerificationServerAcquisitionStep(Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS), options.abortSignal);
|
|
1435
|
+
if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
|
|
1436
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1407
1437
|
return { source: "reused" };
|
|
1408
1438
|
}
|
|
1409
1439
|
}
|
|
1410
1440
|
if (!canStartLocally) {
|
|
1411
1441
|
throw new Error(`No server is reachable at ${options.baseUrl}; automatic startup is limited to local HTTP URLs`);
|
|
1412
1442
|
}
|
|
1443
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1413
1444
|
const server = options.startServer();
|
|
1414
1445
|
let exitedWithCode = null;
|
|
1415
1446
|
try {
|
|
@@ -1420,10 +1451,11 @@ async function acquireVerificationServer(options) {
|
|
|
1420
1451
|
exitedWithCode = exitCode;
|
|
1421
1452
|
break;
|
|
1422
1453
|
}
|
|
1423
|
-
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
1454
|
+
if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
|
|
1455
|
+
throwIfVerificationServerAcquisitionAborted(options.abortSignal);
|
|
1424
1456
|
return { source: "started", server };
|
|
1425
1457
|
}
|
|
1426
|
-
await Bun.sleep(options.pollIntervalMs ?? 200);
|
|
1458
|
+
await waitForVerificationServerAcquisitionStep(Bun.sleep(options.pollIntervalMs ?? 200), options.abortSignal);
|
|
1427
1459
|
}
|
|
1428
1460
|
} catch (error) {
|
|
1429
1461
|
await stopVerificationServer(server);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hraness/direct",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "A TypeScript harness for deterministic frontend development with repeatable scenarios, local fixtures, and browser verification for coding agents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,9 +21,9 @@ global `direct` CLI.
|
|
|
21
21
|
For a new installation, pin the reviewed public release:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
bun add --dev @hraness/direct@0.7.
|
|
24
|
+
bun add --dev @hraness/direct@0.7.7
|
|
25
25
|
# or, in an npm project
|
|
26
|
-
npm install --save-dev @hraness/direct@0.7.
|
|
26
|
+
npm install --save-dev @hraness/direct@0.7.7
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
The equivalent manifest entry is:
|
|
@@ -31,7 +31,7 @@ The equivalent manifest entry is:
|
|
|
31
31
|
```json
|
|
32
32
|
{
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@hraness/direct": "0.7.
|
|
34
|
+
"@hraness/direct": "0.7.7"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
```
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
extract,
|
|
10
10
|
weighted,
|
|
11
11
|
type ActionGenerator,
|
|
12
|
+
type Cell,
|
|
12
13
|
type Formula,
|
|
13
14
|
type JSON as BombadilJson,
|
|
14
15
|
type Tree,
|
|
@@ -27,8 +28,38 @@ const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2";
|
|
|
27
28
|
const DIRECT_SESSION_MANIFEST_SCHEMA = "direct.session-manifest/v1";
|
|
28
29
|
const DIRECT_PROBE_SCHEMA = "direct.probe/v1";
|
|
29
30
|
const MAX_RAW_CONTRACT_CHARACTERS = 2_000_000;
|
|
31
|
+
const MAX_NAMED_SNAPSHOT_CANONICAL_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
const MAX_NAMED_SNAPSHOT_JSON_DEPTH = 64;
|
|
30
33
|
const BRIDGE_KEYS = new Set(["manifest", "reset", "schema", "snapshot"]);
|
|
31
34
|
const UNSAFE_CLICK_INPUT_TYPES = new Set(["image", "reset", "submit"]);
|
|
35
|
+
const UNSAFE_CLICK_LABEL_PHRASES = [
|
|
36
|
+
"clear",
|
|
37
|
+
"close",
|
|
38
|
+
"delete",
|
|
39
|
+
"discard",
|
|
40
|
+
"erase",
|
|
41
|
+
"log out",
|
|
42
|
+
"remove",
|
|
43
|
+
"reset",
|
|
44
|
+
"sign out",
|
|
45
|
+
"unlink",
|
|
46
|
+
] as const;
|
|
47
|
+
const SNAPSHOT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u;
|
|
48
|
+
const RESERVED_SNAPSHOT_NAMES = new Set([
|
|
49
|
+
"direct",
|
|
50
|
+
"__proto__",
|
|
51
|
+
"constructor",
|
|
52
|
+
"prototype",
|
|
53
|
+
]);
|
|
54
|
+
const RESOURCE_LEAK_OPTION_KEYS = new Set(["growthLimit", "metric", "windowMillis"]);
|
|
55
|
+
const RESOURCE_METRICS = [
|
|
56
|
+
"dom_nodes",
|
|
57
|
+
"js_event_listeners",
|
|
58
|
+
"js_heap_total",
|
|
59
|
+
"js_heap_used",
|
|
60
|
+
"layout_objects",
|
|
61
|
+
] as const;
|
|
62
|
+
const RESOURCE_METRIC_SET = new Set<string>(RESOURCE_METRICS);
|
|
32
63
|
|
|
33
64
|
export interface DirectBombadilObservation {
|
|
34
65
|
readonly [key: string | number | symbol]: BombadilJson;
|
|
@@ -48,12 +79,32 @@ export interface DirectBombadilObservation {
|
|
|
48
79
|
}
|
|
49
80
|
|
|
50
81
|
export interface DirectBombadilProperties {
|
|
82
|
+
readonly startupContract: Formula;
|
|
51
83
|
readonly exactContract: Formula;
|
|
52
84
|
readonly stableCatalog: Formula;
|
|
53
85
|
readonly noDeclaredViolations: Formula;
|
|
54
86
|
readonly eventualQuiescence: Formula;
|
|
55
87
|
}
|
|
56
88
|
|
|
89
|
+
function observationHasExactContract(
|
|
90
|
+
observation: DirectBombadilObservation,
|
|
91
|
+
): boolean {
|
|
92
|
+
return observation.contractValid
|
|
93
|
+
&& observation.activeSource === "scenario"
|
|
94
|
+
&& observation.activeScenario.length > 0
|
|
95
|
+
&& observation.activeRoute.length > 0
|
|
96
|
+
&& observation.activationHash.length > 0
|
|
97
|
+
&& observation.catalogHash.length > 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type DirectBombadilResourceMetric = (typeof RESOURCE_METRICS)[number];
|
|
101
|
+
|
|
102
|
+
export interface DirectBombadilResourceLeakOptions {
|
|
103
|
+
readonly growthLimit: number;
|
|
104
|
+
readonly metric: DirectBombadilResourceMetric;
|
|
105
|
+
readonly windowMillis: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
57
108
|
function safeClickAction(action: ActionTemplate): boolean {
|
|
58
109
|
if (typeof action !== "object" || action === null) return false;
|
|
59
110
|
const candidate = "Click" in action
|
|
@@ -67,11 +118,19 @@ function safeClickAction(action: ActionTemplate): boolean {
|
|
|
67
118
|
const inputType = fingerprint.inputType?.toLowerCase() ?? "";
|
|
68
119
|
const labels = [fingerprint.accessibleName, fingerprint.textContent]
|
|
69
120
|
.filter((label): label is string => label !== null)
|
|
70
|
-
.map((label) => label
|
|
121
|
+
.map((label) => label
|
|
122
|
+
.trim()
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.replace(/[^\p{L}\p{N}]+/gu, " "));
|
|
125
|
+
const hasUnsafeLabel = labels.some((label) => {
|
|
126
|
+
const padded = ` ${label} `;
|
|
127
|
+
return UNSAFE_CLICK_LABEL_PHRASES.some((phrase) => padded.includes(` ${phrase} `));
|
|
128
|
+
});
|
|
71
129
|
return fingerprint.href === null
|
|
72
130
|
&& tag !== "a"
|
|
131
|
+
&& tag !== "label"
|
|
73
132
|
&& fingerprint.role?.toLowerCase() !== "link"
|
|
74
|
-
&& !
|
|
133
|
+
&& !hasUnsafeLabel
|
|
75
134
|
&& !UNSAFE_CLICK_INPUT_TYPES.has(inputType)
|
|
76
135
|
&& (tag !== "button" || inputType === "button");
|
|
77
136
|
}
|
|
@@ -100,7 +159,8 @@ function pruneActionTree<Action>(
|
|
|
100
159
|
|
|
101
160
|
/**
|
|
102
161
|
* Builds a browser action generator without reload/history actions or visible
|
|
103
|
-
* navigation
|
|
162
|
+
* navigation, submission, reset, or destructive click targets, keeping Direct
|
|
163
|
+
* continuously bound.
|
|
104
164
|
*/
|
|
105
165
|
export function createDirectBombadilActions(): ActionGenerator<ActionTemplate> {
|
|
106
166
|
const safeClicks = actions(() => pruneActionTree(clicks.generate(), safeClickAction) ?? []);
|
|
@@ -126,6 +186,72 @@ function hasExactKeys(
|
|
|
126
186
|
return keys.length === expected.size && keys.every((key) => expected.has(key));
|
|
127
187
|
}
|
|
128
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Builds Bombadil's documented sliding-window resource growth invariant from
|
|
191
|
+
* the public browser state because 0.7.2 omits its extras module from exports.
|
|
192
|
+
*/
|
|
193
|
+
export function createDirectBombadilResourceLeakProperty(
|
|
194
|
+
options: DirectBombadilResourceLeakOptions,
|
|
195
|
+
): Formula {
|
|
196
|
+
if (!isRecord(options) || !hasExactKeys(options, RESOURCE_LEAK_OPTION_KEYS)) {
|
|
197
|
+
throw new Error("Bombadil resource leak options must contain metric, growthLimit, and windowMillis");
|
|
198
|
+
}
|
|
199
|
+
if (typeof options.metric !== "string" || !RESOURCE_METRIC_SET.has(options.metric)) {
|
|
200
|
+
throw new Error("Bombadil resource leak metric is unsupported");
|
|
201
|
+
}
|
|
202
|
+
if (
|
|
203
|
+
typeof options.growthLimit !== "number"
|
|
204
|
+
|| !Number.isFinite(options.growthLimit)
|
|
205
|
+
|| options.growthLimit <= 0
|
|
206
|
+
|| options.growthLimit > Number.MAX_SAFE_INTEGER
|
|
207
|
+
) {
|
|
208
|
+
throw new Error("Bombadil resource leak growthLimit must be a positive finite safe number");
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
typeof options.windowMillis !== "number"
|
|
212
|
+
|| !Number.isSafeInteger(options.windowMillis)
|
|
213
|
+
|| options.windowMillis < 1
|
|
214
|
+
|| options.windowMillis > 300_000
|
|
215
|
+
) {
|
|
216
|
+
throw new Error("Bombadil resource leak windowMillis must be an integer between 1 and 300000");
|
|
217
|
+
}
|
|
218
|
+
const metric = options.metric;
|
|
219
|
+
const samples: Array<{ readonly timestamp: number; readonly value: number }> = [];
|
|
220
|
+
let previousTimestamp = -1;
|
|
221
|
+
const window = extract<BombadilBrowserState, {
|
|
222
|
+
readonly baseline: number;
|
|
223
|
+
readonly valid: boolean;
|
|
224
|
+
readonly value: number;
|
|
225
|
+
}>((state) => {
|
|
226
|
+
const timestamp = state.resources.timestamp * 1_000;
|
|
227
|
+
const value = state.resources[metric];
|
|
228
|
+
if (
|
|
229
|
+
!Number.isFinite(timestamp)
|
|
230
|
+
|| timestamp < 0
|
|
231
|
+
|| timestamp < previousTimestamp
|
|
232
|
+
|| !Number.isFinite(value)
|
|
233
|
+
|| value < 0
|
|
234
|
+
) {
|
|
235
|
+
return { baseline: 0, valid: false, value: 0 };
|
|
236
|
+
}
|
|
237
|
+
previousTimestamp = timestamp;
|
|
238
|
+
samples.push({ timestamp, value });
|
|
239
|
+
const cutoff = timestamp - options.windowMillis;
|
|
240
|
+
while (samples.length > 2 && (samples[1]?.timestamp ?? Number.POSITIVE_INFINITY) <= cutoff) {
|
|
241
|
+
samples.shift();
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
baseline: samples[0]?.value ?? value,
|
|
245
|
+
valid: true,
|
|
246
|
+
value,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
return always(() =>
|
|
250
|
+
window.current.valid
|
|
251
|
+
&& window.current.value - window.current.baseline <= options.growthLimit
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
129
255
|
function invalidObservation(bridgePresent = false): DirectBombadilObservation {
|
|
130
256
|
return {
|
|
131
257
|
activationHash: "",
|
|
@@ -150,6 +276,99 @@ function boundedJsonClone(value: unknown): BombadilJson | null {
|
|
|
150
276
|
return JSON.parse(source) as BombadilJson;
|
|
151
277
|
}
|
|
152
278
|
|
|
279
|
+
function cloneNamedSnapshotJson(
|
|
280
|
+
value: unknown,
|
|
281
|
+
depth = 0,
|
|
282
|
+
ancestors: WeakSet<object> = new WeakSet<object>(),
|
|
283
|
+
): BombadilJson | undefined {
|
|
284
|
+
if (depth > MAX_NAMED_SNAPSHOT_JSON_DEPTH) return undefined;
|
|
285
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
289
|
+
if (typeof value !== "object") return undefined;
|
|
290
|
+
if (ancestors.has(value)) return undefined;
|
|
291
|
+
ancestors.add(value);
|
|
292
|
+
try {
|
|
293
|
+
if (Array.isArray(value)) {
|
|
294
|
+
const cloned: BombadilJson[] = [];
|
|
295
|
+
for (const entry of value) {
|
|
296
|
+
const child = cloneNamedSnapshotJson(entry, depth + 1, ancestors);
|
|
297
|
+
if (child === undefined) return undefined;
|
|
298
|
+
cloned.push(child);
|
|
299
|
+
}
|
|
300
|
+
return cloned;
|
|
301
|
+
}
|
|
302
|
+
const clonedEntries: [string, BombadilJson][] = [];
|
|
303
|
+
for (const key of Object.keys(value)) {
|
|
304
|
+
const child = cloneNamedSnapshotJson(
|
|
305
|
+
Reflect.get(value, key),
|
|
306
|
+
depth + 1,
|
|
307
|
+
ancestors,
|
|
308
|
+
);
|
|
309
|
+
if (child === undefined) return undefined;
|
|
310
|
+
clonedEntries.push([key, child]);
|
|
311
|
+
}
|
|
312
|
+
return Object.fromEntries(clonedEntries) as BombadilJson;
|
|
313
|
+
} finally {
|
|
314
|
+
ancestors.delete(value);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function boundedNamedSnapshotJson(value: unknown): BombadilJson | undefined {
|
|
319
|
+
const cloned = cloneNamedSnapshotJson(value);
|
|
320
|
+
if (cloned === undefined) return undefined;
|
|
321
|
+
const source = JSON.stringify(cloned);
|
|
322
|
+
return new TextEncoder().encode(source).byteLength
|
|
323
|
+
<= MAX_NAMED_SNAPSHOT_CANONICAL_BYTES
|
|
324
|
+
? cloned
|
|
325
|
+
: undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Creates a named, bounded JSON extractor that fails closed to an explicit
|
|
330
|
+
* fallback when a page getter throws or returns non-JSON or oversized data.
|
|
331
|
+
*/
|
|
332
|
+
export function createDirectBombadilNamedSnapshot<T extends BombadilJson>(options: {
|
|
333
|
+
readonly fallback: T;
|
|
334
|
+
readonly name: string;
|
|
335
|
+
readonly read: (state: BombadilBrowserState) => unknown;
|
|
336
|
+
readonly validate: (value: BombadilJson) => value is T;
|
|
337
|
+
}): Cell<T> {
|
|
338
|
+
if (
|
|
339
|
+
options.name.length === 0
|
|
340
|
+
|| options.name.length > 128
|
|
341
|
+
|| !SNAPSHOT_NAME_PATTERN.test(options.name)
|
|
342
|
+
|| RESERVED_SNAPSHOT_NAMES.has(options.name)
|
|
343
|
+
) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
"Bombadil snapshot name must be a safe, unreserved 1-128 character identifier",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const validate = (value: unknown): T | undefined => {
|
|
349
|
+
const owned = boundedNamedSnapshotJson(value);
|
|
350
|
+
return owned !== undefined && options.validate(owned) ? owned : undefined;
|
|
351
|
+
};
|
|
352
|
+
let fallback: T | undefined;
|
|
353
|
+
try {
|
|
354
|
+
fallback = validate(options.fallback);
|
|
355
|
+
} catch {
|
|
356
|
+
fallback = undefined;
|
|
357
|
+
}
|
|
358
|
+
if (fallback === undefined) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
"Bombadil snapshot fallback must be bounded JSON accepted by validate",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return extract<BombadilBrowserState, T>((state) => {
|
|
364
|
+
try {
|
|
365
|
+
return validate(options.read(state)) ?? fallback;
|
|
366
|
+
} catch {
|
|
367
|
+
return fallback;
|
|
368
|
+
}
|
|
369
|
+
}).named(options.name);
|
|
370
|
+
}
|
|
371
|
+
|
|
153
372
|
function readNonNegativeCounters(value: unknown): {
|
|
154
373
|
readonly valid: boolean;
|
|
155
374
|
readonly values: number[];
|
|
@@ -247,39 +466,50 @@ export function readDirectBombadilObservation(
|
|
|
247
466
|
}
|
|
248
467
|
}
|
|
249
468
|
|
|
250
|
-
/** Builds
|
|
469
|
+
/** Builds bounded startup plus strict recurring Direct browser invariants. */
|
|
251
470
|
export function createDirectBombadilProperties(): DirectBombadilProperties {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
471
|
+
let initial: Readonly<{
|
|
472
|
+
activationHash: string;
|
|
473
|
+
activeRoute: string;
|
|
474
|
+
activeScenario: string;
|
|
475
|
+
catalogHash: string;
|
|
476
|
+
}> | null = null;
|
|
477
|
+
const direct = extract<BombadilBrowserState, DirectBombadilObservation>((state) => {
|
|
478
|
+
const observation = readDirectBombadilObservation(state.window);
|
|
479
|
+
if (initial === null && observationHasExactContract(observation)) {
|
|
480
|
+
initial = Object.freeze({
|
|
481
|
+
activationHash: observation.activationHash,
|
|
482
|
+
activeRoute: observation.activeRoute,
|
|
483
|
+
activeScenario: observation.activeScenario,
|
|
484
|
+
catalogHash: observation.catalogHash,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return observation;
|
|
488
|
+
}).named("direct");
|
|
255
489
|
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
eventually(() =>
|
|
273
|
-
direct.current.contractValid
|
|
274
|
-
&& direct.current.violationsValid
|
|
275
|
-
&& direct.current.violations.every((value: number) => value === 0)
|
|
276
|
-
).within(10, "seconds"),
|
|
277
|
-
);
|
|
490
|
+
const startupContract = eventually(() => initial !== null).within(10, "seconds");
|
|
491
|
+
const exactContract = always(() => initial === null || (
|
|
492
|
+
observationHasExactContract(direct.current)
|
|
493
|
+
&& direct.current.activationHash === initial.activationHash
|
|
494
|
+
&& direct.current.activeRoute === initial.activeRoute
|
|
495
|
+
&& direct.current.activeScenario === initial.activeScenario
|
|
496
|
+
));
|
|
497
|
+
const stableCatalog = always(() => initial === null || (
|
|
498
|
+
observationHasExactContract(direct.current)
|
|
499
|
+
&& direct.current.catalogHash === initial.catalogHash
|
|
500
|
+
));
|
|
501
|
+
const noDeclaredViolations = always(() => initial === null || (
|
|
502
|
+
observationHasExactContract(direct.current)
|
|
503
|
+
&& direct.current.violationsValid
|
|
504
|
+
&& direct.current.violations.every((value: number) => value === 0)
|
|
505
|
+
));
|
|
278
506
|
const eventualQuiescence = always(
|
|
279
|
-
eventually(() => direct.current.isQuiescent)
|
|
507
|
+
eventually(() => initial !== null && direct.current.isQuiescent)
|
|
508
|
+
.within(10, "seconds"),
|
|
280
509
|
);
|
|
281
510
|
|
|
282
511
|
return Object.freeze({
|
|
512
|
+
startupContract,
|
|
283
513
|
exactContract,
|
|
284
514
|
stableCatalog,
|
|
285
515
|
noDeclaredViolations,
|