@xema/omni-protocol 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,10 @@ validateSnapshot(snapshot, manifest, "snapshot", { self: identity.id, capabiliti
58
58
  `exerciseAdapter` validates the manifest, opens an authenticated session, connects, checks
59
59
  required capability methods, subscribes, validates the snapshot, every delivered event, and every
60
60
  authentication state published during the run — against the latest login — states a capacity,
61
- then unsubscribes and disconnects.
61
+ then unsubscribes and disconnects. `result.notExercised` names what the run never reached — each
62
+ optional part of a task, of the break state and roster, each contribution, each event type — so a
63
+ clean result is read for what it covers and not for the whole contract; `assertReached(result,
64
+ subjects)` is the paired assertion.
62
65
 
63
66
  ```ts
64
67
  const result = await exerciseAdapter(adapter, context, { collectOnly: true });
package/dist/testing.d.ts CHANGED
@@ -1,6 +1,14 @@
1
- import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Manifest } from "./index.js";
1
+ import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Manifest, type ProviderEvent } from "./index.js";
2
2
  import { type ProtocolViolation } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
4
+ /**
5
+ * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
6
+ * a fixture without it exercises none of its rules and passes clean. One subject per family of
7
+ * rules -- each optional part of a task, each optional part of the break state and roster, each
8
+ * declared contribution, and each event type.
9
+ */
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities"];
11
+ export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
4
12
  export interface AdapterContractResult {
5
13
  events: ProviderEventEnvelope[];
6
14
  /** The state the session was restored with at sign-in. */
@@ -12,6 +20,13 @@ export interface AdapterContractResult {
12
20
  login: AuthenticationState;
13
21
  /** True only when every unsubscribe, `disconnect()`, and `close()` settled without throwing. */
14
22
  disconnectWasClean: boolean;
23
+ /**
24
+ * What the run never reached, and so what a clean `violations` says nothing about. Nothing here
25
+ * is a violation -- an adapter with no team has nothing to exercise -- but a fixture with no
26
+ * tasks exercises no task rule, and an adapter's own test asserts that the subjects it meant to
27
+ * reach are absent from this list.
28
+ */
29
+ notExercised: readonly ContractSubject[];
15
30
  /** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
16
31
  violations: readonly ProtocolViolation[];
17
32
  }
@@ -59,6 +74,11 @@ export declare function assertCommandRefusedAfterWithdrawal(result: {
59
74
  code: string;
60
75
  };
61
76
  }): void;
77
+ /**
78
+ * Throws unless the run reached every subject named: the paired assertion beside a clean result,
79
+ * so a fixture that never produced a roster cannot pass a test that meant to check one.
80
+ */
81
+ export declare function assertReached(result: AdapterContractResult, subjects: readonly ContractSubject[]): void;
62
82
  /** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
63
83
  export declare function assertDuplicateEventDelivery<C extends Channel>(envelopes: readonly ProviderEventEnvelope<C>[]): ProviderEventEnvelope<C>[];
64
84
  /** Validates an authoritative reconnect snapshot containing assignments missed while offline. */
package/dist/testing.js CHANGED
@@ -1,6 +1,128 @@
1
1
  import { browserSessionKey, } from "./index.js";
2
2
  import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateSnapshot, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
+ /**
5
+ * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
6
+ * a fixture without it exercises none of its rules and passes clean. One subject per family of
7
+ * rules -- each optional part of a task, each optional part of the break state and roster, each
8
+ * declared contribution, and each event type.
9
+ */
10
+ const STATE_SUBJECTS = [
11
+ "tasks",
12
+ "task.browsers",
13
+ "task.attributes",
14
+ "task.handlingHistory",
15
+ "task.consultation",
16
+ "task.lead",
17
+ "task.assisting",
18
+ "task.dispositions",
19
+ "task.destinations",
20
+ "task.custom",
21
+ "break.reasons",
22
+ "break.imposed",
23
+ "team.members",
24
+ "team.requests",
25
+ "contacts",
26
+ "scheduledActivities",
27
+ ];
28
+ // Pinned to the event union the way validation pins its closed sets: a type added to
29
+ // `ProviderEvent` without a row here, or a row it lacks, is a compile error.
30
+ const EVENT_TYPES = {
31
+ snapshot: true, "provider-status": true, "break-state": true, "task-offered": true, "task-updated": true,
32
+ "task-media-ended": true, "task-ended": true, announcement: true, "provider-summary": true,
33
+ "team-updated": true, "contacts-updated": true, "calendar-updated": true,
34
+ };
35
+ const CONTRACT_SUBJECTS = [
36
+ ...STATE_SUBJECTS,
37
+ ...Object.keys(EVENT_TYPES).map(type => `event.${type}`),
38
+ ];
39
+ // What the run observed. The input is untrusted and has already been reported on, so nothing
40
+ // here assumes its shape; a subject is reached only by something the element rules would see.
41
+ const isRecord = (value) => typeof value === "object" && value !== null;
42
+ const some = (value) => Array.isArray(value) && value.length > 0;
43
+ function observeTask(value, seen) {
44
+ if (!isRecord(value))
45
+ return;
46
+ seen.add("tasks");
47
+ if (some(value.browsers))
48
+ seen.add("task.browsers");
49
+ if (some(value.attributes))
50
+ seen.add("task.attributes");
51
+ if (some(value.handlingHistory))
52
+ seen.add("task.handlingHistory");
53
+ if (value.consultation !== undefined)
54
+ seen.add("task.consultation");
55
+ if (value.lead !== undefined)
56
+ seen.add("task.lead");
57
+ if (value.assisting !== undefined)
58
+ seen.add("task.assisting");
59
+ const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
60
+ if (isRecord(capabilities.dispositions))
61
+ seen.add("task.dispositions");
62
+ if (isRecord(capabilities.blindTransfer) && some(capabilities.blindTransfer.destinations))
63
+ seen.add("task.destinations");
64
+ if (some(capabilities.custom))
65
+ seen.add("task.custom");
66
+ }
67
+ function observeBreak(value, seen) {
68
+ if (!isRecord(value))
69
+ return;
70
+ if (some(value.reasons))
71
+ seen.add("break.reasons");
72
+ if (value.imposed !== undefined)
73
+ seen.add("break.imposed");
74
+ }
75
+ function observeTeam(value, seen) {
76
+ if (!isRecord(value))
77
+ return;
78
+ if (some(value.members))
79
+ seen.add("team.members");
80
+ if (some(value.requests))
81
+ seen.add("team.requests");
82
+ }
83
+ function observeSnapshot(value, seen) {
84
+ if (!isRecord(value))
85
+ return;
86
+ if (Array.isArray(value.tasks))
87
+ value.tasks.forEach(task => observeTask(task, seen));
88
+ observeBreak(value.break, seen);
89
+ observeTeam(value.team, seen);
90
+ if (some(value.contacts))
91
+ seen.add("contacts");
92
+ if (some(value.scheduledActivities))
93
+ seen.add("scheduledActivities");
94
+ }
95
+ function observeEvent(envelope, seen) {
96
+ const event = isRecord(envelope) ? envelope.event : undefined;
97
+ if (!isRecord(event))
98
+ return;
99
+ if (typeof event.type === "string" && event.type in EVENT_TYPES)
100
+ seen.add(`event.${event.type}`);
101
+ switch (event.type) {
102
+ case "snapshot":
103
+ observeSnapshot(event.snapshot, seen);
104
+ break;
105
+ case "break-state":
106
+ observeBreak(event.break, seen);
107
+ break;
108
+ case "task-offered":
109
+ case "task-updated":
110
+ observeTask(event.task, seen);
111
+ break;
112
+ case "team-updated":
113
+ observeTeam(event.team, seen);
114
+ break;
115
+ case "contacts-updated":
116
+ if (some(event.contacts))
117
+ seen.add("contacts");
118
+ break;
119
+ case "calendar-updated":
120
+ if (some(event.scheduledActivities))
121
+ seen.add("scheduledActivities");
122
+ break;
123
+ default: break;
124
+ }
125
+ }
4
126
  /**
5
127
  * Adapter conformance exercise: validates the manifest, opens an authenticated session,
6
128
  * connects, checks that every method the declarations require is implemented, subscribes,
@@ -16,6 +138,7 @@ export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
16
138
  export async function exerciseAdapter(adapter, context, options = {}) {
17
139
  const violations = [...validateManifest(adapter.manifest)];
18
140
  const events = [];
141
+ const seen = new Set();
19
142
  const storedSecrets = new Map();
20
143
  const authentication = await adapter.createAuthenticationSession({
21
144
  ...context,
@@ -104,6 +227,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
104
227
  requireMethod(live, "openMedia", "the manifest channel is voice");
105
228
  const eventIds = new Set();
106
229
  unsubscribe = connection.subscribe(envelope => {
230
+ observeEvent(envelope, seen);
107
231
  violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
108
232
  if (typeof envelope?.id === "string") {
109
233
  if (eventIds.has(envelope.id))
@@ -113,6 +237,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
113
237
  events.push(envelope);
114
238
  });
115
239
  const snapshot = await connection.snapshot();
240
+ observeSnapshot(snapshot, seen);
116
241
  violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
117
242
  requireCapabilityMethods(live, current().capabilities);
118
243
  if (publishesUserIds(snapshot))
@@ -169,6 +294,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
169
294
  events: events,
170
295
  authenticationState: authenticationState,
171
296
  login: (login ?? authenticationState),
297
+ notExercised: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
172
298
  disconnectWasClean,
173
299
  violations,
174
300
  };
@@ -296,6 +422,16 @@ export function assertCommandRefusedAfterWithdrawal(result) {
296
422
  throw new Error(`A command after its capability was withdrawn fails with omni.capability-not-enabled, not ${result.failure?.code}`);
297
423
  }
298
424
  }
425
+ /**
426
+ * Throws unless the run reached every subject named: the paired assertion beside a clean result,
427
+ * so a fixture that never produced a roster cannot pass a test that meant to check one.
428
+ */
429
+ export function assertReached(result, subjects) {
430
+ const missed = subjects.filter(subject => result.notExercised.includes(subject));
431
+ if (missed.length > 0) {
432
+ throw new Error(`The exercise never reached ${missed.join(", ")}: its clean result says nothing about them`);
433
+ }
434
+ }
299
435
  /** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
300
436
  export function assertDuplicateEventDelivery(envelopes) {
301
437
  const byId = new Map();
@@ -811,6 +811,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
811
811
  // Presence is the permission, and it cuts both ways: data a provider never declared a
812
812
  // capability for is data Omni would show against a control the agent does not have.
813
813
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
814
+ // And it cuts the other way too: a declared contribution is required, `[]` included. A snapshot
815
+ // that omits one the manifest declares has not cleared it, it has said nothing, and Omni would
816
+ // go on showing whatever it held.
817
+ if (snapshot.contacts === undefined && idle.contacts === true) {
818
+ into.add("snapshot.contacts.required", `${path}.contacts`, "the manifest declares contacts, so every snapshot carries the contribution: [] when there are none");
819
+ }
820
+ if (snapshot.scheduledActivities === undefined && idle.calendar === true) {
821
+ into.add("snapshot.calendar.required", `${path}.scheduledActivities`, "the manifest declares calendar, so every snapshot carries the contribution: [] when there are none");
822
+ }
814
823
  if (snapshot.contacts !== undefined) {
815
824
  into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
816
825
  if (Array.isArray(snapshot.contacts)) {
package/guide.md CHANGED
@@ -3148,7 +3148,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3148
3148
  | --- | --- |
3149
3149
  | `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
3150
3150
  | `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
3151
- | `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including idle-capability gating. |
3151
+ | `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
3152
3152
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
3153
3153
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
3154
3154
  | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
@@ -3204,6 +3204,15 @@ latest the session published during the run, which differs only when the adapter
3204
3204
  `authentication.refreshing.identity`, a changed capability set `authentication.refreshing.capabilities`.
3205
3205
  A capability granted by a later login requires its methods just as one declared at sign-in does.
3206
3206
 
3207
+ `result.notExercised` lists what the run never reached — one subject per family of rules: each
3208
+ optional part of a task (`task.browsers`, `task.handlingHistory`, `task.lead`, …), the break's
3209
+ `reasons` and `imposed`, the roster's `members` and `requests`, each declared contribution, and
3210
+ each event type (`event.task-ended`, …) — and so what a clean `violations` says nothing about.
3211
+ Nothing there is a violation: an adapter with no team has nothing to exercise. But a fixture with
3212
+ no tasks exercises no task rule, and a pass over it reads as coverage it is not.
3213
+ `assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
3214
+ never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
3215
+
3207
3216
  Three properties of the harness matter to adapter authors:
3208
3217
 
3209
3218
  - **Violations are collected, never thrown from inside the subscribe listener.** Throwing there
@@ -3225,6 +3234,7 @@ cannot be established from TypeScript structure alone.
3225
3234
  | --- | --- |
3226
3235
  | `assertCapabilityWithdrawal(states, snapshot, manifest)` | A capability withdrawn by a later `authenticated` state is gone from the next snapshot: no roster for a login that no longer leads, no requests for one that may no longer join. Every state is validated on the way, `refreshing` must carry the login over, and the sequence passes only through usable states. |
3227
3236
  | `assertCommandRefusedAfterWithdrawal(result)` | A command that arrives after its capability was withdrawn fails with `omni.capability-not-enabled`, named by the provider. |
3237
+ | `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `exerciseAdapter` result. |
3228
3238
  | `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
3229
3239
  | `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
3230
3240
  | `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",