@xema/omni-protocol 0.1.25 → 0.1.27
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 +1 -1
- package/dist/index.d.ts +78 -72
- package/dist/index.js +7 -7
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +27 -27
- package/dist/validation.d.ts +10 -10
- package/dist/validation.js +97 -100
- package/guide.md +160 -150
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserSessionKey,
|
|
1
|
+
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
2
|
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
@@ -31,8 +31,8 @@ const STATE_SUBJECTS = [
|
|
|
31
31
|
// Pinned to the event union the way validation pins its closed sets: a type added to
|
|
32
32
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
33
33
|
const EVENT_TYPES = {
|
|
34
|
-
snapshot: true, "
|
|
35
|
-
"task-media-
|
|
34
|
+
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
35
|
+
"task-media-started": true, "task-media-ended": true, "task-ended": true, announcement: true, "queue-summary": true,
|
|
36
36
|
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
37
37
|
};
|
|
38
38
|
const CONTRACT_SUBJECTS = [
|
|
@@ -172,8 +172,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
172
172
|
let disconnectWasClean = false;
|
|
173
173
|
try {
|
|
174
174
|
authenticationState = await authentication.state();
|
|
175
|
-
const
|
|
176
|
-
violations.push(...validateAuthenticationState(authenticationState, "authentication", {
|
|
175
|
+
const levels = effectiveLevels(adapter.manifest.orgLevels).map(level => level.id);
|
|
176
|
+
violations.push(...validateAuthenticationState(authenticationState, "authentication", { levels }));
|
|
177
177
|
if (authenticationState.status !== "authenticated") {
|
|
178
178
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
179
179
|
}
|
|
@@ -193,7 +193,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
193
193
|
const reader = () => ({
|
|
194
194
|
self: current().identity.id,
|
|
195
195
|
capabilities: current().capabilities,
|
|
196
|
-
|
|
196
|
+
loginId: context.loginId,
|
|
197
197
|
autoAcceptTasks: context.autoAcceptTasks ?? true,
|
|
198
198
|
});
|
|
199
199
|
// The optional methods are optional only until something declares a need for them. Each
|
|
@@ -228,7 +228,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
228
228
|
requireMethod(on, "setPreference", "the login declares capabilities.preferences");
|
|
229
229
|
};
|
|
230
230
|
unsubscribeAuthentication = authentication.subscribe(state => {
|
|
231
|
-
const own = validateAuthenticationState(state, "authentication", {
|
|
231
|
+
const own = validateAuthenticationState(state, "authentication", { levels });
|
|
232
232
|
violations.push(...own);
|
|
233
233
|
if (own.length > 0)
|
|
234
234
|
return;
|
|
@@ -590,7 +590,7 @@ const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
|
590
590
|
export class TaskStream {
|
|
591
591
|
tasks = new Map();
|
|
592
592
|
static stated(task) {
|
|
593
|
-
const media = isRecord(task) && (task.media === "
|
|
593
|
+
const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
|
|
594
594
|
return { phase: String(isRecord(task) ? task.phase : undefined), media };
|
|
595
595
|
}
|
|
596
596
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
@@ -638,30 +638,30 @@ export class TaskStream {
|
|
|
638
638
|
}
|
|
639
639
|
}
|
|
640
640
|
// A task replaces the task, and an update re-states media without moving it: the
|
|
641
|
-
// transitions belong to task-media-
|
|
641
|
+
// transitions belong to task-media-started and task-media-ended. Releasing ended is the
|
|
642
642
|
// one move an update may make, since wrapped audio has nothing left to end.
|
|
643
643
|
{
|
|
644
644
|
const next = TaskStream.stated(event.task);
|
|
645
645
|
if (known.media !== next.media && !(known.media === "ended" && next.media === "none")) {
|
|
646
|
-
refuse("stream.taskUpdated.media", `${at}.task.media`, `a task-updated re-states media, it does not move it: ${id} held ${known.media} and the update says ${next.media}; audio arrives on task-media-
|
|
646
|
+
refuse("stream.taskUpdated.media", `${at}.task.media`, `a task-updated re-states media, it does not move it: ${id} held ${known.media} and the update says ${next.media}; audio arrives on task-media-started and ends on task-media-ended`);
|
|
647
647
|
}
|
|
648
648
|
this.tasks.set(id, next);
|
|
649
649
|
}
|
|
650
650
|
break;
|
|
651
|
-
case "task-media-
|
|
651
|
+
case "task-media-started":
|
|
652
652
|
if (id === undefined)
|
|
653
653
|
break;
|
|
654
654
|
if (known === undefined) {
|
|
655
|
-
refuse("stream.
|
|
655
|
+
refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
656
656
|
break;
|
|
657
657
|
}
|
|
658
658
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
659
|
-
refuse("stream.
|
|
659
|
+
refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
660
660
|
}
|
|
661
|
-
if (known.media === "
|
|
662
|
-
refuse("stream.
|
|
661
|
+
if (known.media === "started") {
|
|
662
|
+
refuse("stream.taskMediaStarted.duplicate", `${at}.taskId`, `media already started on ${id}; started and ended alternate`);
|
|
663
663
|
}
|
|
664
|
-
known.media = "
|
|
664
|
+
known.media = "started";
|
|
665
665
|
break;
|
|
666
666
|
case "task-media-ended":
|
|
667
667
|
if (id === undefined)
|
|
@@ -673,8 +673,8 @@ export class TaskStream {
|
|
|
673
673
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
674
674
|
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
675
675
|
}
|
|
676
|
-
if (known.media !== "
|
|
677
|
-
refuse("stream.taskMediaEnded.silent", `${at}.taskId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-
|
|
676
|
+
if (known.media !== "started") {
|
|
677
|
+
refuse("stream.taskMediaEnded.silent", `${at}.taskId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-started, or on a task carried with media started`);
|
|
678
678
|
}
|
|
679
679
|
known.media = "ended";
|
|
680
680
|
break;
|
|
@@ -756,7 +756,7 @@ export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
|
756
756
|
}
|
|
757
757
|
/**
|
|
758
758
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
759
|
-
* with the snapshot it began from -- every task is introduced once, `task-media-
|
|
759
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-started` and
|
|
760
760
|
* `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
|
|
761
761
|
* follows the media ending is `completing` or `task-ended`.
|
|
762
762
|
*/
|
|
@@ -769,7 +769,7 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
769
769
|
assertNoViolations(found, "The media follows the task");
|
|
770
770
|
}
|
|
771
771
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
772
|
-
export function stillHost(report = { online: true
|
|
772
|
+
export function stillHost(report = { online: true }) {
|
|
773
773
|
return { report: () => report, subscribe: () => () => undefined };
|
|
774
774
|
}
|
|
775
775
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
@@ -833,20 +833,20 @@ export function assertBreakBeginsAfterTask(steps) {
|
|
|
833
833
|
* implementation; pass 0 to demand an exact match.
|
|
834
834
|
*/
|
|
835
835
|
export function assertWrapTimeout(task, mediaEndedAt, observedDeadline, toleranceMs = 1_000) {
|
|
836
|
-
if (task.
|
|
836
|
+
if (task.wrapAllowance === undefined) {
|
|
837
837
|
if (observedDeadline !== undefined) {
|
|
838
838
|
throw new Error(`Wrap deadline mismatch: the task states no allowance, so there is no deadline, received ${observedDeadline}`);
|
|
839
839
|
}
|
|
840
840
|
return;
|
|
841
841
|
}
|
|
842
842
|
if (observedDeadline === undefined) {
|
|
843
|
-
throw new Error(`Wrap deadline mismatch: the task allows ${task.
|
|
843
|
+
throw new Error(`Wrap deadline mismatch: the task allows ${task.wrapAllowance}s, but no deadline was observed`);
|
|
844
844
|
}
|
|
845
845
|
const ended = Date.parse(mediaEndedAt);
|
|
846
846
|
const deadline = Date.parse(observedDeadline);
|
|
847
847
|
if (Number.isNaN(ended) || Number.isNaN(deadline))
|
|
848
848
|
throw new Error("Wrap scenario requires valid ISO-8601 times");
|
|
849
|
-
const expected = ended + task.
|
|
849
|
+
const expected = ended + task.wrapAllowance * 1_000;
|
|
850
850
|
if (Math.abs(deadline - expected) > toleranceMs) {
|
|
851
851
|
throw new Error(`Wrap deadline mismatch: expected ${new Date(expected).toISOString()} within ${toleranceMs}ms, received ${observedDeadline}`);
|
|
852
852
|
}
|
|
@@ -854,14 +854,14 @@ export function assertWrapTimeout(task, mediaEndedAt, observedDeadline, toleranc
|
|
|
854
854
|
/** The session key one scenario derives, or `undefined` where the browser shares nothing. */
|
|
855
855
|
const sessionKeyFor = (scenario) => browserSessionKey(scenario);
|
|
856
856
|
/** Validates whether two task-browser definitions should share one browser session. */
|
|
857
|
-
export function
|
|
857
|
+
export function assertBrowserSessionIsolation(left, right, expectedReuse) {
|
|
858
858
|
const leftKey = sessionKeyFor(left);
|
|
859
859
|
const rightKey = sessionKeyFor(right);
|
|
860
|
-
// A browser that does not
|
|
861
|
-
// Treating "no key" as a match would report
|
|
860
|
+
// A browser that does not share its session has no session key at all, so two of them never share one.
|
|
861
|
+
// Treating "no key" as a match would report sharing nobody asked for.
|
|
862
862
|
const actualReuse = leftKey !== undefined && leftKey === rightKey;
|
|
863
863
|
if (actualReuse !== expectedReuse) {
|
|
864
|
-
throw new Error(`Browser
|
|
864
|
+
throw new Error(`Browser session sharing mismatch: expected ${expectedReuse}, received ${actualReuse} (${String(leftKey)} vs ${String(rightKey)})`);
|
|
865
865
|
}
|
|
866
866
|
}
|
|
867
867
|
/**
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProtocolViolation, type
|
|
1
|
+
import { type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -13,8 +13,8 @@ export declare function validateManifest(manifest: unknown, path?: string): Prot
|
|
|
13
13
|
export interface TaskValidationContext {
|
|
14
14
|
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
15
15
|
channel: string;
|
|
16
|
-
/** The
|
|
17
|
-
|
|
16
|
+
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
17
|
+
levels?: readonly string[];
|
|
18
18
|
}
|
|
19
19
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
20
20
|
/**
|
|
@@ -31,11 +31,11 @@ export interface ReaderContext {
|
|
|
31
31
|
* The login's `AuthenticationState.capabilities`. The login is the permission: a lead's
|
|
32
32
|
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
33
33
|
*/
|
|
34
|
-
capabilities?:
|
|
35
|
-
/** The
|
|
36
|
-
|
|
37
|
-
/** The login's `
|
|
38
|
-
|
|
34
|
+
capabilities?: UserCapabilities;
|
|
35
|
+
/** The level ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
|
|
36
|
+
levels?: readonly string[];
|
|
37
|
+
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
38
|
+
loginId?: string;
|
|
39
39
|
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
|
|
40
40
|
autoAcceptTasks?: boolean;
|
|
41
41
|
}
|
|
@@ -59,7 +59,7 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
|
|
|
59
59
|
export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
|
|
60
60
|
/** What a login is validated against beyond its own shape. */
|
|
61
61
|
export interface LoginValidationContext {
|
|
62
|
-
/** The
|
|
63
|
-
|
|
62
|
+
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
63
|
+
levels?: readonly string[];
|
|
64
64
|
}
|
|
65
65
|
export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
|