dsh-agent-team-ui-testing 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +172 -36
- package/annotation-capture.js +111 -0
- package/browser-safety.js +45 -1
- package/client.js +3721 -132
- package/context-menu.js +134 -0
- package/core/clock.js +88 -0
- package/core/config-store.js +566 -0
- package/core/config.js +892 -0
- package/core/contracts.js +240 -0
- package/core/errors.js +156 -0
- package/core/evidence.js +141 -0
- package/core/geometry.js +217 -0
- package/core/identity.js +180 -0
- package/core/index.js +24 -0
- package/core/p0.js +1101 -0
- package/core/readiness.js +175 -0
- package/core/registry.js +239 -0
- package/core/tab-access.js +1350 -0
- package/docs/adapters.md +160 -0
- package/docs/architecture.md +44 -9
- package/docs/capability-tiers.md +20 -4
- package/docs/capture-p0.md +39 -2
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +24 -6
- package/docs/getting-started.md +47 -1
- package/docs/images/tutorial-01-overview.png +0 -0
- package/docs/images/tutorial-02-history-dropdown.png +0 -0
- package/docs/images/tutorial-03-pick-element.png +0 -0
- package/docs/images/tutorial-04-annotate.png +0 -0
- package/docs/index.md +11 -7
- package/docs/multi-tab.md +55 -20
- package/docs/network-p0.md +2 -2
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +2 -2
- package/docs/t3-vision.md +6 -1
- package/docs/t4-dynamic-behavior.md +1 -0
- package/docs/tools.md +65 -7
- package/docs/tutorial.md +125 -0
- package/docs/verification.md +26 -2
- package/docs/viewport-control.md +21 -8
- package/index.js +3263 -251
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/network-p0.js +24 -7
- package/package.json +4 -3
- package/select-control.js +298 -0
- package/skills/ui-testing-common/SKILL.md +54 -0
- package/skills/ui-testing-fake/SKILL.md +43 -0
- package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
- package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
- package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
- package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
- package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
- package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
- package/targets/fake/adapter.js +1163 -0
- package/targets/fake/fixtures.js +177 -0
- package/targets/web/adapter.js +1231 -0
- package/targets/web/provider.js +415 -0
- package/skills/ui-testing/references/scenarios/annotations.md +0 -87
- /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
package/core/p0.js
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0 neutral models (A–E), provider contract helpers and pure algorithms.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately platform-free: it imports `node:` builtins plus
|
|
5
|
+
* `core/geometry.js` / `core/errors.js` only. It must never import `index.js`,
|
|
6
|
+
* a browser/CDP module, `targets/*` or any other platform implementation.
|
|
7
|
+
*
|
|
8
|
+
* Neutral model rules (docs/internal/cross-environment-foundation-m0.md §3):
|
|
9
|
+
* - A frame keeps `sourceTimestamp` (null is legal) and `receivedAt`; a missing
|
|
10
|
+
* source clock is never fabricated from `receivedAt`.
|
|
11
|
+
* - Network events never carry `cdpSessionId` in their public keys; platform
|
|
12
|
+
* references live in `transportRef`, and a public request key carries only
|
|
13
|
+
* the irreversible `transportId` scope derived from it.
|
|
14
|
+
* - Different generation / target / incompatible mapping are not comparable.
|
|
15
|
+
* - The evidence envelope carries scope/session/target/generation/
|
|
16
|
+
* configRevision/operation/source/capturedAt.
|
|
17
|
+
*
|
|
18
|
+
* Factories normalise (optional fields become `null`, never `undefined`) and do
|
|
19
|
+
* not throw on missing required fields; `validate*` reports what is missing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
COORDINATE_SPACES,
|
|
26
|
+
assertCompatibleMapping,
|
|
27
|
+
invertTransform,
|
|
28
|
+
mapRect,
|
|
29
|
+
} from './geometry.js';
|
|
30
|
+
import { AdapterError } from './errors.js';
|
|
31
|
+
|
|
32
|
+
/** P0 families. Values are the family letters so `P0_FAMILIES.A === 'A'`. */
|
|
33
|
+
export const P0_FAMILIES = Object.freeze({ A: 'A', B: 'B', C: 'C', D: 'D', E: 'E' });
|
|
34
|
+
|
|
35
|
+
/** Provider method name that owns each family. */
|
|
36
|
+
export const P0_FAMILY_METHODS = Object.freeze({
|
|
37
|
+
A: 'capture',
|
|
38
|
+
B: 'network',
|
|
39
|
+
C: 'display',
|
|
40
|
+
D: 'canvas',
|
|
41
|
+
E: 'targets',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Capability ids grouped by family (public capability discovery). */
|
|
45
|
+
export const P0_CAPABILITIES = Object.freeze({
|
|
46
|
+
A: Object.freeze(['capture', 'frames', 'image']),
|
|
47
|
+
B: Object.freeze(['network', 'events', 'body', 'control']),
|
|
48
|
+
C: Object.freeze(['display', 'geometry']),
|
|
49
|
+
D: Object.freeze(['canvas', 'annotation', 'preview']),
|
|
50
|
+
E: Object.freeze(['targets', 'lease', 'lifecycle']),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Coordinate spaces. Same list as core/geometry.js COORDINATE_SPACES. */
|
|
54
|
+
export const DISPLAY_SPACES = COORDINATE_SPACES;
|
|
55
|
+
|
|
56
|
+
/** Support answers "does the engineering support this?" — never现场 availability. */
|
|
57
|
+
export const P0_SUPPORT = Object.freeze({
|
|
58
|
+
SUPPORTED: 'supported',
|
|
59
|
+
UNSUPPORTED: 'unsupported',
|
|
60
|
+
PLANNED: 'planned',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Availability answers "is it usable right now?" — never support. */
|
|
64
|
+
export const P0_AVAILABILITY = Object.freeze({
|
|
65
|
+
AVAILABLE: 'available',
|
|
66
|
+
UNAVAILABLE: 'unavailable',
|
|
67
|
+
NOT_PROBED: 'not-probed',
|
|
68
|
+
BUSY: 'busy',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const NETWORK_PHASES = Object.freeze([
|
|
72
|
+
'request', 'response', 'redirect', 'failed', 'finished', 'open', 'message', 'close', 'error',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export const NETWORK_KINDS = Object.freeze(['http', 'ws', 'sse', 'fetch']);
|
|
76
|
+
|
|
77
|
+
export const BODY_STATES = Object.freeze(['captured', 'empty', 'unavailable', 'omitted']);
|
|
78
|
+
|
|
79
|
+
export const TARGET_LIFECYCLES = Object.freeze(['available', 'bound', 'closed', 'stale', 'gone']);
|
|
80
|
+
|
|
81
|
+
/** Evidence envelope keys required on every P0 result (m0 §3 rule 4). */
|
|
82
|
+
export const P0_EVIDENCE_FIELDS = Object.freeze([
|
|
83
|
+
'version', 'scope', 'session', 'target', 'generation', 'configRevision', 'operation', 'source', 'capturedAt',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** Legacy annotation export version (explicit on every old-format export). */
|
|
87
|
+
export const LEGACY_ANNOTATION_FORMAT_VERSION = 1;
|
|
88
|
+
|
|
89
|
+
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
90
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
91
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
92
|
+
const isInteger = (value) => Number.isInteger(value);
|
|
93
|
+
const isText = (value) => typeof value === 'string' && value.length > 0;
|
|
94
|
+
const isHex64 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
95
|
+
const nullable = (value) => (value === undefined ? null : value);
|
|
96
|
+
|
|
97
|
+
function sha256Hex(bytes) {
|
|
98
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function positiveCount(value) {
|
|
102
|
+
const number = Number(value);
|
|
103
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Neutral model factories
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* P0-A frame.
|
|
112
|
+
* @param {object} input
|
|
113
|
+
* @returns {object} neutral frame
|
|
114
|
+
*/
|
|
115
|
+
export function createFrame({
|
|
116
|
+
frameId = null,
|
|
117
|
+
sessionId = null,
|
|
118
|
+
targetId = null,
|
|
119
|
+
generation = null,
|
|
120
|
+
sourceTimestamp = null,
|
|
121
|
+
receivedAt = null,
|
|
122
|
+
encoded = null,
|
|
123
|
+
rgba = null,
|
|
124
|
+
mapping = null,
|
|
125
|
+
integrity = null,
|
|
126
|
+
evidence = null,
|
|
127
|
+
} = {}) {
|
|
128
|
+
return {
|
|
129
|
+
family: P0_FAMILIES.A,
|
|
130
|
+
frameId: nullable(frameId),
|
|
131
|
+
sessionId: nullable(sessionId),
|
|
132
|
+
targetId: nullable(targetId),
|
|
133
|
+
generation: nullable(generation),
|
|
134
|
+
sourceTimestamp: nullable(sourceTimestamp),
|
|
135
|
+
receivedAt: nullable(receivedAt),
|
|
136
|
+
encoded: nullable(encoded),
|
|
137
|
+
rgba: nullable(rgba),
|
|
138
|
+
mapping: nullable(mapping),
|
|
139
|
+
integrity: nullable(integrity),
|
|
140
|
+
evidence: nullable(evidence),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* P0-B request key. `requestId` + `hopId` + (optional) `transportId`.
|
|
146
|
+
*
|
|
147
|
+
* `requestId` is only unique inside one transport (a CDP session), so a public
|
|
148
|
+
* key that omits the transport scope collides across transports. `transportId`
|
|
149
|
+
* is the irreversible, platform-neutral scope derived by
|
|
150
|
+
* `transportIdFromRef()`; a raw platform id (`cdpSessionId`) must never appear
|
|
151
|
+
* here. It is only emitted when supplied, so keys built without a transport
|
|
152
|
+
* scope keep their historical shape.
|
|
153
|
+
*/
|
|
154
|
+
export function createRequestKey({ requestId = null, hopId = 0, transportId = null } = {}) {
|
|
155
|
+
const key = { requestId: nullable(requestId), hopId: isInteger(hopId) ? hopId : 0 };
|
|
156
|
+
if (isText(transportId)) key.transportId = transportId;
|
|
157
|
+
return key;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compare two public request keys. Semantics: `transportId`, `requestId` and
|
|
162
|
+
* `hopId` must all be equal (a missing transport scope compares as `null`, so
|
|
163
|
+
* two legacy keys without one still match each other).
|
|
164
|
+
*
|
|
165
|
+
* @param {object} a
|
|
166
|
+
* @param {object} b
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function samePublicRequestKey(a, b) {
|
|
170
|
+
if (!isObject(a) || !isObject(b)) return false;
|
|
171
|
+
const transportId = (key) => (isText(key.transportId) ? key.transportId : null);
|
|
172
|
+
const requestId = (key) => nullable(key.requestId);
|
|
173
|
+
const hopId = (key) => (isInteger(key.hopId) ? key.hopId : 0);
|
|
174
|
+
return transportId(a) === transportId(b)
|
|
175
|
+
&& requestId(a) === requestId(b)
|
|
176
|
+
&& hopId(a) === hopId(b);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* P0-B neutral network event.
|
|
181
|
+
*/
|
|
182
|
+
export function createNetworkEvent({
|
|
183
|
+
eventId = null,
|
|
184
|
+
requestKey = null,
|
|
185
|
+
sessionId = null,
|
|
186
|
+
targetId = null,
|
|
187
|
+
generation = null,
|
|
188
|
+
phase = null,
|
|
189
|
+
kind = null,
|
|
190
|
+
method = null,
|
|
191
|
+
url = null,
|
|
192
|
+
status = null,
|
|
193
|
+
sourceTimestamp = null,
|
|
194
|
+
receivedAt = null,
|
|
195
|
+
body = null,
|
|
196
|
+
transportRef = null,
|
|
197
|
+
} = {}) {
|
|
198
|
+
return {
|
|
199
|
+
family: P0_FAMILIES.B,
|
|
200
|
+
eventId: nullable(eventId),
|
|
201
|
+
requestKey: nullable(requestKey),
|
|
202
|
+
sessionId: nullable(sessionId),
|
|
203
|
+
targetId: nullable(targetId),
|
|
204
|
+
generation: nullable(generation),
|
|
205
|
+
phase: nullable(phase),
|
|
206
|
+
kind: nullable(kind),
|
|
207
|
+
method: nullable(method),
|
|
208
|
+
url: nullable(url),
|
|
209
|
+
status: nullable(status),
|
|
210
|
+
sourceTimestamp: nullable(sourceTimestamp),
|
|
211
|
+
receivedAt: nullable(receivedAt),
|
|
212
|
+
body: nullable(body),
|
|
213
|
+
transportRef: nullable(transportRef),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* P0-C display space.
|
|
219
|
+
*/
|
|
220
|
+
export function createDisplaySpace({
|
|
221
|
+
space = null,
|
|
222
|
+
geometryVersion = null,
|
|
223
|
+
transform = null,
|
|
224
|
+
observedAt = null,
|
|
225
|
+
source = null,
|
|
226
|
+
} = {}) {
|
|
227
|
+
return {
|
|
228
|
+
family: P0_FAMILIES.C,
|
|
229
|
+
space: nullable(space),
|
|
230
|
+
geometryVersion: nullable(geometryVersion),
|
|
231
|
+
transform: nullable(transform),
|
|
232
|
+
observedAt: nullable(observedAt),
|
|
233
|
+
source: nullable(source),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* P0-D annotation document.
|
|
239
|
+
*/
|
|
240
|
+
export function createAnnotationDoc({
|
|
241
|
+
docId = null,
|
|
242
|
+
targetId = null,
|
|
243
|
+
generation = null,
|
|
244
|
+
geometryVersion = null,
|
|
245
|
+
revision = null,
|
|
246
|
+
shapes = null,
|
|
247
|
+
} = {}) {
|
|
248
|
+
return {
|
|
249
|
+
family: P0_FAMILIES.D,
|
|
250
|
+
docId: nullable(docId),
|
|
251
|
+
targetId: nullable(targetId),
|
|
252
|
+
generation: nullable(generation),
|
|
253
|
+
geometryVersion: nullable(geometryVersion),
|
|
254
|
+
revision: nullable(revision),
|
|
255
|
+
shapes: nullable(shapes),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* P0-E target identity.
|
|
261
|
+
*/
|
|
262
|
+
export function createTargetIdentity({
|
|
263
|
+
targetId = null,
|
|
264
|
+
environment = null,
|
|
265
|
+
kind = null,
|
|
266
|
+
generation = null,
|
|
267
|
+
label = null,
|
|
268
|
+
lifecycle = null,
|
|
269
|
+
} = {}) {
|
|
270
|
+
return {
|
|
271
|
+
family: P0_FAMILIES.E,
|
|
272
|
+
targetId: nullable(targetId),
|
|
273
|
+
environment: nullable(environment),
|
|
274
|
+
kind: nullable(kind),
|
|
275
|
+
generation: nullable(generation),
|
|
276
|
+
label: nullable(label),
|
|
277
|
+
lifecycle: nullable(lifecycle),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* P0-E lease.
|
|
283
|
+
*/
|
|
284
|
+
export function createLease({
|
|
285
|
+
leaseId = null,
|
|
286
|
+
targetId = null,
|
|
287
|
+
generation = null,
|
|
288
|
+
owner = null,
|
|
289
|
+
expiresAt = null,
|
|
290
|
+
} = {}) {
|
|
291
|
+
return {
|
|
292
|
+
family: P0_FAMILIES.E,
|
|
293
|
+
leaseId: nullable(leaseId),
|
|
294
|
+
targetId: nullable(targetId),
|
|
295
|
+
generation: nullable(generation),
|
|
296
|
+
owner: nullable(owner),
|
|
297
|
+
expiresAt: nullable(expiresAt),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Validation
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
function result() {
|
|
306
|
+
return { ok: true, missing: [], errors: [] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function addError(acc, code, path, message) {
|
|
310
|
+
acc.errors.push({ code, path, message });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function requireKeys(acc, object, keys, prefix = '') {
|
|
314
|
+
for (const key of keys) {
|
|
315
|
+
if (!hasOwn(object, key) || object[key] === undefined) {
|
|
316
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
317
|
+
acc.missing.push(path);
|
|
318
|
+
addError(acc, 'MISSING_FIELD', path, `${path} is required and must not be undefined`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function checkText(acc, object, key, path) {
|
|
324
|
+
if (hasOwn(object, key) && object[key] !== null && !isText(object[key])) {
|
|
325
|
+
addError(acc, 'INVALID_VALUE', path, `${path} must be a non-empty string or null`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function validateMappingObject(acc, mapping, path) {
|
|
330
|
+
if (!isObject(mapping)) {
|
|
331
|
+
addError(acc, 'INVALID_TYPE', path, `${path} must be an object`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
for (const key of ['from', 'to']) {
|
|
335
|
+
if (!hasOwn(mapping, key) || !DISPLAY_SPACES.includes(mapping[key])) {
|
|
336
|
+
addError(acc, 'INVALID_SPACE', `${path}.${key}`, `${path}.${key} must be one of ${DISPLAY_SPACES.join('|')}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const key of ['scaleX', 'scaleY']) {
|
|
340
|
+
if (!isFiniteNumber(mapping[key]) || mapping[key] <= 0) {
|
|
341
|
+
addError(acc, 'INVALID_SCALE', `${path}.${key}`, `${path}.${key} must be a positive finite number`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const key of ['offsetX', 'offsetY']) {
|
|
345
|
+
if (!isFiniteNumber(mapping[key])) {
|
|
346
|
+
addError(acc, 'INVALID_OFFSET', `${path}.${key}`, `${path}.${key} must be a finite number`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (hasOwn(mapping, 'geometryVersion') && mapping.geometryVersion !== null && !isInteger(mapping.geometryVersion)) {
|
|
350
|
+
addError(acc, 'INVALID_GEOMETRY_VERSION', `${path}.geometryVersion`, `${path}.geometryVersion must be an integer or null`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function validateIntegrity(acc, integrity, path) {
|
|
355
|
+
if (!isObject(integrity)) {
|
|
356
|
+
addError(acc, 'INVALID_TYPE', path, `${path} must be an object`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (!['complete', 'incomplete'].includes(integrity.state)) {
|
|
360
|
+
addError(acc, 'INVALID_INTEGRITY_STATE', `${path}.state`, `${path}.state must be "complete" or "incomplete"`);
|
|
361
|
+
}
|
|
362
|
+
for (const key of ['dropped', 'evicted', 'incomplete']) {
|
|
363
|
+
if (!hasOwn(integrity, key)) {
|
|
364
|
+
addError(acc, 'MISSING_INTEGRITY_COUNTER', `${path}.${key}`, `${path}.${key} is required`);
|
|
365
|
+
} else if (!isInteger(integrity[key]) || integrity[key] < 0) {
|
|
366
|
+
addError(acc, 'INVALID_INTEGRITY_COUNTER', `${path}.${key}`, `${path}.${key} must be a non-negative integer`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function validateEvidence(acc, evidence, path) {
|
|
372
|
+
if (!isObject(evidence)) {
|
|
373
|
+
addError(acc, 'INVALID_EVIDENCE', path, `${path} must be an object`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
for (const key of P0_EVIDENCE_FIELDS) {
|
|
377
|
+
if (!hasOwn(evidence, key) || evidence[key] === undefined) {
|
|
378
|
+
addError(acc, 'MISSING_EVIDENCE_FIELD', `${path}.${key}`, `${path}.${key} is required on the evidence envelope`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (hasOwn(evidence, 'version') && evidence.version !== 1) {
|
|
382
|
+
addError(acc, 'INVALID_EVIDENCE_VERSION', `${path}.version`, `${path}.version must be 1`);
|
|
383
|
+
}
|
|
384
|
+
if (hasOwn(evidence, 'capturedAt') && !isFiniteNumber(evidence.capturedAt)) {
|
|
385
|
+
addError(acc, 'INVALID_EVIDENCE_TIME', `${path}.capturedAt`, `${path}.capturedAt must be a finite epoch-millisecond number`);
|
|
386
|
+
}
|
|
387
|
+
if (hasOwn(evidence, 'generation') && !isInteger(evidence.generation)) {
|
|
388
|
+
addError(acc, 'INVALID_EVIDENCE_GENERATION', `${path}.generation`, `${path}.generation must be an integer`);
|
|
389
|
+
}
|
|
390
|
+
if (hasOwn(evidence, 'configRevision') && !isInteger(evidence.configRevision)) {
|
|
391
|
+
addError(acc, 'INVALID_EVIDENCE_CONFIG_REVISION', `${path}.configRevision`, `${path}.configRevision must be an integer`);
|
|
392
|
+
}
|
|
393
|
+
for (const key of ['scope', 'session', 'target']) {
|
|
394
|
+
if (hasOwn(evidence, key) && evidence[key] !== null && typeof evidence[key] !== 'string') {
|
|
395
|
+
addError(acc, 'INVALID_EVIDENCE_IDENTITY', `${path}.${key}`, `${path}.${key} must be a string or null`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function finish(acc) {
|
|
401
|
+
acc.ok = acc.missing.length === 0 && acc.errors.length === 0;
|
|
402
|
+
return acc;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @returns {{ok:boolean, missing:string[], errors:{code:string,path:string,message:string}[]}}
|
|
407
|
+
*/
|
|
408
|
+
export function validateFrame(frame) {
|
|
409
|
+
const acc = result();
|
|
410
|
+
if (!isObject(frame)) {
|
|
411
|
+
addError(acc, 'INVALID_TYPE', 'frame', 'frame must be an object');
|
|
412
|
+
acc.missing.push('frame');
|
|
413
|
+
return finish(acc);
|
|
414
|
+
}
|
|
415
|
+
requireKeys(acc, frame, ['frameId', 'sessionId', 'targetId', 'generation', 'sourceTimestamp', 'receivedAt', 'encoded', 'rgba', 'mapping', 'integrity', 'evidence']);
|
|
416
|
+
for (const key of ['frameId', 'sessionId', 'targetId']) checkText(acc, frame, key, key);
|
|
417
|
+
if (hasOwn(frame, 'generation') && (!isInteger(frame.generation) || frame.generation < 1)) {
|
|
418
|
+
addError(acc, 'INVALID_GENERATION', 'generation', 'generation must be an integer >= 1');
|
|
419
|
+
}
|
|
420
|
+
if (hasOwn(frame, 'sourceTimestamp') && frame.sourceTimestamp !== null && !isFiniteNumber(frame.sourceTimestamp)) {
|
|
421
|
+
addError(acc, 'INVALID_SOURCE_TIMESTAMP', 'sourceTimestamp', 'sourceTimestamp must be null or a finite epoch-millisecond number');
|
|
422
|
+
}
|
|
423
|
+
if (hasOwn(frame, 'receivedAt') && !isFiniteNumber(frame.receivedAt)) {
|
|
424
|
+
addError(acc, 'INVALID_RECEIVED_AT', 'receivedAt', 'receivedAt must be a finite epoch-millisecond number');
|
|
425
|
+
}
|
|
426
|
+
if (hasOwn(frame, 'encoded')) {
|
|
427
|
+
const encoded = frame.encoded;
|
|
428
|
+
if (!isObject(encoded)) {
|
|
429
|
+
addError(acc, 'INVALID_TYPE', 'encoded', 'encoded must be an object');
|
|
430
|
+
} else {
|
|
431
|
+
requireKeys(acc, encoded, ['format', 'bytes', 'sha256'], 'encoded');
|
|
432
|
+
if (hasOwn(encoded, 'format') && !isText(encoded.format)) addError(acc, 'INVALID_ENCODING_FORMAT', 'encoded.format', 'encoded.format must be a non-empty string');
|
|
433
|
+
if (hasOwn(encoded, 'bytes') && !(encoded.bytes instanceof Uint8Array)) addError(acc, 'INVALID_ENCODING_BYTES', 'encoded.bytes', 'encoded.bytes must be a Uint8Array/Buffer');
|
|
434
|
+
if (hasOwn(encoded, 'sha256') && !isHex64(encoded.sha256)) addError(acc, 'INVALID_ENCODING_SHA256', 'encoded.sha256', 'encoded.sha256 must be a lowercase 64-char hex digest');
|
|
435
|
+
if (isObject(encoded) && hasOwn(encoded, 'byteLength') && encoded.bytes instanceof Uint8Array && encoded.byteLength !== encoded.bytes.length) {
|
|
436
|
+
addError(acc, 'INCONSISTENT_ENCODING_LENGTH', 'encoded.byteLength', 'encoded.byteLength must equal encoded.bytes.length');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (hasOwn(frame, 'rgba')) {
|
|
441
|
+
const rgba = frame.rgba;
|
|
442
|
+
if (!isObject(rgba)) {
|
|
443
|
+
addError(acc, 'INVALID_TYPE', 'rgba', 'rgba must be an object');
|
|
444
|
+
} else {
|
|
445
|
+
requireKeys(acc, rgba, ['width', 'height', 'sha256'], 'rgba');
|
|
446
|
+
for (const key of ['width', 'height']) {
|
|
447
|
+
if (!isInteger(rgba[key]) || rgba[key] < 1) addError(acc, 'INVALID_RGBA_DIMENSION', `rgba.${key}`, `rgba.${key} must be an integer >= 1`);
|
|
448
|
+
}
|
|
449
|
+
if (rgba.sha256 !== null && rgba.sha256 !== undefined && !isHex64(rgba.sha256)) addError(acc, 'INVALID_RGBA_SHA256', 'rgba.sha256', 'rgba.sha256 must be null or a lowercase 64-char hex digest');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (hasOwn(frame, 'mapping')) validateMappingObject(acc, frame.mapping, 'mapping');
|
|
453
|
+
if (hasOwn(frame, 'integrity')) validateIntegrity(acc, frame.integrity, 'integrity');
|
|
454
|
+
if (hasOwn(frame, 'evidence')) validateEvidence(acc, frame.evidence, 'evidence');
|
|
455
|
+
return finish(acc);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* @returns {{ok:boolean, missing:string[], errors:{code:string,path:string,message:string}[]}}
|
|
460
|
+
*/
|
|
461
|
+
export function validateNetworkEvent(event) {
|
|
462
|
+
const acc = result();
|
|
463
|
+
if (!isObject(event)) {
|
|
464
|
+
addError(acc, 'INVALID_TYPE', 'event', 'event must be an object');
|
|
465
|
+
acc.missing.push('event');
|
|
466
|
+
return finish(acc);
|
|
467
|
+
}
|
|
468
|
+
requireKeys(acc, event, ['eventId', 'requestKey', 'sessionId', 'targetId', 'generation', 'phase', 'kind', 'method', 'url', 'status', 'sourceTimestamp', 'receivedAt', 'body', 'transportRef']);
|
|
469
|
+
|
|
470
|
+
// Platform identity must never leak into public keys (m0 §3 rule 2).
|
|
471
|
+
if (hasOwn(event, 'cdpSessionId')) {
|
|
472
|
+
addError(acc, 'PLATFORM_IDENTITY_LEAK', 'cdpSessionId', 'cdpSessionId must not appear on a neutral network event; move it into transportRef');
|
|
473
|
+
}
|
|
474
|
+
if (isObject(event.requestKey) && hasOwn(event.requestKey, 'cdpSessionId')) {
|
|
475
|
+
addError(acc, 'PLATFORM_IDENTITY_LEAK', 'requestKey.cdpSessionId', 'cdpSessionId must not appear on a neutral request key; move it into transportRef');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const key of ['eventId', 'sessionId', 'targetId', 'url']) checkText(acc, event, key, key);
|
|
479
|
+
if (hasOwn(event, 'generation') && (!isInteger(event.generation) || event.generation < 1)) {
|
|
480
|
+
addError(acc, 'INVALID_GENERATION', 'generation', 'generation must be an integer >= 1');
|
|
481
|
+
}
|
|
482
|
+
if (hasOwn(event, 'sourceTimestamp') && event.sourceTimestamp !== null && !isFiniteNumber(event.sourceTimestamp)) {
|
|
483
|
+
addError(acc, 'INVALID_SOURCE_TIMESTAMP', 'sourceTimestamp', 'sourceTimestamp must be null or a finite epoch-millisecond number');
|
|
484
|
+
}
|
|
485
|
+
if (hasOwn(event, 'receivedAt') && !isFiniteNumber(event.receivedAt)) {
|
|
486
|
+
addError(acc, 'INVALID_RECEIVED_AT', 'receivedAt', 'receivedAt must be a finite epoch-millisecond number');
|
|
487
|
+
}
|
|
488
|
+
if (hasOwn(event, 'phase') && !NETWORK_PHASES.includes(event.phase)) {
|
|
489
|
+
addError(acc, 'INVALID_PHASE', 'phase', `phase must be one of ${NETWORK_PHASES.join('|')}`);
|
|
490
|
+
}
|
|
491
|
+
if (hasOwn(event, 'kind') && !NETWORK_KINDS.includes(event.kind)) {
|
|
492
|
+
addError(acc, 'INVALID_KIND', 'kind', `kind must be one of ${NETWORK_KINDS.join('|')}`);
|
|
493
|
+
}
|
|
494
|
+
if (hasOwn(event, 'method') && event.method !== null && !isText(event.method)) {
|
|
495
|
+
addError(acc, 'INVALID_METHOD', 'method', 'method must be a non-empty string or null');
|
|
496
|
+
}
|
|
497
|
+
if (hasOwn(event, 'status') && event.status !== null && (!isInteger(event.status) || event.status < 0 || event.status > 999)) {
|
|
498
|
+
addError(acc, 'INVALID_STATUS', 'status', 'status must be null or an integer between 0 and 999');
|
|
499
|
+
}
|
|
500
|
+
if (hasOwn(event, 'requestKey')) {
|
|
501
|
+
const requestKey = event.requestKey;
|
|
502
|
+
if (!isObject(requestKey)) {
|
|
503
|
+
addError(acc, 'INVALID_TYPE', 'requestKey', 'requestKey must be an object');
|
|
504
|
+
} else {
|
|
505
|
+
requireKeys(acc, requestKey, ['requestId', 'hopId'], 'requestKey');
|
|
506
|
+
if (hasOwn(requestKey, 'requestId') && !isText(requestKey.requestId)) addError(acc, 'INVALID_REQUEST_ID', 'requestKey.requestId', 'requestKey.requestId must be a non-empty string');
|
|
507
|
+
if (hasOwn(requestKey, 'hopId') && (!isInteger(requestKey.hopId) || requestKey.hopId < 0)) addError(acc, 'INVALID_HOP_ID', 'requestKey.hopId', 'requestKey.hopId must be an integer >= 0');
|
|
508
|
+
// `transportId` is optional so keys built before the transport scope
|
|
509
|
+
// existed still validate, but a present-and-malformed value (null, '',
|
|
510
|
+
// non-string) is an error rather than silently accepted.
|
|
511
|
+
if (hasOwn(requestKey, 'transportId') && !isText(requestKey.transportId)) {
|
|
512
|
+
addError(acc, 'INVALID_TYPE', 'requestKey.transportId', 'requestKey.transportId must be a non-empty string when present');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (hasOwn(event, 'body')) {
|
|
517
|
+
const body = event.body;
|
|
518
|
+
if (!isObject(body)) {
|
|
519
|
+
addError(acc, 'INVALID_TYPE', 'body', 'body must be an object');
|
|
520
|
+
} else {
|
|
521
|
+
if (!BODY_STATES.includes(body.state)) addError(acc, 'INVALID_BODY_STATE', 'body.state', `body.state must be one of ${BODY_STATES.join('|')}`);
|
|
522
|
+
if (body.state === 'captured' && hasOwn(body, 'data') && typeof body.data !== 'string') addError(acc, 'INVALID_BODY_DATA', 'body.data', 'body.data must be a string when present');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (hasOwn(event, 'transportRef')) {
|
|
526
|
+
const transportRef = event.transportRef;
|
|
527
|
+
if (!isObject(transportRef)) {
|
|
528
|
+
addError(acc, 'INVALID_TYPE', 'transportRef', 'transportRef must be an object');
|
|
529
|
+
} else if (!isText(transportRef.kind)) {
|
|
530
|
+
addError(acc, 'INVALID_TRANSPORT_KIND', 'transportRef.kind', 'transportRef.kind must be a non-empty string');
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return finish(acc);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* @returns {{ok:boolean, missing:string[], errors:{code:string,path:string,message:string}[]}}
|
|
538
|
+
*/
|
|
539
|
+
export function validateDisplaySpace(display) {
|
|
540
|
+
const acc = result();
|
|
541
|
+
if (!isObject(display)) {
|
|
542
|
+
addError(acc, 'INVALID_TYPE', 'display', 'display must be an object');
|
|
543
|
+
acc.missing.push('display');
|
|
544
|
+
return finish(acc);
|
|
545
|
+
}
|
|
546
|
+
requireKeys(acc, display, ['space', 'geometryVersion', 'transform', 'observedAt', 'source']);
|
|
547
|
+
if (hasOwn(display, 'space') && !DISPLAY_SPACES.includes(display.space)) {
|
|
548
|
+
addError(acc, 'INVALID_SPACE', 'space', `space must be one of ${DISPLAY_SPACES.join('|')}`);
|
|
549
|
+
}
|
|
550
|
+
if (hasOwn(display, 'geometryVersion') && (!isInteger(display.geometryVersion) || display.geometryVersion < 0)) {
|
|
551
|
+
addError(acc, 'INVALID_GEOMETRY_VERSION', 'geometryVersion', 'geometryVersion must be an integer >= 0');
|
|
552
|
+
}
|
|
553
|
+
if (hasOwn(display, 'observedAt') && !isFiniteNumber(display.observedAt)) {
|
|
554
|
+
addError(acc, 'INVALID_OBSERVED_AT', 'observedAt', 'observedAt must be a finite epoch-millisecond number');
|
|
555
|
+
}
|
|
556
|
+
if (hasOwn(display, 'source') && !isText(display.source)) {
|
|
557
|
+
addError(acc, 'INVALID_SOURCE', 'source', 'source must be a non-empty string');
|
|
558
|
+
}
|
|
559
|
+
if (hasOwn(display, 'transform')) {
|
|
560
|
+
validateMappingObject(acc, display.transform, 'transform');
|
|
561
|
+
if (isObject(display.transform) && hasOwn(display.transform, 'from') && hasOwn(display, 'space') && display.transform.from !== display.space) {
|
|
562
|
+
addError(acc, 'INVALID_TRANSFORM_ORIGIN', 'transform.from', 'transform.from must equal display.space');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return finish(acc);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* @returns {{ok:boolean, missing:string[], errors:{code:string,path:string,message:string}[]}}
|
|
570
|
+
*/
|
|
571
|
+
export function validateAnnotationDoc(doc) {
|
|
572
|
+
const acc = result();
|
|
573
|
+
if (!isObject(doc)) {
|
|
574
|
+
addError(acc, 'INVALID_TYPE', 'doc', 'annotation doc must be an object');
|
|
575
|
+
acc.missing.push('doc');
|
|
576
|
+
return finish(acc);
|
|
577
|
+
}
|
|
578
|
+
requireKeys(acc, doc, ['docId', 'targetId', 'generation', 'geometryVersion', 'revision', 'shapes']);
|
|
579
|
+
for (const key of ['docId', 'targetId']) checkText(acc, doc, key, key);
|
|
580
|
+
if (hasOwn(doc, 'generation') && (!isInteger(doc.generation) || doc.generation < 1)) {
|
|
581
|
+
addError(acc, 'INVALID_GENERATION', 'generation', 'generation must be an integer >= 1');
|
|
582
|
+
}
|
|
583
|
+
if (hasOwn(doc, 'geometryVersion') && (!isInteger(doc.geometryVersion) || doc.geometryVersion < 0)) {
|
|
584
|
+
addError(acc, 'INVALID_GEOMETRY_VERSION', 'geometryVersion', 'geometryVersion must be an integer >= 0');
|
|
585
|
+
}
|
|
586
|
+
if (hasOwn(doc, 'revision') && (!isInteger(doc.revision) || doc.revision < 0)) {
|
|
587
|
+
addError(acc, 'INVALID_REVISION', 'revision', 'revision must be an integer >= 0');
|
|
588
|
+
}
|
|
589
|
+
if (hasOwn(doc, 'shapes')) {
|
|
590
|
+
if (!Array.isArray(doc.shapes)) {
|
|
591
|
+
addError(acc, 'INVALID_TYPE', 'shapes', 'shapes must be an array');
|
|
592
|
+
} else {
|
|
593
|
+
const seen = new Set();
|
|
594
|
+
doc.shapes.forEach((shape, index) => {
|
|
595
|
+
const path = `shapes[${index}]`;
|
|
596
|
+
if (!isObject(shape)) {
|
|
597
|
+
addError(acc, 'INVALID_SHAPE', path, 'each shape must be an object');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (!isText(shape.shapeId)) addError(acc, 'INVALID_SHAPE_ID', `${path}.shapeId`, 'shape.shapeId must be a non-empty string');
|
|
601
|
+
if (!isText(shape.kind)) addError(acc, 'INVALID_SHAPE_KIND', `${path}.kind`, 'shape.kind must be a non-empty string');
|
|
602
|
+
if (isText(shape.shapeId)) {
|
|
603
|
+
if (seen.has(shape.shapeId)) addError(acc, 'DUPLICATE_SHAPE_ID', `${path}.shapeId`, `duplicate shape id "${shape.shapeId}"`);
|
|
604
|
+
seen.add(shape.shapeId);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return finish(acc);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @returns {{ok:boolean, missing:string[], errors:{code:string,path:string,message:string}[]}}
|
|
614
|
+
*/
|
|
615
|
+
export function validateTargetIdentity(identity) {
|
|
616
|
+
const acc = result();
|
|
617
|
+
if (!isObject(identity)) {
|
|
618
|
+
addError(acc, 'INVALID_TYPE', 'target', 'target identity must be an object');
|
|
619
|
+
acc.missing.push('target');
|
|
620
|
+
return finish(acc);
|
|
621
|
+
}
|
|
622
|
+
requireKeys(acc, identity, ['targetId', 'environment', 'kind', 'generation', 'label', 'lifecycle']);
|
|
623
|
+
for (const key of ['targetId', 'environment', 'kind', 'label']) checkText(acc, identity, key, key);
|
|
624
|
+
if (hasOwn(identity, 'generation') && (!isInteger(identity.generation) || identity.generation < 1)) {
|
|
625
|
+
addError(acc, 'INVALID_GENERATION', 'generation', 'generation must be an integer >= 1');
|
|
626
|
+
}
|
|
627
|
+
if (hasOwn(identity, 'lifecycle') && !TARGET_LIFECYCLES.includes(identity.lifecycle)) {
|
|
628
|
+
addError(acc, 'INVALID_LIFECYCLE', 'lifecycle', `lifecycle must be one of ${TARGET_LIFECYCLES.join('|')}`);
|
|
629
|
+
}
|
|
630
|
+
return finish(acc);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* @returns {{ok:boolean, missing:string[], errors:{code:string,path:string,message:string}[]}}
|
|
635
|
+
*/
|
|
636
|
+
export function validateLease(lease) {
|
|
637
|
+
const acc = result();
|
|
638
|
+
if (!isObject(lease)) {
|
|
639
|
+
addError(acc, 'INVALID_TYPE', 'lease', 'lease must be an object');
|
|
640
|
+
acc.missing.push('lease');
|
|
641
|
+
return finish(acc);
|
|
642
|
+
}
|
|
643
|
+
requireKeys(acc, lease, ['leaseId', 'targetId', 'generation', 'owner', 'expiresAt']);
|
|
644
|
+
for (const key of ['leaseId', 'targetId']) checkText(acc, lease, key, key);
|
|
645
|
+
if (hasOwn(lease, 'generation') && (!isInteger(lease.generation) || lease.generation < 1)) {
|
|
646
|
+
addError(acc, 'INVALID_GENERATION', 'generation', 'generation must be an integer >= 1');
|
|
647
|
+
}
|
|
648
|
+
if (hasOwn(lease, 'expiresAt') && !isFiniteNumber(lease.expiresAt)) {
|
|
649
|
+
addError(acc, 'INVALID_EXPIRES_AT', 'expiresAt', 'expiresAt must be a finite epoch-millisecond number');
|
|
650
|
+
}
|
|
651
|
+
if (hasOwn(lease, 'owner')) {
|
|
652
|
+
const owner = lease.owner;
|
|
653
|
+
const valid = isText(owner) || (isObject(owner) && isText(owner.id));
|
|
654
|
+
if (!valid) addError(acc, 'INVALID_OWNER', 'owner', 'owner must be a non-empty string or an object with a non-empty id');
|
|
655
|
+
}
|
|
656
|
+
return finish(acc);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Web -> neutral converters
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
function pickSourceTimestamp(event) {
|
|
664
|
+
// An explicit sourceTimestamp (including null) is authoritative: a missing
|
|
665
|
+
// source clock is never replaced by receivedAt.
|
|
666
|
+
if (hasOwn(event, 'sourceTimestamp')) return nullable(event.sourceTimestamp);
|
|
667
|
+
const ts = event.ts;
|
|
668
|
+
if (isObject(ts)) {
|
|
669
|
+
if (isFiniteNumber(ts.wallMs)) return ts.wallMs;
|
|
670
|
+
if (isFiniteNumber(ts.cdpSeconds)) return Math.round(ts.cdpSeconds * 1000);
|
|
671
|
+
}
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Derive an irreversible, platform-neutral transport scope id from a
|
|
677
|
+
* transportRef (`kind|cdpSessionId|targetId|frameId|loaderId`, sha256, first 16
|
|
678
|
+
* hex chars). The raw ids never enter a public key, so `PLATFORM_IDENTITY_LEAK`
|
|
679
|
+
* keeps holding; the digest only makes identical request ids comparable per
|
|
680
|
+
* transport. A reference without any transport identity field (e.g. a purely
|
|
681
|
+
* local source) falls back to a stable `<kind>:local` value.
|
|
682
|
+
*
|
|
683
|
+
* @param {object} transportRef
|
|
684
|
+
* @returns {string}
|
|
685
|
+
*/
|
|
686
|
+
export function transportIdFromRef(transportRef = {}) {
|
|
687
|
+
const ref = isObject(transportRef) ? transportRef : {};
|
|
688
|
+
const kind = isText(ref.kind) ? ref.kind : null;
|
|
689
|
+
const parts = ['cdpSessionId', 'targetId', 'frameId', 'loaderId']
|
|
690
|
+
.map((key) => (isText(ref[key]) ? ref[key] : ''));
|
|
691
|
+
if (parts.every((part) => part === '')) return `${kind ?? 'neutral'}:local`;
|
|
692
|
+
return createHash('sha256')
|
|
693
|
+
.update([kind ?? 'unknown', ...parts].join('|'))
|
|
694
|
+
.digest('hex')
|
|
695
|
+
.slice(0, 16);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Convert a Web/CDP-shaped network event (network-p0.js) to the neutral model.
|
|
700
|
+
* `cdpSessionId` moves to `transportRef`; it never survives as a public key.
|
|
701
|
+
* `requestKey.transportId` carries the irreversible transport scope, so equal
|
|
702
|
+
* `requestId`s from different transports get different public keys.
|
|
703
|
+
*
|
|
704
|
+
* @param {object} webEvent
|
|
705
|
+
* @returns {object} neutral network event
|
|
706
|
+
*/
|
|
707
|
+
export function toNeutralNetworkEvent(webEvent = {}) {
|
|
708
|
+
if (!isObject(webEvent)) {
|
|
709
|
+
throw new AdapterError('INVALID_ARGUMENT', 'toNeutralNetworkEvent requires a web event object', { reason: 'event-not-object' });
|
|
710
|
+
}
|
|
711
|
+
const requestKeyInput = isObject(webEvent.requestKey) ? webEvent.requestKey : null;
|
|
712
|
+
const requestId = requestKeyInput ? nullable(requestKeyInput.requestId) : nullable(webEvent.requestId);
|
|
713
|
+
const hopId = requestKeyInput && isInteger(requestKeyInput.hopId)
|
|
714
|
+
? requestKeyInput.hopId
|
|
715
|
+
: (isInteger(webEvent.hopId) ? webEvent.hopId : 0);
|
|
716
|
+
const transportRef = {
|
|
717
|
+
kind: 'web',
|
|
718
|
+
cdpSessionId: nullable(webEvent.cdpSessionId),
|
|
719
|
+
targetId: nullable(webEvent.targetId),
|
|
720
|
+
frameId: nullable(webEvent.frameId),
|
|
721
|
+
loaderId: nullable(webEvent.loaderId),
|
|
722
|
+
seq: nullable(webEvent.seq),
|
|
723
|
+
};
|
|
724
|
+
const transportId = transportIdFromRef(transportRef);
|
|
725
|
+
const eventId = isText(webEvent.eventId)
|
|
726
|
+
? webEvent.eventId
|
|
727
|
+
: `web:${transportId}:${nullable(webEvent.sessionId) ?? 'session'}:${nullable(webEvent.seq) ?? requestId ?? 0}:${nullable(webEvent.phase) ?? 'request'}:${hopId}`;
|
|
728
|
+
const receivedAt = isFiniteNumber(webEvent.receivedAt)
|
|
729
|
+
? webEvent.receivedAt
|
|
730
|
+
: (isObject(webEvent.ts) && isFiniteNumber(webEvent.ts.wallMs) ? webEvent.ts.wallMs : null);
|
|
731
|
+
|
|
732
|
+
return createNetworkEvent({
|
|
733
|
+
eventId,
|
|
734
|
+
requestKey: createRequestKey({ requestId, hopId, transportId }),
|
|
735
|
+
sessionId: nullable(webEvent.sessionId),
|
|
736
|
+
targetId: nullable(webEvent.targetId),
|
|
737
|
+
generation: nullable(webEvent.generation),
|
|
738
|
+
phase: nullable(webEvent.phase) ?? 'request',
|
|
739
|
+
kind: nullable(webEvent.kind) ?? 'http',
|
|
740
|
+
method: nullable(webEvent.method),
|
|
741
|
+
url: nullable(webEvent.url) ?? '',
|
|
742
|
+
status: nullable(webEvent.status),
|
|
743
|
+
sourceTimestamp: pickSourceTimestamp(webEvent),
|
|
744
|
+
receivedAt,
|
|
745
|
+
body: nullable(webEvent.body) ?? { state: 'unavailable', reasonCode: 'not-captured' },
|
|
746
|
+
transportRef,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Convert a Web FrameSource frame (base64 encoded JPEG/PNG + page geometry) to
|
|
752
|
+
* the neutral model. `capturedAt` is the source clock; `receivedAt` is kept
|
|
753
|
+
* separate and never substituted for it.
|
|
754
|
+
*
|
|
755
|
+
* `mapping` declares `from: 'document-css-px' -> to: 'image-px'`, so it scales
|
|
756
|
+
* by `imageSize / pageSize` and shifts by `-scroll * scale`.
|
|
757
|
+
*
|
|
758
|
+
* @param {object} webFrame
|
|
759
|
+
* @returns {object} neutral frame
|
|
760
|
+
*/
|
|
761
|
+
export function toNeutralFrame(webFrame = {}) {
|
|
762
|
+
if (!isObject(webFrame)) {
|
|
763
|
+
throw new AdapterError('INVALID_ARGUMENT', 'toNeutralFrame requires a web frame object', { reason: 'frame-not-object' });
|
|
764
|
+
}
|
|
765
|
+
const raw = hasOwn(webFrame, 'data') ? webFrame.data : (isObject(webFrame.encoded) ? webFrame.encoded.base64 : null);
|
|
766
|
+
let bytes;
|
|
767
|
+
if (typeof raw === 'string') {
|
|
768
|
+
bytes = Buffer.from(raw, 'base64');
|
|
769
|
+
} else if (raw instanceof Uint8Array) {
|
|
770
|
+
bytes = raw;
|
|
771
|
+
} else if (isObject(webFrame.encoded) && webFrame.encoded.bytes instanceof Uint8Array) {
|
|
772
|
+
bytes = webFrame.encoded.bytes;
|
|
773
|
+
} else {
|
|
774
|
+
bytes = new Uint8Array(0);
|
|
775
|
+
}
|
|
776
|
+
const format = isText(webFrame.format)
|
|
777
|
+
? webFrame.format
|
|
778
|
+
: (isObject(webFrame.encoded) && isText(webFrame.encoded.format) ? webFrame.encoded.format : 'jpeg');
|
|
779
|
+
const width = positiveCount(webFrame.width);
|
|
780
|
+
const height = positiveCount(webFrame.height);
|
|
781
|
+
const pageWidth = positiveCount(webFrame.pageWidth) || width;
|
|
782
|
+
const pageHeight = positiveCount(webFrame.pageHeight) || height;
|
|
783
|
+
// The mapping is declared `document-css-px -> image-px`, so the scale is
|
|
784
|
+
// imageSize / pageSize: image_px = (document_css_px - scroll) * scale.
|
|
785
|
+
// A missing page size falls back to the image size above, which yields 1.
|
|
786
|
+
const scaleX = pageWidth > 0 ? width / pageWidth : 1;
|
|
787
|
+
const scaleY = pageHeight > 0 ? height / pageHeight : 1;
|
|
788
|
+
const scrollX = isFiniteNumber(webFrame.scrollX) ? webFrame.scrollX : 0;
|
|
789
|
+
const scrollY = isFiniteNumber(webFrame.scrollY) ? webFrame.scrollY : 0;
|
|
790
|
+
const geometryVersion = isInteger(webFrame.geometryVersion) ? webFrame.geometryVersion : 1;
|
|
791
|
+
const sessionId = nullable(webFrame.sessionId);
|
|
792
|
+
const targetId = nullable(webFrame.targetId);
|
|
793
|
+
const generation = nullable(webFrame.generation);
|
|
794
|
+
const receivedAt = isFiniteNumber(webFrame.receivedAt) ? webFrame.receivedAt : null;
|
|
795
|
+
const sourceTimestamp = hasOwn(webFrame, 'capturedAt') ? nullable(webFrame.capturedAt) : null;
|
|
796
|
+
|
|
797
|
+
return createFrame({
|
|
798
|
+
frameId: isText(webFrame.frameId)
|
|
799
|
+
? webFrame.frameId
|
|
800
|
+
: `${sessionId ?? 'session'}:${targetId ?? 'target'}:${nullable(webFrame.seq) ?? 0}`,
|
|
801
|
+
sessionId,
|
|
802
|
+
targetId,
|
|
803
|
+
generation,
|
|
804
|
+
sourceTimestamp,
|
|
805
|
+
receivedAt,
|
|
806
|
+
encoded: { format, bytes, sha256: sha256Hex(bytes), byteLength: bytes.length },
|
|
807
|
+
rgba: { width: width || 0, height: height || 0, sha256: null },
|
|
808
|
+
mapping: {
|
|
809
|
+
from: 'document-css-px',
|
|
810
|
+
to: 'image-px',
|
|
811
|
+
scaleX,
|
|
812
|
+
scaleY,
|
|
813
|
+
// `-0` is normalised to `0`: it is the same point on the page, and a
|
|
814
|
+
// signed zero would make otherwise identical mappings compare unequal.
|
|
815
|
+
offsetX: scrollX === 0 ? 0 : -scrollX * scaleX,
|
|
816
|
+
offsetY: scrollY === 0 ? 0 : -scrollY * scaleY,
|
|
817
|
+
geometryVersion,
|
|
818
|
+
},
|
|
819
|
+
integrity: { state: 'complete', dropped: 0, evicted: 0, incomplete: 0, reason: null },
|
|
820
|
+
evidence: {
|
|
821
|
+
version: 1,
|
|
822
|
+
scope: nullable(webFrame.scope),
|
|
823
|
+
session: sessionId,
|
|
824
|
+
target: targetId,
|
|
825
|
+
generation,
|
|
826
|
+
configRevision: isInteger(webFrame.configRevision) ? webFrame.configRevision : 0,
|
|
827
|
+
operation: 'p0.frame.capture',
|
|
828
|
+
source: isText(webFrame.source) ? webFrame.source : 'web.frame-source',
|
|
829
|
+
capturedAt: receivedAt ?? 0,
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Export a neutral annotation document in the legacy format. The legacy export
|
|
836
|
+
* always carries an explicit `formatVersion`.
|
|
837
|
+
*/
|
|
838
|
+
export function fromNeutralAnnotation(doc = {}) {
|
|
839
|
+
const check = validateAnnotationDoc(doc);
|
|
840
|
+
if (!check.ok) {
|
|
841
|
+
throw new AdapterError('INVALID_ARGUMENT', 'annotation doc is not a valid neutral document', {
|
|
842
|
+
missing: check.missing,
|
|
843
|
+
errors: check.errors,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
formatVersion: LEGACY_ANNOTATION_FORMAT_VERSION,
|
|
848
|
+
kind: 'annotation-doc',
|
|
849
|
+
docId: doc.docId,
|
|
850
|
+
targetId: doc.targetId,
|
|
851
|
+
generation: doc.generation,
|
|
852
|
+
geometryVersion: doc.geometryVersion,
|
|
853
|
+
revision: doc.revision,
|
|
854
|
+
shapes: doc.shapes.map((shape) => ({ ...shape })),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Import a legacy annotation document. A legacy document without an explicit
|
|
860
|
+
* `formatVersion` is rejected rather than silently guessed.
|
|
861
|
+
*/
|
|
862
|
+
export function toNeutralAnnotation(legacy = {}) {
|
|
863
|
+
if (!isObject(legacy) || legacy.formatVersion !== LEGACY_ANNOTATION_FORMAT_VERSION) {
|
|
864
|
+
throw new AdapterError('CONFIG_INVALID', 'legacy annotation document must carry formatVersion 1', {
|
|
865
|
+
reason: 'format-version-missing',
|
|
866
|
+
formatVersion: isObject(legacy) ? nullable(legacy.formatVersion) : null,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
return createAnnotationDoc({
|
|
870
|
+
docId: legacy.docId,
|
|
871
|
+
targetId: legacy.targetId,
|
|
872
|
+
generation: legacy.generation,
|
|
873
|
+
geometryVersion: legacy.geometryVersion,
|
|
874
|
+
revision: legacy.revision,
|
|
875
|
+
shapes: Array.isArray(legacy.shapes) ? legacy.shapes.map((shape) => ({ ...shape })) : null,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
// P0 provider interface helpers
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Assert the minimum P0 provider contract: `manifest.class === 'p0'`, at least
|
|
885
|
+
* one valid family, and — if present — each of the five family methods is an
|
|
886
|
+
* object. All five family methods are optional; a missing method is reported as
|
|
887
|
+
* `unsupported` by providerCapabilities, never faked.
|
|
888
|
+
*/
|
|
889
|
+
export function assertProvider(provider) {
|
|
890
|
+
if (!isObject(provider)) {
|
|
891
|
+
throw new AdapterError('INVALID_ARGUMENT', 'p0 provider must be an object', { reason: 'provider-not-object' });
|
|
892
|
+
}
|
|
893
|
+
const manifest = provider.manifest;
|
|
894
|
+
if (!isObject(manifest)) {
|
|
895
|
+
throw new AdapterError('CAPABILITY_UNSUPPORTED', 'p0 provider must expose a manifest', { reason: 'manifest-missing' });
|
|
896
|
+
}
|
|
897
|
+
if (manifest.class !== 'p0') {
|
|
898
|
+
throw new AdapterError('CAPABILITY_UNSUPPORTED', 'p0 provider manifest.class must be "p0"', {
|
|
899
|
+
reason: 'manifest-class',
|
|
900
|
+
actual: nullable(manifest.class),
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
const declared = Array.isArray(manifest.families) ? manifest.families : [manifest.family];
|
|
904
|
+
const families = declared.filter((family) => family !== undefined && family !== null);
|
|
905
|
+
if (families.length === 0) {
|
|
906
|
+
throw new AdapterError('CAPABILITY_UNSUPPORTED', 'p0 provider must declare at least one family', { reason: 'family-missing' });
|
|
907
|
+
}
|
|
908
|
+
for (const family of families) {
|
|
909
|
+
if (!hasOwn(P0_FAMILIES, family)) {
|
|
910
|
+
throw new AdapterError('CAPABILITY_UNSUPPORTED', `unknown p0 family "${family}"`, { reason: 'family-invalid', family });
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
for (const method of Object.values(P0_FAMILY_METHODS)) {
|
|
914
|
+
const value = provider[method];
|
|
915
|
+
if (value !== undefined && value !== null && !isObject(value)) {
|
|
916
|
+
throw new AdapterError('CAPABILITY_UNSUPPORTED', `p0 provider method "${method}" must be an object when present`, {
|
|
917
|
+
reason: 'method-invalid',
|
|
918
|
+
method,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return provider;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Per-family capability discovery.
|
|
927
|
+
*
|
|
928
|
+
* `support` answers engineering support (missing method -> unsupported);
|
|
929
|
+
* `availability` answers the current session (no session or unbound target ->
|
|
930
|
+
* not-probed). The two are never merged into one boolean.
|
|
931
|
+
*
|
|
932
|
+
* @returns {Record<string, {family:string, capabilities:string[], support:string, availability:string, reason:string|null}>}
|
|
933
|
+
*/
|
|
934
|
+
export function providerCapabilities(provider, { session = null, snapshot = null } = {}) {
|
|
935
|
+
assertProvider(provider);
|
|
936
|
+
const manifest = provider.manifest;
|
|
937
|
+
const declared = new Set(Array.isArray(manifest.families) ? manifest.families : [manifest.family]);
|
|
938
|
+
let reported = {};
|
|
939
|
+
// `familyStatus` is the P0 provider's family-level status method. It is
|
|
940
|
+
// deliberately not called `capabilities` so it cannot be confused with the
|
|
941
|
+
// six-axis `capability` readiness axis.
|
|
942
|
+
if (typeof provider.familyStatus === 'function') {
|
|
943
|
+
const value = provider.familyStatus({ session, snapshot });
|
|
944
|
+
if (isObject(value)) reported = value;
|
|
945
|
+
}
|
|
946
|
+
const hasSession = Boolean(session && isText(session.sessionId));
|
|
947
|
+
const hasTarget = Boolean(hasSession && isObject(session.target));
|
|
948
|
+
const out = {};
|
|
949
|
+
for (const family of Object.keys(P0_FAMILIES)) {
|
|
950
|
+
const method = P0_FAMILY_METHODS[family];
|
|
951
|
+
const capabilities = [...P0_CAPABILITIES[family]];
|
|
952
|
+
const base = { family, capabilities };
|
|
953
|
+
if (!declared.has(family)) {
|
|
954
|
+
out[method] = { ...base, support: P0_SUPPORT.UNSUPPORTED, availability: P0_AVAILABILITY.UNAVAILABLE, reason: 'family-not-declared' };
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const handler = provider[method];
|
|
958
|
+
if (handler === undefined || handler === null) {
|
|
959
|
+
out[method] = { ...base, support: P0_SUPPORT.UNSUPPORTED, availability: P0_AVAILABILITY.UNAVAILABLE, reason: 'method-missing' };
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (!isObject(handler)) {
|
|
963
|
+
out[method] = { ...base, support: P0_SUPPORT.UNSUPPORTED, availability: P0_AVAILABILITY.UNAVAILABLE, reason: 'method-invalid' };
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const entry = isObject(reported[method]) ? reported[method] : (isObject(reported[family]) ? reported[family] : null);
|
|
967
|
+
const support = entry && entry.support === P0_SUPPORT.PLANNED ? P0_SUPPORT.PLANNED : P0_SUPPORT.SUPPORTED;
|
|
968
|
+
if (!hasSession) {
|
|
969
|
+
out[method] = { ...base, support, availability: P0_AVAILABILITY.NOT_PROBED, reason: 'session-unbound' };
|
|
970
|
+
} else if (!hasTarget) {
|
|
971
|
+
out[method] = { ...base, support, availability: P0_AVAILABILITY.NOT_PROBED, reason: 'target-unbound' };
|
|
972
|
+
} else if (entry && typeof entry.availability === 'string') {
|
|
973
|
+
out[method] = { ...base, support, availability: entry.availability, reason: nullable(entry.reason) };
|
|
974
|
+
} else {
|
|
975
|
+
out[method] = { ...base, support, availability: P0_AVAILABILITY.AVAILABLE, reason: null };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return out;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const DISPOSED_PROVIDERS = new WeakSet();
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Dispose a P0 provider once. Safe to call repeatedly; a provider without a
|
|
985
|
+
* `dispose` method is reported rather than treated as an error.
|
|
986
|
+
*/
|
|
987
|
+
export async function disposeProvider(provider) {
|
|
988
|
+
if (!isObject(provider) || typeof provider.dispose !== 'function') {
|
|
989
|
+
return { disposed: false, reason: 'no-dispose' };
|
|
990
|
+
}
|
|
991
|
+
if (DISPOSED_PROVIDERS.has(provider)) return { disposed: true, alreadyDisposed: true };
|
|
992
|
+
await provider.dispose();
|
|
993
|
+
DISPOSED_PROVIDERS.add(provider);
|
|
994
|
+
return { disposed: true, alreadyDisposed: false };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ---------------------------------------------------------------------------
|
|
998
|
+
// Pure algorithms
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Place a frame's image pixels into a display space.
|
|
1003
|
+
*
|
|
1004
|
+
* `display.space` is the space the display is expressed in and
|
|
1005
|
+
* `display.transform` maps `display.space -> image-px`; the frame's `mapping`
|
|
1006
|
+
* must agree with it. An incompatible mapping (space, generation/geometry
|
|
1007
|
+
* version, target) is rejected instead of silently compared.
|
|
1008
|
+
*
|
|
1009
|
+
* @param {object} frame neutral frame (validated)
|
|
1010
|
+
* @param {object} display neutral DisplaySpace (validated)
|
|
1011
|
+
*/
|
|
1012
|
+
export function mapFrameToDisplay(frame, display) {
|
|
1013
|
+
const frameCheck = validateFrame(frame);
|
|
1014
|
+
if (!frameCheck.ok) {
|
|
1015
|
+
throw new AdapterError('INVALID_ARGUMENT', 'frame is not a valid neutral frame', {
|
|
1016
|
+
missing: frameCheck.missing,
|
|
1017
|
+
errors: frameCheck.errors,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
const displayCheck = validateDisplaySpace(display);
|
|
1021
|
+
if (!displayCheck.ok) {
|
|
1022
|
+
throw new AdapterError('INVALID_ARGUMENT', 'display is not a valid neutral display space', {
|
|
1023
|
+
missing: displayCheck.missing,
|
|
1024
|
+
errors: displayCheck.errors,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
if (frame.mapping.from !== display.space) {
|
|
1028
|
+
throw new AdapterError('GENERATION_MISMATCH', 'frame mapping origin does not match the display space', {
|
|
1029
|
+
reason: 'space-mismatch',
|
|
1030
|
+
frameSpace: frame.mapping.from,
|
|
1031
|
+
displaySpace: display.space,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
assertCompatibleMapping(frame.mapping, display.transform);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
throw new AdapterError('GENERATION_MISMATCH', 'frame mapping is not compatible with the display transform', {
|
|
1038
|
+
reason: 'mapping-incompatible',
|
|
1039
|
+
frameMapping: frame.mapping,
|
|
1040
|
+
displayTransform: display.transform,
|
|
1041
|
+
cause: error,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
if (hasOwn(frame.mapping, 'geometryVersion') && frame.mapping.geometryVersion !== null
|
|
1045
|
+
&& frame.mapping.geometryVersion !== display.geometryVersion) {
|
|
1046
|
+
throw new AdapterError('GENERATION_MISMATCH', 'frame mapping geometryVersion does not match the display', {
|
|
1047
|
+
reason: 'geometry-version-mismatch',
|
|
1048
|
+
frameGeometryVersion: frame.mapping.geometryVersion,
|
|
1049
|
+
displayGeometryVersion: display.geometryVersion,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
const toDisplay = invertTransform(display.transform);
|
|
1053
|
+
const bounds = mapRect(toDisplay, { x: 0, y: 0, width: frame.rgba.width, height: frame.rgba.height });
|
|
1054
|
+
return {
|
|
1055
|
+
frameId: frame.frameId,
|
|
1056
|
+
targetId: frame.targetId,
|
|
1057
|
+
generation: frame.generation,
|
|
1058
|
+
space: display.space,
|
|
1059
|
+
geometryVersion: display.geometryVersion,
|
|
1060
|
+
transform: toDisplay,
|
|
1061
|
+
image: { width: frame.rgba.width, height: frame.rgba.height },
|
|
1062
|
+
bounds,
|
|
1063
|
+
evidence: {
|
|
1064
|
+
...frame.evidence,
|
|
1065
|
+
operation: 'p0.map.frame-to-display',
|
|
1066
|
+
source: 'core/p0.mapFrameToDisplay',
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Count dropped / evicted / incomplete frames across a frame list.
|
|
1073
|
+
* @param {object[]} frames
|
|
1074
|
+
*/
|
|
1075
|
+
export function frameIntegritySummary(frames = []) {
|
|
1076
|
+
const list = Array.isArray(frames) ? frames : [];
|
|
1077
|
+
const summary = {
|
|
1078
|
+
total: list.length,
|
|
1079
|
+
complete: 0,
|
|
1080
|
+
dropped: 0,
|
|
1081
|
+
evicted: 0,
|
|
1082
|
+
incomplete: 0,
|
|
1083
|
+
state: 'complete',
|
|
1084
|
+
};
|
|
1085
|
+
for (const frame of list) {
|
|
1086
|
+
const integrity = isObject(frame) && isObject(frame.integrity) ? frame.integrity : null;
|
|
1087
|
+
if (!integrity) {
|
|
1088
|
+
summary.incomplete += 1;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const dropped = isInteger(integrity.dropped) ? integrity.dropped : 0;
|
|
1092
|
+
const evicted = isInteger(integrity.evicted) ? integrity.evicted : 0;
|
|
1093
|
+
const incomplete = isInteger(integrity.incomplete) ? integrity.incomplete : 0;
|
|
1094
|
+
summary.dropped += dropped;
|
|
1095
|
+
summary.evicted += evicted;
|
|
1096
|
+
summary.incomplete += incomplete;
|
|
1097
|
+
if (integrity.state === 'complete' && dropped === 0 && evicted === 0 && incomplete === 0) summary.complete += 1;
|
|
1098
|
+
}
|
|
1099
|
+
summary.state = summary.dropped === 0 && summary.evicted === 0 && summary.incomplete === 0 ? 'complete' : 'incomplete';
|
|
1100
|
+
return summary;
|
|
1101
|
+
}
|