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
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only fake environment: TargetAdapter + CapabilityAdapter + P0Provider.
|
|
3
|
+
*
|
|
4
|
+
* This is a contract fixture, not a real platform. It never starts a browser,
|
|
5
|
+
* never opens a socket and never writes a repository file. Everything is
|
|
6
|
+
* deterministic for a given `seed` and injected `now()`.
|
|
7
|
+
*
|
|
8
|
+
* Ownership boundaries (M0 §2, frozen after review):
|
|
9
|
+
* - TargetAdapter owns target discovery/bind/close, generation and sessions.
|
|
10
|
+
* - P0Provider's `targets` family exposes read-only target listing plus lease
|
|
11
|
+
* primitives (`list` / `leaseAcquire` / `leaseRelease` / `leaseStatus`); it
|
|
12
|
+
* never binds or closes a target itself.
|
|
13
|
+
* - `familyStatus` (provider) and `operationStatus` (capability adapter) are
|
|
14
|
+
* named so they cannot be confused with the six-axis `capability` readiness
|
|
15
|
+
* axis.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { AdapterError } from '../../core/errors.js';
|
|
19
|
+
import { createTransform } from '../../core/geometry.js';
|
|
20
|
+
import {
|
|
21
|
+
createAnnotationDoc,
|
|
22
|
+
createDisplaySpace,
|
|
23
|
+
createFrame,
|
|
24
|
+
createLease,
|
|
25
|
+
createNetworkEvent,
|
|
26
|
+
createRequestKey,
|
|
27
|
+
createTargetIdentity,
|
|
28
|
+
frameIntegritySummary,
|
|
29
|
+
validateAnnotationDoc,
|
|
30
|
+
} from '../../core/p0.js';
|
|
31
|
+
import {
|
|
32
|
+
canonicalJson,
|
|
33
|
+
createDeterministicPng,
|
|
34
|
+
fakeId,
|
|
35
|
+
sha256Hex,
|
|
36
|
+
} from './fixtures.js';
|
|
37
|
+
|
|
38
|
+
const CONTRACT_VERSION = 1;
|
|
39
|
+
const CAPABILITY_OPERATIONS = Object.freeze(['fake.t1.echo', 'fake.t1.identity', 'fake.t3.frame-stats']);
|
|
40
|
+
const TARGET_CAPABILITIES = Object.freeze([
|
|
41
|
+
'fake.t1', 'fake.t2', 'fake.t3', 'fake.network',
|
|
42
|
+
'fake.p0.capture', 'fake.p0.display', 'fake.p0.canvas', 'fake.p0.targets',
|
|
43
|
+
]);
|
|
44
|
+
const CAPABILITY_IDS = Object.freeze(['fake.t1', 'fake.t3']);
|
|
45
|
+
|
|
46
|
+
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
47
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
|
+
const isText = (value) => typeof value === 'string' && value.length > 0;
|
|
49
|
+
const isInteger = (value) => Number.isInteger(value);
|
|
50
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
51
|
+
|
|
52
|
+
/** Explicit snapshot revision, or null when the snapshot does not carry one. */
|
|
53
|
+
function explicitSnapshotRevision(snapshot) {
|
|
54
|
+
if (!isObject(snapshot)) return null;
|
|
55
|
+
if (isInteger(snapshot.revision)) return snapshot.revision;
|
|
56
|
+
if (isInteger(snapshot.configRevision)) return snapshot.configRevision;
|
|
57
|
+
const env = isObject(snapshot.environments) ? snapshot.environments.fake : null;
|
|
58
|
+
if (isObject(env) && isInteger(env.revision)) return env.revision;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function snapshotRevision(snapshot) {
|
|
63
|
+
return explicitSnapshotRevision(snapshot) ?? 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshotEnabled(snapshot) {
|
|
67
|
+
if (!isObject(snapshot)) return false;
|
|
68
|
+
const env = (isObject(snapshot.environments) ? snapshot.environments.fake : null)
|
|
69
|
+
?? (isObject(snapshot.config?.global?.environments) ? snapshot.config.global.environments.fake : null);
|
|
70
|
+
if (isObject(env) && typeof env.enabled === 'boolean') return env.enabled;
|
|
71
|
+
if (typeof snapshot.enabled === 'boolean') return snapshot.enabled;
|
|
72
|
+
if (Array.isArray(snapshot.enabledEnvironments)) return snapshot.enabledEnvironments.includes('fake');
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Honor caller cancellation. An AbortSignal reason that is an Error is
|
|
78
|
+
* re-thrown as-is; otherwise the operation reports an aborted state.
|
|
79
|
+
*/
|
|
80
|
+
function assertNotAborted(signal, operation) {
|
|
81
|
+
if (!signal || signal.aborted !== true) return;
|
|
82
|
+
const reason = signal.reason;
|
|
83
|
+
if (reason instanceof Error) throw reason;
|
|
84
|
+
throw new AdapterError('TARGET_GONE', 'operation aborted by caller', { reason: 'aborted', operation });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A snapshot/args revision that differs from the session's `configRevision`
|
|
89
|
+
* means the caller is acting on a stale configuration (M0 §4.1: one revision
|
|
90
|
+
* per step).
|
|
91
|
+
*/
|
|
92
|
+
function assertRevision(session, snapshot, args, operation) {
|
|
93
|
+
const expected = session.configRevision;
|
|
94
|
+
const snapshotValue = explicitSnapshotRevision(snapshot);
|
|
95
|
+
if (snapshotValue !== null && snapshotValue !== expected) {
|
|
96
|
+
throw new AdapterError('CONFIG_STALE', 'snapshot revision differs from the session revision', {
|
|
97
|
+
operation,
|
|
98
|
+
expected,
|
|
99
|
+
actual: snapshotValue,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (isObject(args) && hasOwn(args, 'revision') && args.revision !== expected) {
|
|
103
|
+
throw new AdapterError('CONFIG_STALE', 'operation revision differs from the session revision', {
|
|
104
|
+
operation,
|
|
105
|
+
expected,
|
|
106
|
+
actual: args.revision,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildEvidence(state, {
|
|
112
|
+
operation,
|
|
113
|
+
session = null,
|
|
114
|
+
snapshot = null,
|
|
115
|
+
source = 'targets/fake',
|
|
116
|
+
target = undefined,
|
|
117
|
+
} = {}) {
|
|
118
|
+
const targetIdentity = target !== undefined ? target : (session?.target ?? null);
|
|
119
|
+
return {
|
|
120
|
+
version: 1,
|
|
121
|
+
scope: session?.scope ?? snapshot?.scope ?? 'scope-fake',
|
|
122
|
+
session: session?.sessionId ?? null,
|
|
123
|
+
target: targetIdentity?.targetId ?? null,
|
|
124
|
+
generation: session?.generation ?? null,
|
|
125
|
+
configRevision: session?.configRevision ?? snapshotRevision(snapshot),
|
|
126
|
+
operation,
|
|
127
|
+
source,
|
|
128
|
+
capturedAt: state.now(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function assertSessionLive(record) {
|
|
133
|
+
if (record.disposed || record.closed) {
|
|
134
|
+
throw new AdapterError('TARGET_GONE', 'fake session is closed', {
|
|
135
|
+
reason: 'session-closed',
|
|
136
|
+
sessionId: record.sessionId,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveRecord(state, session, operation) {
|
|
142
|
+
if (!session || !isText(session.sessionId)) {
|
|
143
|
+
throw new AdapterError('TARGET_NOT_BOUND', 'fake provider requires a target session', {
|
|
144
|
+
reason: 'session-missing',
|
|
145
|
+
operation,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const record = state.sessions.get(session.sessionId);
|
|
149
|
+
if (!record) {
|
|
150
|
+
throw new AdapterError('TARGET_NOT_BOUND', `fake session "${session.sessionId}" is unknown`, {
|
|
151
|
+
reason: 'session-unknown',
|
|
152
|
+
sessionId: session.sessionId,
|
|
153
|
+
operation,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
assertSessionLive(record);
|
|
157
|
+
return record;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertGeneration(session, args, operation) {
|
|
161
|
+
if (isObject(args) && hasOwn(args, 'generation') && args.generation !== session.generation) {
|
|
162
|
+
throw new AdapterError('GENERATION_MISMATCH', 'fake operation generation does not match the session', {
|
|
163
|
+
operation,
|
|
164
|
+
expected: session.generation,
|
|
165
|
+
actual: args.generation,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The binding that any target-scoped record must match to be handed out:
|
|
172
|
+
* the currently bound target plus the session generation.
|
|
173
|
+
*
|
|
174
|
+
* `bind()` bumps `record.generation` on every call, so a frame, annotation
|
|
175
|
+
* document or lease recorded before a rebind is stale by construction — even
|
|
176
|
+
* when the rebind selects the same `targetId` again.
|
|
177
|
+
*/
|
|
178
|
+
function currentBinding(session) {
|
|
179
|
+
return {
|
|
180
|
+
targetId: isObject(session?.target) ? session.target.targetId : null,
|
|
181
|
+
generation: session?.generation ?? null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** True when `record` (frame / annotation doc / lease) belongs to the live binding. */
|
|
186
|
+
function matchesCurrentBinding(session, record) {
|
|
187
|
+
if (!isObject(record)) return false;
|
|
188
|
+
const current = currentBinding(session);
|
|
189
|
+
const targetId = hasOwn(record, 'targetId') ? (record.targetId ?? null) : null;
|
|
190
|
+
const generation = hasOwn(record, 'generation') ? (record.generation ?? null) : null;
|
|
191
|
+
return targetId === current.targetId && generation === current.generation;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Fail closed (A05 P0-D/E) when a stored record predates the current binding.
|
|
196
|
+
*
|
|
197
|
+
* One code is used for every superseded-reference case — a different target and
|
|
198
|
+
* an unchanged target with a bumped generation — because both are only ever
|
|
199
|
+
* reachable through a rebind: `GENERATION_MISMATCH`. Returning the record
|
|
200
|
+
* instead would pair old data with `evidence.target` of the new binding.
|
|
201
|
+
*/
|
|
202
|
+
function assertCurrentBinding(session, record, { operation, subject }) {
|
|
203
|
+
if (matchesCurrentBinding(session, record)) return;
|
|
204
|
+
const current = currentBinding(session);
|
|
205
|
+
throw new AdapterError('GENERATION_MISMATCH', `${subject} belongs to a superseded target binding`, {
|
|
206
|
+
operation,
|
|
207
|
+
subject,
|
|
208
|
+
reason: 'target-rebound',
|
|
209
|
+
expected: current.generation,
|
|
210
|
+
actual: isObject(record) && hasOwn(record, 'generation') ? (record.generation ?? null) : null,
|
|
211
|
+
expectedTargetId: current.targetId,
|
|
212
|
+
actualTargetId: isObject(record) ? (record.targetId ?? null) : null,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function requireBoundTarget(state, session, operation) {
|
|
217
|
+
const record = resolveRecord(state, session, operation);
|
|
218
|
+
if (!isObject(session.target)) {
|
|
219
|
+
throw new AdapterError('TARGET_NOT_BOUND', 'fake operation requires a bound target', {
|
|
220
|
+
reason: 'target-unbound',
|
|
221
|
+
operation,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (record.closedTargetIds.has(session.target.targetId)) {
|
|
225
|
+
throw new AdapterError('TARGET_GONE', `fake target "${session.target.targetId}" is closed`, {
|
|
226
|
+
targetId: session.target.targetId,
|
|
227
|
+
operation,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return session.target;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Session / target lifecycle (owned by TargetAdapter, shared with P0 leases)
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function createTargetIdentities(state, sessionId) {
|
|
238
|
+
return [1, 2].map((index) => createTargetIdentity({
|
|
239
|
+
targetId: fakeId(state.seed, 'target', index),
|
|
240
|
+
environment: 'fake',
|
|
241
|
+
kind: 'fake-window',
|
|
242
|
+
generation: 1,
|
|
243
|
+
label: `Fake Window ${index}`,
|
|
244
|
+
lifecycle: 'available',
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function closeSessionRecord(record) {
|
|
249
|
+
if (record.closed) return { closed: true, alreadyClosed: true, generation: record.generation };
|
|
250
|
+
record.closed = true;
|
|
251
|
+
record.generation += 1;
|
|
252
|
+
for (const identity of record.targets) {
|
|
253
|
+
record.closedTargetIds.add(identity.targetId);
|
|
254
|
+
identity.lifecycle = 'closed';
|
|
255
|
+
identity.generation = record.generation;
|
|
256
|
+
}
|
|
257
|
+
record.target = null;
|
|
258
|
+
return { closed: true, alreadyClosed: false, generation: record.generation };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Mark every lease this session owns as invalidated by the rebind (A05 P0-E).
|
|
263
|
+
*
|
|
264
|
+
* The lease records stay readable by id so `leaseStatus` / `leaseRelease` can
|
|
265
|
+
* report `GENERATION_MISMATCH` (with the real cause) instead of degrading into
|
|
266
|
+
* "unknown lease"; they can never be reported as live again.
|
|
267
|
+
*/
|
|
268
|
+
function invalidateSessionLeases(state, record) {
|
|
269
|
+
const releasedAt = state.now();
|
|
270
|
+
for (const lease of state.leases.values()) {
|
|
271
|
+
if (lease.owner?.kind !== 'session' || lease.owner?.id !== record.sessionId) continue;
|
|
272
|
+
if (state.invalidatedLeases.has(lease.leaseId)) continue;
|
|
273
|
+
state.invalidatedLeases.set(lease.leaseId, { reason: 'target-rebound', releasedAt });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function bindTargetRecord(state, record, targetId) {
|
|
278
|
+
assertSessionLive(record);
|
|
279
|
+
if (!isText(targetId)) {
|
|
280
|
+
throw new AdapterError('INVALID_ARGUMENT', 'bind requires a targetId', { reason: 'target-id-missing' });
|
|
281
|
+
}
|
|
282
|
+
const identity = record.targets.find((item) => item.targetId === targetId);
|
|
283
|
+
if (!identity) {
|
|
284
|
+
throw new AdapterError('TARGET_NOT_BOUND', `fake target "${targetId}" was not found`, { reason: 'target-not-found', targetId });
|
|
285
|
+
}
|
|
286
|
+
if (record.closedTargetIds.has(targetId)) {
|
|
287
|
+
throw new AdapterError('TARGET_GONE', `fake target "${targetId}" is closed`, { targetId });
|
|
288
|
+
}
|
|
289
|
+
record.generation += 1;
|
|
290
|
+
invalidateSessionLeases(state, record);
|
|
291
|
+
identity.generation = record.generation;
|
|
292
|
+
identity.lifecycle = 'bound';
|
|
293
|
+
record.target = identity;
|
|
294
|
+
return { ...identity };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function createSessionRecord(state, { scope = null, snapshot = null } = {}) {
|
|
298
|
+
state.sessionSeq += 1;
|
|
299
|
+
const sessionId = fakeId(state.seed, 'session', state.sessionSeq);
|
|
300
|
+
const record = {
|
|
301
|
+
sessionId,
|
|
302
|
+
environment: 'fake',
|
|
303
|
+
scope: isText(scope) ? scope : (isText(snapshot?.scope) ? snapshot.scope : 'scope-fake'),
|
|
304
|
+
generation: 1,
|
|
305
|
+
configRevision: snapshotRevision(snapshot),
|
|
306
|
+
snapshot,
|
|
307
|
+
target: null,
|
|
308
|
+
targets: createTargetIdentities(state, sessionId),
|
|
309
|
+
closedTargetIds: new Set(),
|
|
310
|
+
closed: false,
|
|
311
|
+
disposed: false,
|
|
312
|
+
};
|
|
313
|
+
state.sessions.set(sessionId, record);
|
|
314
|
+
state.lastSession = record;
|
|
315
|
+
return record;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function createTargetSession(state, record) {
|
|
319
|
+
return {
|
|
320
|
+
get sessionId() { return record.sessionId; },
|
|
321
|
+
get environment() { return record.environment; },
|
|
322
|
+
get scope() { return record.scope; },
|
|
323
|
+
get generation() { return record.generation; },
|
|
324
|
+
get configRevision() { return record.configRevision; },
|
|
325
|
+
get target() { return record.target; },
|
|
326
|
+
get snapshot() { return record.snapshot; },
|
|
327
|
+
async listTargets(options = {}) {
|
|
328
|
+
assertNotAborted(options.signal, 'targets.list');
|
|
329
|
+
assertSessionLive(record);
|
|
330
|
+
return record.targets.map((identity) => ({ ...identity }));
|
|
331
|
+
},
|
|
332
|
+
async bind(targetId, options = {}) {
|
|
333
|
+
assertNotAborted(options.signal, 'targets.bind');
|
|
334
|
+
return bindTargetRecord(state, record, targetId);
|
|
335
|
+
},
|
|
336
|
+
async close(options = {}) {
|
|
337
|
+
assertNotAborted(options.signal, 'targets.close');
|
|
338
|
+
return closeSessionRecord(record);
|
|
339
|
+
},
|
|
340
|
+
async dispose(options = {}) {
|
|
341
|
+
assertNotAborted(options.signal, 'targets.dispose');
|
|
342
|
+
if (record.disposed) return { disposed: true, alreadyDisposed: true };
|
|
343
|
+
record.disposed = true;
|
|
344
|
+
closeSessionRecord(record);
|
|
345
|
+
return { disposed: true, alreadyDisposed: false };
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Fake TargetAdapter
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
function createFakeTargetAdapter(state) {
|
|
355
|
+
let disposed = false;
|
|
356
|
+
return {
|
|
357
|
+
manifest: {
|
|
358
|
+
contractVersion: CONTRACT_VERSION,
|
|
359
|
+
id: 'fake',
|
|
360
|
+
class: 'target',
|
|
361
|
+
environment: 'fake',
|
|
362
|
+
displayName: 'Fake (test-only)',
|
|
363
|
+
capabilities: [...TARGET_CAPABILITIES],
|
|
364
|
+
dependencies: [],
|
|
365
|
+
visibility: 'test-only',
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Six independent readiness axes (never merged into one boolean).
|
|
370
|
+
* No target is started and nothing is scanned.
|
|
371
|
+
*/
|
|
372
|
+
async probe({ scope = null, snapshot = null, signal = null } = {}) {
|
|
373
|
+
assertNotAborted(signal, 'targets.probe');
|
|
374
|
+
const checkedAt = state.now();
|
|
375
|
+
const configRevision = snapshotRevision(snapshot);
|
|
376
|
+
const enabled = snapshotEnabled(snapshot);
|
|
377
|
+
const record = state.lastSession && !state.lastSession.closed && !state.lastSession.disposed
|
|
378
|
+
? state.lastSession
|
|
379
|
+
: null;
|
|
380
|
+
const bound = Boolean(record && record.target);
|
|
381
|
+
return {
|
|
382
|
+
contractVersion: CONTRACT_VERSION,
|
|
383
|
+
adapterId: 'fake',
|
|
384
|
+
environment: 'fake',
|
|
385
|
+
scope: isText(scope) ? scope : null,
|
|
386
|
+
checkedAt,
|
|
387
|
+
configRevision,
|
|
388
|
+
target: bound ? { targetId: record.target.targetId, generation: record.target.generation } : null,
|
|
389
|
+
axes: {
|
|
390
|
+
support: { state: 'supported', reason: null },
|
|
391
|
+
enabled: { state: enabled, reason: enabled ? null : 'config-disabled' },
|
|
392
|
+
dependency: { state: 'ok', reason: null },
|
|
393
|
+
connection: { state: 'not-probed', reason: 'in-process-fake' },
|
|
394
|
+
target: {
|
|
395
|
+
state: bound ? 'bound' : 'unbound',
|
|
396
|
+
targetId: bound ? record.target.targetId : null,
|
|
397
|
+
generation: bound ? record.target.generation : null,
|
|
398
|
+
reason: bound ? null : 'no-bound-target',
|
|
399
|
+
},
|
|
400
|
+
capability: {
|
|
401
|
+
state: bound ? 'available' : 'not-probed',
|
|
402
|
+
reason: bound ? null : 'target-unbound',
|
|
403
|
+
items: {
|
|
404
|
+
'fake.t1': bound ? 'available' : 'not-probed',
|
|
405
|
+
'fake.t2': bound ? 'available' : 'not-probed',
|
|
406
|
+
'fake.t3': bound ? 'available' : 'not-probed',
|
|
407
|
+
'fake.network': bound ? 'available' : 'not-probed',
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async createSession({ scope = null, snapshot = null, signal = null } = {}) {
|
|
415
|
+
assertNotAborted(signal, 'targets.createSession');
|
|
416
|
+
if (disposed) {
|
|
417
|
+
throw new AdapterError('TARGET_GONE', 'fake target adapter is disposed', { reason: 'adapter-disposed' });
|
|
418
|
+
}
|
|
419
|
+
return createTargetSession(state, createSessionRecord(state, { scope, snapshot }));
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async dispose() {
|
|
423
|
+
if (disposed) return { disposed: true, alreadyDisposed: true };
|
|
424
|
+
disposed = true;
|
|
425
|
+
for (const record of state.sessions.values()) {
|
|
426
|
+
if (!record.closed) closeSessionRecord(record);
|
|
427
|
+
record.disposed = true;
|
|
428
|
+
}
|
|
429
|
+
return { disposed: true, alreadyDisposed: false };
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Fake CapabilityAdapter
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
function frameStats(state, session) {
|
|
439
|
+
const frames = state.frames.filter((frame) => (
|
|
440
|
+
frame.sessionId === session.sessionId && matchesCurrentBinding(session, frame)
|
|
441
|
+
));
|
|
442
|
+
const latest = frames.length > 0 ? frames[frames.length - 1] : null;
|
|
443
|
+
const combined = sha256Hex(Buffer.from(frames.map((frame) => frame.encoded.sha256).join(':'), 'utf8'));
|
|
444
|
+
return {
|
|
445
|
+
count: frames.length,
|
|
446
|
+
width: latest ? latest.rgba.width : 0,
|
|
447
|
+
height: latest ? latest.rgba.height : 0,
|
|
448
|
+
sha256: combined,
|
|
449
|
+
frames: frames.map((frame) => ({
|
|
450
|
+
frameId: frame.frameId,
|
|
451
|
+
width: frame.rgba.width,
|
|
452
|
+
height: frame.rgba.height,
|
|
453
|
+
sha256: frame.encoded.sha256,
|
|
454
|
+
})),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function createFakeCapabilityAdapter(state) {
|
|
459
|
+
let disposed = false;
|
|
460
|
+
return {
|
|
461
|
+
manifest: {
|
|
462
|
+
contractVersion: CONTRACT_VERSION,
|
|
463
|
+
id: 'fake.capability',
|
|
464
|
+
class: 'capability',
|
|
465
|
+
environment: 'fake',
|
|
466
|
+
displayName: 'Fake Capability (test-only)',
|
|
467
|
+
capabilities: [...CAPABILITY_IDS],
|
|
468
|
+
operations: [...CAPABILITY_OPERATIONS],
|
|
469
|
+
dependencies: [],
|
|
470
|
+
visibility: 'test-only',
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
operationStatus({ session = null, snapshot = null, signal = null } = {}) {
|
|
474
|
+
assertNotAborted(signal, 'capability.operationStatus');
|
|
475
|
+
const live = Boolean(session && isText(session.sessionId) && state.sessions.has(session.sessionId));
|
|
476
|
+
const bound = Boolean(live && session.target);
|
|
477
|
+
const availability = bound ? 'available' : 'not-probed';
|
|
478
|
+
const reason = bound ? null : (live ? 'target-unbound' : 'session-unbound');
|
|
479
|
+
const out = {};
|
|
480
|
+
for (const operation of CAPABILITY_OPERATIONS) {
|
|
481
|
+
out[operation] = { support: 'supported', availability, reason };
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
async invoke({ operation, args = {}, session = null, snapshot = null, signal = null } = {}) {
|
|
487
|
+
assertNotAborted(signal, operation ?? 'capability.invoke');
|
|
488
|
+
if (!CAPABILITY_OPERATIONS.includes(operation)) {
|
|
489
|
+
throw new AdapterError('CAPABILITY_UNSUPPORTED', `fake capability "${operation}" is not supported`, {
|
|
490
|
+
operation: operation ?? null,
|
|
491
|
+
operations: [...CAPABILITY_OPERATIONS],
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (disposed) {
|
|
495
|
+
throw new AdapterError('TARGET_GONE', 'fake capability adapter is disposed', { reason: 'adapter-disposed' });
|
|
496
|
+
}
|
|
497
|
+
const record = resolveRecord(state, session, operation);
|
|
498
|
+
assertRevision(session, snapshot, args, operation);
|
|
499
|
+
assertGeneration(session, args, operation);
|
|
500
|
+
if (!isObject(session.target)) {
|
|
501
|
+
throw new AdapterError('TARGET_NOT_BOUND', 'fake capability requires a bound target', {
|
|
502
|
+
reason: 'target-unbound',
|
|
503
|
+
operation,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
if (record.closedTargetIds.has(session.target.targetId)) {
|
|
507
|
+
throw new AdapterError('TARGET_GONE', `fake target "${session.target.targetId}" is closed`, {
|
|
508
|
+
targetId: session.target.targetId,
|
|
509
|
+
operation,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const evidence = buildEvidence(state, { operation, session, snapshot });
|
|
514
|
+
if (operation === 'fake.t1.echo') {
|
|
515
|
+
const json = canonicalJson(isObject(args) ? args : {});
|
|
516
|
+
return {
|
|
517
|
+
value: {
|
|
518
|
+
operation,
|
|
519
|
+
args: isObject(args) ? JSON.parse(JSON.stringify(args)) : {},
|
|
520
|
+
json,
|
|
521
|
+
sha256: sha256Hex(Buffer.from(json, 'utf8')),
|
|
522
|
+
},
|
|
523
|
+
evidence,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (operation === 'fake.t1.identity') {
|
|
527
|
+
return {
|
|
528
|
+
value: {
|
|
529
|
+
environment: 'fake',
|
|
530
|
+
sessionId: session.sessionId,
|
|
531
|
+
sessionGeneration: session.generation,
|
|
532
|
+
targetId: session.target.targetId,
|
|
533
|
+
targetGeneration: session.target.generation,
|
|
534
|
+
configRevision: session.configRevision,
|
|
535
|
+
},
|
|
536
|
+
evidence,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return { value: frameStats(state, session), evidence };
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
async dispose() {
|
|
543
|
+
if (disposed) return { disposed: true, alreadyDisposed: true };
|
|
544
|
+
disposed = true;
|
|
545
|
+
return { disposed: true, alreadyDisposed: false };
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// Fake P0Provider (A–E)
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
function buildFakeNetworkEvents(state, session) {
|
|
555
|
+
const receivedAt = state.now();
|
|
556
|
+
const shared = {
|
|
557
|
+
sessionId: session.sessionId,
|
|
558
|
+
targetId: session.target?.targetId ?? null,
|
|
559
|
+
generation: session.generation,
|
|
560
|
+
sourceTimestamp: null,
|
|
561
|
+
receivedAt,
|
|
562
|
+
transportRef: { kind: 'fake', handle: fakeId(state.seed, 'transport', 'network') },
|
|
563
|
+
};
|
|
564
|
+
return [
|
|
565
|
+
createNetworkEvent({
|
|
566
|
+
...shared,
|
|
567
|
+
eventId: fakeId(state.seed, 'event', 1),
|
|
568
|
+
requestKey: createRequestKey({ requestId: fakeId(state.seed, 'request', 1), hopId: 0 }),
|
|
569
|
+
phase: 'response',
|
|
570
|
+
kind: 'http',
|
|
571
|
+
method: 'GET',
|
|
572
|
+
url: 'https://fake.test/api/items',
|
|
573
|
+
status: 200,
|
|
574
|
+
body: {
|
|
575
|
+
state: 'captured',
|
|
576
|
+
contentType: 'application/json',
|
|
577
|
+
data: '{"ok":true}',
|
|
578
|
+
base64Encoded: false,
|
|
579
|
+
bytes: 11,
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
582
|
+
createNetworkEvent({
|
|
583
|
+
...shared,
|
|
584
|
+
eventId: fakeId(state.seed, 'event', 2),
|
|
585
|
+
requestKey: createRequestKey({ requestId: fakeId(state.seed, 'request', 2), hopId: 0 }),
|
|
586
|
+
phase: 'response',
|
|
587
|
+
kind: 'http',
|
|
588
|
+
method: 'POST',
|
|
589
|
+
url: 'https://fake.test/api/upload',
|
|
590
|
+
status: 204,
|
|
591
|
+
body: {
|
|
592
|
+
state: 'unavailable',
|
|
593
|
+
reasonCode: 'body-not-captured',
|
|
594
|
+
reasonDetail: 'fake provider does not capture this body',
|
|
595
|
+
},
|
|
596
|
+
}),
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function cloneDoc(doc) {
|
|
601
|
+
if (!doc) return null;
|
|
602
|
+
return { ...doc, shapes: doc.shapes.map((shape) => ({ ...shape })) };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Resolve a lease for a status/release call, scoped to the live binding.
|
|
607
|
+
*
|
|
608
|
+
* Both the targetId and the generation must match the session's current
|
|
609
|
+
* binding. `bind()` invalidates the session's leases (reason `target-rebound`)
|
|
610
|
+
* and bumps the generation, so a lease acquired before any rebind — to the same
|
|
611
|
+
* target or to another one — is refused here instead of being reported as a
|
|
612
|
+
* live lease on the new target.
|
|
613
|
+
*/
|
|
614
|
+
function resolveScopedLease(state, session, args, { operation, label }) {
|
|
615
|
+
if (!isText(args.leaseId)) {
|
|
616
|
+
throw new AdapterError('INVALID_ARGUMENT', `${label} requires args.leaseId`, { reason: 'lease-id-missing' });
|
|
617
|
+
}
|
|
618
|
+
const lease = state.leases.get(args.leaseId);
|
|
619
|
+
if (!lease) {
|
|
620
|
+
throw new AdapterError('INVALID_ARGUMENT', `unknown lease "${args.leaseId}"`, {
|
|
621
|
+
reason: 'lease-unknown',
|
|
622
|
+
leaseId: args.leaseId,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
const current = currentBinding(session);
|
|
626
|
+
const invalidation = state.invalidatedLeases.get(lease.leaseId) ?? null;
|
|
627
|
+
if (lease.targetId !== current.targetId || lease.generation !== current.generation) {
|
|
628
|
+
throw new AdapterError('GENERATION_MISMATCH', 'lease belongs to a superseded target binding', {
|
|
629
|
+
operation,
|
|
630
|
+
leaseId: lease.leaseId,
|
|
631
|
+
reason: invalidation?.reason ?? 'binding-mismatch',
|
|
632
|
+
expected: current.generation,
|
|
633
|
+
actual: lease.generation ?? null,
|
|
634
|
+
expectedTargetId: current.targetId,
|
|
635
|
+
actualTargetId: lease.targetId ?? null,
|
|
636
|
+
invalidatedAt: invalidation?.releasedAt ?? null,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return lease;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function createFakeP0Provider(state) {
|
|
643
|
+
let disposed = false;
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
manifest: {
|
|
647
|
+
contractVersion: CONTRACT_VERSION,
|
|
648
|
+
id: 'fake.p0',
|
|
649
|
+
class: 'p0',
|
|
650
|
+
environment: 'fake',
|
|
651
|
+
displayName: 'Fake P0 (test-only)',
|
|
652
|
+
family: 'A',
|
|
653
|
+
families: ['A', 'B', 'C', 'D', 'E'],
|
|
654
|
+
capabilities: [
|
|
655
|
+
'fake.p0.capture', 'fake.p0.network', 'fake.p0.display', 'fake.p0.canvas', 'fake.p0.targets',
|
|
656
|
+
],
|
|
657
|
+
dependencies: [],
|
|
658
|
+
visibility: 'test-only',
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
familyStatus({ session = null, snapshot = null, signal = null } = {}) {
|
|
662
|
+
assertNotAborted(signal, 'p0.familyStatus');
|
|
663
|
+
const live = Boolean(session && isText(session.sessionId) && state.sessions.has(session.sessionId));
|
|
664
|
+
const bound = Boolean(live && session.target);
|
|
665
|
+
const availability = bound ? 'available' : 'not-probed';
|
|
666
|
+
const reason = bound ? null : (live ? 'target-unbound' : 'session-unbound');
|
|
667
|
+
const make = () => ({ support: 'supported', availability, reason });
|
|
668
|
+
return {
|
|
669
|
+
capture: make(),
|
|
670
|
+
network: make(),
|
|
671
|
+
display: make(),
|
|
672
|
+
canvas: make(),
|
|
673
|
+
targets: make(),
|
|
674
|
+
};
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
capture: {
|
|
678
|
+
/** 2x2 deterministic PNG, encoded and hashed; source clock from injected now(). */
|
|
679
|
+
async screenshot({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
680
|
+
assertNotAborted(signal, 'p0.capture.screenshot');
|
|
681
|
+
resolveRecord(state, session, 'p0.capture.screenshot');
|
|
682
|
+
assertRevision(session, snapshot, args, 'p0.capture.screenshot');
|
|
683
|
+
assertGeneration(session, args, 'p0.capture.screenshot');
|
|
684
|
+
const png = createDeterministicPng({ width: 2, height: 2 });
|
|
685
|
+
const receivedAt = state.now();
|
|
686
|
+
const sourceTimestamp = hasOwn(args, 'sourceTimestamp') ? args.sourceTimestamp : receivedAt;
|
|
687
|
+
state.frameSeq += 1;
|
|
688
|
+
const frame = createFrame({
|
|
689
|
+
frameId: fakeId(state.seed, 'frame', state.frameSeq),
|
|
690
|
+
sessionId: session.sessionId,
|
|
691
|
+
targetId: session.target?.targetId ?? null,
|
|
692
|
+
generation: session.generation,
|
|
693
|
+
sourceTimestamp,
|
|
694
|
+
receivedAt,
|
|
695
|
+
encoded: {
|
|
696
|
+
format: 'png',
|
|
697
|
+
bytes: png.bytes,
|
|
698
|
+
sha256: png.sha256,
|
|
699
|
+
byteLength: png.bytes.length,
|
|
700
|
+
},
|
|
701
|
+
rgba: {
|
|
702
|
+
width: png.width,
|
|
703
|
+
height: png.height,
|
|
704
|
+
sha256: sha256Hex(png.rgba),
|
|
705
|
+
},
|
|
706
|
+
mapping: {
|
|
707
|
+
from: 'document-css-px',
|
|
708
|
+
to: 'image-px',
|
|
709
|
+
scaleX: 1,
|
|
710
|
+
scaleY: 1,
|
|
711
|
+
offsetX: 0,
|
|
712
|
+
offsetY: 0,
|
|
713
|
+
geometryVersion: state.displayVersion,
|
|
714
|
+
},
|
|
715
|
+
integrity: { state: 'complete', dropped: 0, evicted: 0, incomplete: 0, reason: null },
|
|
716
|
+
evidence: buildEvidence(state, {
|
|
717
|
+
operation: 'p0.capture.screenshot',
|
|
718
|
+
session,
|
|
719
|
+
snapshot,
|
|
720
|
+
source: 'targets/fake/capture',
|
|
721
|
+
}),
|
|
722
|
+
});
|
|
723
|
+
state.frames.push(frame);
|
|
724
|
+
return { frame, evidence: frame.evidence };
|
|
725
|
+
},
|
|
726
|
+
|
|
727
|
+
async frames({ session = null, snapshot = null, signal = null } = {}) {
|
|
728
|
+
assertNotAborted(signal, 'p0.capture.frames');
|
|
729
|
+
resolveRecord(state, session, 'p0.capture.frames');
|
|
730
|
+
assertRevision(session, snapshot, null, 'p0.capture.frames');
|
|
731
|
+
// Frames are retained per session, but only the ones recorded for the
|
|
732
|
+
// live target *and* the live generation may be handed out: a rebind must
|
|
733
|
+
// never surface the previous target's pixels under the new evidence.
|
|
734
|
+
const frames = state.frames.filter((frame) => (
|
|
735
|
+
frame.sessionId === session.sessionId && matchesCurrentBinding(session, frame)
|
|
736
|
+
));
|
|
737
|
+
return {
|
|
738
|
+
frames,
|
|
739
|
+
count: frames.length,
|
|
740
|
+
integrity: frameIntegritySummary(frames),
|
|
741
|
+
evidence: buildEvidence(state, {
|
|
742
|
+
operation: 'p0.capture.frames',
|
|
743
|
+
session,
|
|
744
|
+
snapshot,
|
|
745
|
+
source: 'targets/fake/capture',
|
|
746
|
+
}),
|
|
747
|
+
};
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
async image({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
751
|
+
assertNotAborted(signal, 'p0.capture.image');
|
|
752
|
+
resolveRecord(state, session, 'p0.capture.image');
|
|
753
|
+
assertRevision(session, snapshot, args, 'p0.capture.image');
|
|
754
|
+
const frames = state.frames.filter((frame) => frame.sessionId === session.sessionId);
|
|
755
|
+
let frame = null;
|
|
756
|
+
if (isText(args.frameId)) {
|
|
757
|
+
const stored = frames.find((item) => item.frameId === args.frameId) ?? null;
|
|
758
|
+
if (stored) {
|
|
759
|
+
// A frame recorded before the current binding is refused instead of
|
|
760
|
+
// being served with the new target's evidence envelope.
|
|
761
|
+
assertCurrentBinding(session, stored, {
|
|
762
|
+
operation: 'p0.capture.image',
|
|
763
|
+
subject: `frame "${stored.frameId}"`,
|
|
764
|
+
});
|
|
765
|
+
frame = stored;
|
|
766
|
+
}
|
|
767
|
+
} else {
|
|
768
|
+
const live = frames.filter((item) => matchesCurrentBinding(session, item));
|
|
769
|
+
frame = live.length > 0 ? live[live.length - 1] : null;
|
|
770
|
+
}
|
|
771
|
+
if (!frame) {
|
|
772
|
+
throw new AdapterError('INVALID_ARGUMENT', 'no captured frame matches the request', {
|
|
773
|
+
reason: 'frame-not-found',
|
|
774
|
+
frameId: isText(args.frameId) ? args.frameId : null,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
frameId: frame.frameId,
|
|
779
|
+
format: frame.encoded.format,
|
|
780
|
+
bytes: frame.encoded.bytes,
|
|
781
|
+
sha256: frame.encoded.sha256,
|
|
782
|
+
evidence: buildEvidence(state, {
|
|
783
|
+
operation: 'p0.capture.image',
|
|
784
|
+
session,
|
|
785
|
+
snapshot,
|
|
786
|
+
source: 'targets/fake/capture',
|
|
787
|
+
}),
|
|
788
|
+
};
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
network: {
|
|
793
|
+
async start({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
794
|
+
assertNotAborted(signal, 'p0.network.start');
|
|
795
|
+
resolveRecord(state, session, 'p0.network.start');
|
|
796
|
+
assertRevision(session, snapshot, args, 'p0.network.start');
|
|
797
|
+
state.networkStarted = true;
|
|
798
|
+
state.networkStarts += 1;
|
|
799
|
+
return {
|
|
800
|
+
started: true,
|
|
801
|
+
captureBodies: args.captureBodies !== false,
|
|
802
|
+
events: 2,
|
|
803
|
+
evidence: buildEvidence(state, {
|
|
804
|
+
operation: 'p0.network.start',
|
|
805
|
+
session,
|
|
806
|
+
snapshot,
|
|
807
|
+
source: 'targets/fake/network',
|
|
808
|
+
}),
|
|
809
|
+
};
|
|
810
|
+
},
|
|
811
|
+
|
|
812
|
+
async stop({ session = null, snapshot = null, signal = null } = {}) {
|
|
813
|
+
assertNotAborted(signal, 'p0.network.stop');
|
|
814
|
+
resolveRecord(state, session, 'p0.network.stop');
|
|
815
|
+
assertRevision(session, snapshot, null, 'p0.network.stop');
|
|
816
|
+
state.networkStarted = false;
|
|
817
|
+
return {
|
|
818
|
+
stopped: true,
|
|
819
|
+
evidence: buildEvidence(state, {
|
|
820
|
+
operation: 'p0.network.stop',
|
|
821
|
+
session,
|
|
822
|
+
snapshot,
|
|
823
|
+
source: 'targets/fake/network',
|
|
824
|
+
}),
|
|
825
|
+
};
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
async events({ session = null, snapshot = null, signal = null } = {}) {
|
|
829
|
+
assertNotAborted(signal, 'p0.network.events');
|
|
830
|
+
resolveRecord(state, session, 'p0.network.events');
|
|
831
|
+
assertRevision(session, snapshot, null, 'p0.network.events');
|
|
832
|
+
const events = buildFakeNetworkEvents(state, session);
|
|
833
|
+
return {
|
|
834
|
+
events,
|
|
835
|
+
count: events.length,
|
|
836
|
+
nextCursor: null,
|
|
837
|
+
evidence: buildEvidence(state, {
|
|
838
|
+
operation: 'p0.network.events',
|
|
839
|
+
session,
|
|
840
|
+
snapshot,
|
|
841
|
+
source: 'targets/fake/network',
|
|
842
|
+
}),
|
|
843
|
+
};
|
|
844
|
+
},
|
|
845
|
+
|
|
846
|
+
async detail({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
847
|
+
assertNotAborted(signal, 'p0.network.detail');
|
|
848
|
+
resolveRecord(state, session, 'p0.network.detail');
|
|
849
|
+
assertRevision(session, snapshot, args, 'p0.network.detail');
|
|
850
|
+
const events = buildFakeNetworkEvents(state, session);
|
|
851
|
+
const hopId = isInteger(args.hopId) ? args.hopId : 0;
|
|
852
|
+
const event = events.find((item) => (
|
|
853
|
+
(isText(args.eventId) && item.eventId === args.eventId)
|
|
854
|
+
|| (isText(args.requestId) && item.requestKey.requestId === args.requestId && item.requestKey.hopId === hopId)
|
|
855
|
+
));
|
|
856
|
+
if (!event) {
|
|
857
|
+
throw new AdapterError('INVALID_ARGUMENT', 'no fake network event matches the request', {
|
|
858
|
+
reason: 'event-not-found',
|
|
859
|
+
requestId: isText(args.requestId) ? args.requestId : null,
|
|
860
|
+
hopId,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
event,
|
|
865
|
+
body: event.body,
|
|
866
|
+
evidence: buildEvidence(state, {
|
|
867
|
+
operation: 'p0.network.detail',
|
|
868
|
+
session,
|
|
869
|
+
snapshot,
|
|
870
|
+
source: 'targets/fake/network',
|
|
871
|
+
}),
|
|
872
|
+
};
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
|
|
876
|
+
display: {
|
|
877
|
+
async read({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
878
|
+
assertNotAborted(signal, 'p0.display.read');
|
|
879
|
+
resolveRecord(state, session, 'p0.display.read');
|
|
880
|
+
assertRevision(session, snapshot, args, 'p0.display.read');
|
|
881
|
+
if (args.advance === true) state.displayVersion += 1;
|
|
882
|
+
const display = createDisplaySpace({
|
|
883
|
+
space: 'document-css-px',
|
|
884
|
+
geometryVersion: state.displayVersion,
|
|
885
|
+
transform: createTransform({
|
|
886
|
+
from: 'document-css-px',
|
|
887
|
+
to: 'image-px',
|
|
888
|
+
scaleX: 1,
|
|
889
|
+
scaleY: 1,
|
|
890
|
+
offsetX: 0,
|
|
891
|
+
offsetY: 0,
|
|
892
|
+
geometryVersion: state.displayVersion,
|
|
893
|
+
}),
|
|
894
|
+
observedAt: state.now(),
|
|
895
|
+
source: 'targets/fake/display',
|
|
896
|
+
});
|
|
897
|
+
return {
|
|
898
|
+
display,
|
|
899
|
+
evidence: buildEvidence(state, {
|
|
900
|
+
operation: 'p0.display.read',
|
|
901
|
+
session,
|
|
902
|
+
snapshot,
|
|
903
|
+
source: 'targets/fake/display',
|
|
904
|
+
}),
|
|
905
|
+
};
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
|
|
909
|
+
canvas: {
|
|
910
|
+
async annotationWrite({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
911
|
+
assertNotAborted(signal, 'p0.canvas.annotationWrite');
|
|
912
|
+
resolveRecord(state, session, 'p0.canvas.annotationWrite');
|
|
913
|
+
assertRevision(session, snapshot, args, 'p0.canvas.annotationWrite');
|
|
914
|
+
const target = requireBoundTarget(state, session, 'p0.canvas.annotationWrite');
|
|
915
|
+
const doc = args.doc;
|
|
916
|
+
if (!isObject(doc)) {
|
|
917
|
+
throw new AdapterError('INVALID_ARGUMENT', 'annotationWrite requires args.doc', { reason: 'doc-missing' });
|
|
918
|
+
}
|
|
919
|
+
if (doc.targetId !== target.targetId) {
|
|
920
|
+
throw new AdapterError('TARGET_GONE', 'annotation document targets a different target', {
|
|
921
|
+
expected: target.targetId,
|
|
922
|
+
actual: doc.targetId ?? null,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
if (doc.generation !== session.generation) {
|
|
926
|
+
throw new AdapterError('GENERATION_MISMATCH', 'annotation generation is stale', {
|
|
927
|
+
expected: session.generation,
|
|
928
|
+
actual: doc.generation ?? null,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
const current = (() => {
|
|
932
|
+
// A document left behind by an earlier target/generation may stay in
|
|
933
|
+
// storage, but it must never seed the next revision of the live
|
|
934
|
+
// binding: that would carry the previous target's shapes over.
|
|
935
|
+
const stored = isObject(state.annotation?.doc) ? state.annotation.doc : null;
|
|
936
|
+
if (stored && matchesCurrentBinding(session, stored)) return stored;
|
|
937
|
+
return createAnnotationDoc({
|
|
938
|
+
docId: fakeId(state.seed, 'annotation', target.targetId),
|
|
939
|
+
targetId: target.targetId,
|
|
940
|
+
generation: session.generation,
|
|
941
|
+
geometryVersion: state.displayVersion,
|
|
942
|
+
revision: 0,
|
|
943
|
+
shapes: [],
|
|
944
|
+
});
|
|
945
|
+
})();
|
|
946
|
+
const expectedRevision = isInteger(args.expectedRevision) ? args.expectedRevision : current.revision;
|
|
947
|
+
if (expectedRevision !== current.revision) {
|
|
948
|
+
throw new AdapterError('CONFIG_CONFLICT', 'annotation revision conflict', {
|
|
949
|
+
expectedRevision,
|
|
950
|
+
currentRevision: current.revision,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const candidate = createAnnotationDoc({
|
|
954
|
+
docId: isText(doc.docId) ? doc.docId : current.docId,
|
|
955
|
+
targetId: target.targetId,
|
|
956
|
+
generation: session.generation,
|
|
957
|
+
geometryVersion: state.displayVersion,
|
|
958
|
+
revision: current.revision + 1,
|
|
959
|
+
shapes: Array.isArray(doc.shapes) ? doc.shapes.map((shape) => ({ ...shape })) : [],
|
|
960
|
+
});
|
|
961
|
+
const check = validateAnnotationDoc(candidate);
|
|
962
|
+
if (!check.ok) {
|
|
963
|
+
throw new AdapterError('INVALID_ARGUMENT', 'annotation document is invalid', {
|
|
964
|
+
missing: check.missing,
|
|
965
|
+
errors: check.errors,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
state.annotation = { doc: candidate };
|
|
969
|
+
return {
|
|
970
|
+
doc: cloneDoc(candidate),
|
|
971
|
+
evidence: buildEvidence(state, {
|
|
972
|
+
operation: 'p0.canvas.annotationWrite',
|
|
973
|
+
session,
|
|
974
|
+
snapshot,
|
|
975
|
+
source: 'targets/fake/canvas',
|
|
976
|
+
}),
|
|
977
|
+
};
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
async annotationRead({ session = null, snapshot = null, signal = null } = {}) {
|
|
981
|
+
assertNotAborted(signal, 'p0.canvas.annotationRead');
|
|
982
|
+
resolveRecord(state, session, 'p0.canvas.annotationRead');
|
|
983
|
+
assertRevision(session, snapshot, null, 'p0.canvas.annotationRead');
|
|
984
|
+
requireBoundTarget(state, session, 'p0.canvas.annotationRead');
|
|
985
|
+
// The stored document is keyed by nothing but "the last doc"; its
|
|
986
|
+
// targetId/generation decide whether it may be returned for *this*
|
|
987
|
+
// binding. A stale document is refused rather than mislabelled.
|
|
988
|
+
const stored = isObject(state.annotation?.doc) ? state.annotation.doc : null;
|
|
989
|
+
if (stored) {
|
|
990
|
+
assertCurrentBinding(session, stored, {
|
|
991
|
+
operation: 'p0.canvas.annotationRead',
|
|
992
|
+
subject: 'annotation document',
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
doc: cloneDoc(stored),
|
|
997
|
+
evidence: buildEvidence(state, {
|
|
998
|
+
operation: 'p0.canvas.annotationRead',
|
|
999
|
+
session,
|
|
1000
|
+
snapshot,
|
|
1001
|
+
source: 'targets/fake/canvas',
|
|
1002
|
+
}),
|
|
1003
|
+
};
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
targets: {
|
|
1008
|
+
/**
|
|
1009
|
+
* Read-only target listing. Binding/closing is owned by TargetAdapter;
|
|
1010
|
+
* the provider only observes and manages leases.
|
|
1011
|
+
*/
|
|
1012
|
+
async list({ session = null, snapshot = null, signal = null } = {}) {
|
|
1013
|
+
assertNotAborted(signal, 'p0.targets.list');
|
|
1014
|
+
resolveRecord(state, session, 'p0.targets.list');
|
|
1015
|
+
assertRevision(session, snapshot, null, 'p0.targets.list');
|
|
1016
|
+
const targets = (await session.listTargets({ signal })).map((target) => ({ ...target }));
|
|
1017
|
+
return {
|
|
1018
|
+
targets,
|
|
1019
|
+
evidence: buildEvidence(state, {
|
|
1020
|
+
operation: 'p0.targets.list',
|
|
1021
|
+
session,
|
|
1022
|
+
snapshot,
|
|
1023
|
+
source: 'targets/fake/targets',
|
|
1024
|
+
}),
|
|
1025
|
+
};
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
async leaseAcquire({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
1029
|
+
assertNotAborted(signal, 'p0.targets.leaseAcquire');
|
|
1030
|
+
resolveRecord(state, session, 'p0.targets.leaseAcquire');
|
|
1031
|
+
assertRevision(session, snapshot, args, 'p0.targets.leaseAcquire');
|
|
1032
|
+
const target = requireBoundTarget(state, session, 'p0.targets.leaseAcquire');
|
|
1033
|
+
const ttlMs = isFiniteNumber(args.ttlMs) && args.ttlMs > 0 ? args.ttlMs : 60000;
|
|
1034
|
+
state.leaseSeq += 1;
|
|
1035
|
+
const lease = createLease({
|
|
1036
|
+
leaseId: fakeId(state.seed, 'lease', state.leaseSeq),
|
|
1037
|
+
targetId: target.targetId,
|
|
1038
|
+
generation: session.generation,
|
|
1039
|
+
owner: { kind: 'session', id: session.sessionId },
|
|
1040
|
+
expiresAt: state.now() + ttlMs,
|
|
1041
|
+
});
|
|
1042
|
+
state.leases.set(lease.leaseId, lease);
|
|
1043
|
+
return {
|
|
1044
|
+
lease: { ...lease },
|
|
1045
|
+
expired: false,
|
|
1046
|
+
remainingMs: ttlMs,
|
|
1047
|
+
evidence: buildEvidence(state, {
|
|
1048
|
+
operation: 'p0.targets.leaseAcquire',
|
|
1049
|
+
session,
|
|
1050
|
+
snapshot,
|
|
1051
|
+
source: 'targets/fake/targets',
|
|
1052
|
+
}),
|
|
1053
|
+
};
|
|
1054
|
+
},
|
|
1055
|
+
|
|
1056
|
+
async leaseStatus({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
1057
|
+
assertNotAborted(signal, 'p0.targets.leaseStatus');
|
|
1058
|
+
resolveRecord(state, session, 'p0.targets.leaseStatus');
|
|
1059
|
+
assertRevision(session, snapshot, args, 'p0.targets.leaseStatus');
|
|
1060
|
+
const lease = resolveScopedLease(state, session, args, {
|
|
1061
|
+
operation: 'p0.targets.leaseStatus',
|
|
1062
|
+
label: 'leaseStatus',
|
|
1063
|
+
});
|
|
1064
|
+
const now = state.now();
|
|
1065
|
+
return {
|
|
1066
|
+
lease: { ...lease },
|
|
1067
|
+
expired: lease.expiresAt <= now,
|
|
1068
|
+
remainingMs: Math.max(0, lease.expiresAt - now),
|
|
1069
|
+
evidence: buildEvidence(state, {
|
|
1070
|
+
operation: 'p0.targets.leaseStatus',
|
|
1071
|
+
session,
|
|
1072
|
+
snapshot,
|
|
1073
|
+
source: 'targets/fake/targets',
|
|
1074
|
+
}),
|
|
1075
|
+
};
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
async leaseRelease({ session = null, snapshot = null, args = {}, signal = null } = {}) {
|
|
1079
|
+
assertNotAborted(signal, 'p0.targets.leaseRelease');
|
|
1080
|
+
resolveRecord(state, session, 'p0.targets.leaseRelease');
|
|
1081
|
+
assertRevision(session, snapshot, args, 'p0.targets.leaseRelease');
|
|
1082
|
+
const lease = resolveScopedLease(state, session, args, {
|
|
1083
|
+
operation: 'p0.targets.leaseRelease',
|
|
1084
|
+
label: 'leaseRelease',
|
|
1085
|
+
});
|
|
1086
|
+
state.leases.delete(args.leaseId);
|
|
1087
|
+
state.invalidatedLeases.delete(args.leaseId);
|
|
1088
|
+
return {
|
|
1089
|
+
released: true,
|
|
1090
|
+
lease: { ...lease },
|
|
1091
|
+
evidence: buildEvidence(state, {
|
|
1092
|
+
operation: 'p0.targets.leaseRelease',
|
|
1093
|
+
session,
|
|
1094
|
+
snapshot,
|
|
1095
|
+
source: 'targets/fake/targets',
|
|
1096
|
+
}),
|
|
1097
|
+
};
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
|
|
1101
|
+
async dispose() {
|
|
1102
|
+
if (disposed) return { disposed: true, alreadyDisposed: true };
|
|
1103
|
+
disposed = true;
|
|
1104
|
+
state.frames.length = 0;
|
|
1105
|
+
state.leases.clear();
|
|
1106
|
+
state.invalidatedLeases.clear();
|
|
1107
|
+
state.annotation = null;
|
|
1108
|
+
return { disposed: true, alreadyDisposed: false };
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
// Environment factory
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Create the three fake adapters over one shared lifecycle state.
|
|
1119
|
+
*
|
|
1120
|
+
* @param {{now?:() => number, seed?: string}} [options]
|
|
1121
|
+
* @returns {{targetAdapter:object, capabilityAdapter:object, p0Provider:object, dispose:() => Promise<object>}}
|
|
1122
|
+
*/
|
|
1123
|
+
export function createFakeEnvironment({ now = Date.now, seed = 'fake' } = {}) {
|
|
1124
|
+
if (typeof now !== 'function') throw new TypeError('createFakeEnvironment: now must be a function');
|
|
1125
|
+
if (!isText(seed)) throw new TypeError('createFakeEnvironment: seed must be a non-empty string');
|
|
1126
|
+
|
|
1127
|
+
const state = {
|
|
1128
|
+
now,
|
|
1129
|
+
seed,
|
|
1130
|
+
sessions: new Map(),
|
|
1131
|
+
sessionSeq: 0,
|
|
1132
|
+
lastSession: null,
|
|
1133
|
+
frames: [],
|
|
1134
|
+
frameSeq: 0,
|
|
1135
|
+
leases: new Map(),
|
|
1136
|
+
leaseSeq: 0,
|
|
1137
|
+
/** leaseId -> {reason, releasedAt} for leases invalidated by a rebind. */
|
|
1138
|
+
invalidatedLeases: new Map(),
|
|
1139
|
+
annotation: null,
|
|
1140
|
+
displayVersion: 1,
|
|
1141
|
+
networkStarted: false,
|
|
1142
|
+
networkStarts: 0,
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
const targetAdapter = createFakeTargetAdapter(state);
|
|
1146
|
+
const capabilityAdapter = createFakeCapabilityAdapter(state);
|
|
1147
|
+
const p0Provider = createFakeP0Provider(state);
|
|
1148
|
+
let disposed = false;
|
|
1149
|
+
|
|
1150
|
+
return {
|
|
1151
|
+
targetAdapter,
|
|
1152
|
+
capabilityAdapter,
|
|
1153
|
+
p0Provider,
|
|
1154
|
+
async dispose() {
|
|
1155
|
+
if (disposed) return { disposed: true, alreadyDisposed: true };
|
|
1156
|
+
disposed = true;
|
|
1157
|
+
await p0Provider.dispose();
|
|
1158
|
+
await capabilityAdapter.dispose();
|
|
1159
|
+
await targetAdapter.dispose();
|
|
1160
|
+
return { disposed: true, alreadyDisposed: false };
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
}
|