@xema/omni-protocol 0.1.62 → 0.1.63
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/dist/testing.d.ts +8 -0
- package/dist/testing.js +76 -8
- package/guide.md +11 -1
- package/package.json +1 -1
package/dist/testing.d.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface ExerciseAdapterOptions {
|
|
|
43
43
|
drive?: boolean;
|
|
44
44
|
/** How long the drive waits for each thing the provider owes it. Defaults to 5000. */
|
|
45
45
|
driveTimeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Builds the adapter again, as a host reload does: a new object with no memory, for the same
|
|
48
|
+
* login. Given it, the drive -- once the host's muted leg is recorded -- connects a second adapter
|
|
49
|
+
* with the same context and the same store and expects its snapshot to carry the task with that
|
|
50
|
+
* leg. A platform that holds the record hands it back; an adapter that composed the record in
|
|
51
|
+
* memory has nothing, and is named. Without it the store is required and never read.
|
|
52
|
+
*/
|
|
53
|
+
rebuild?: () => Adapter<Channel>;
|
|
46
54
|
}
|
|
47
55
|
/**
|
|
48
56
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
package/dist/testing.js
CHANGED
|
@@ -161,14 +161,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
161
161
|
const breaks = new BreakStream();
|
|
162
162
|
let seeded = false;
|
|
163
163
|
const storedSecrets = new Map();
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
},
|
|
171
|
-
});
|
|
164
|
+
const authenticationSecrets = {
|
|
165
|
+
get: async (key) => storedSecrets.get(key),
|
|
166
|
+
set: async (key, value) => { storedSecrets.set(key, value); },
|
|
167
|
+
delete: async (key) => { storedSecrets.delete(key); },
|
|
168
|
+
};
|
|
169
|
+
const authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
|
|
172
170
|
let connection;
|
|
173
171
|
let unsubscribe;
|
|
174
172
|
let unsubscribeAuthentication;
|
|
@@ -376,6 +374,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
376
374
|
violations.push(...await driveOneCall({
|
|
377
375
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
378
376
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
377
|
+
context, secrets: authenticationSecrets, reader, rebuild: options.rebuild,
|
|
379
378
|
}));
|
|
380
379
|
}
|
|
381
380
|
}
|
|
@@ -956,6 +955,70 @@ async function driveOneCall(drive) {
|
|
|
956
955
|
};
|
|
957
956
|
drive.waiters.add(waiter);
|
|
958
957
|
});
|
|
958
|
+
const recordSurvivesReload = async (at) => {
|
|
959
|
+
let again;
|
|
960
|
+
try {
|
|
961
|
+
again = drive.rebuild();
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
refuse("drive.reload.rejected", "drive.reload", `building the adapter again threw: ${String(error)}`);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
let session;
|
|
968
|
+
let second;
|
|
969
|
+
try {
|
|
970
|
+
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
971
|
+
if (again.manifest.id !== drive.manifest.id) {
|
|
972
|
+
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
976
|
+
second = await again.connect(drive.context);
|
|
977
|
+
const snapshot = await second.snapshot();
|
|
978
|
+
// The second adapter's snapshot is held to everything a first one is.
|
|
979
|
+
found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
|
|
980
|
+
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
981
|
+
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
982
|
+
if (carried === undefined) {
|
|
983
|
+
refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const history = carried.handlingHistory;
|
|
987
|
+
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
988
|
+
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
989
|
+
const before = latestTask().handlingHistory;
|
|
990
|
+
const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
|
|
991
|
+
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
992
|
+
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
993
|
+
if (lost.length > 0) {
|
|
994
|
+
refuse("drive.reload.history", "drive.reload.history", `a second adapter built from the same login carries ${taskId} without ${lost.join(", ")}, which the first had published: a record once read is not unread`);
|
|
995
|
+
}
|
|
996
|
+
const leg = steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
997
|
+
if (leg === undefined) {
|
|
998
|
+
refuse("drive.reload.history", "drive.reload.history", `a second adapter built from the same login carries ${taskId} without the muted leg at ${at}: the record was composed in memory and died with the adapter; the login's store is where it lives`);
|
|
999
|
+
}
|
|
1000
|
+
else if (leg.mutedBy !== "host") {
|
|
1001
|
+
refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
try {
|
|
1009
|
+
await second?.disconnect();
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
refuse("drive.reload.rejected", "drive.reload", `the second adapter's disconnect threw: ${String(error)}`);
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
await session?.close();
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
959
1022
|
// The task, as first seen: on the snapshot, or on the first offer.
|
|
960
1023
|
let cursor = 0;
|
|
961
1024
|
let task = isRecord(drive.snapshot) && Array.isArray(drive.snapshot.tasks) ? drive.snapshot.tasks.find(isTask) : undefined;
|
|
@@ -1113,6 +1176,11 @@ async function driveOneCall(drive) {
|
|
|
1113
1176
|
setMuted(false);
|
|
1114
1177
|
await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1115
1178
|
mutedLeg = { at, task: latestTask() };
|
|
1179
|
+
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1180
|
+
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1181
|
+
// the store -- or it composed the record in memory and the record died with it.
|
|
1182
|
+
if (drive.rebuild !== undefined)
|
|
1183
|
+
await recordSurvivesReload(at);
|
|
1116
1184
|
}
|
|
1117
1185
|
// 4. Hold and resume, where offered.
|
|
1118
1186
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
package/guide.md
CHANGED
|
@@ -4590,7 +4590,17 @@ violation (`drive.command.failed`), and an event the provider owes and never sen
|
|
|
4590
4590
|
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4591
4591
|
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4592
4592
|
restates the task's record afterwards, the leg is in it or the hole is named
|
|
4593
|
-
(`drive.recordStep.history`).
|
|
4593
|
+
(`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
|
|
4594
|
+
does, the drive then connects a second adapter for the same login with the same context and the
|
|
4595
|
+
same `store`, and expects its snapshot to carry the task with that leg and the host's word: a
|
|
4596
|
+
platform that holds the record hands it back, and an adapter that composed the record in memory
|
|
4597
|
+
has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`, `.rejected`). The second
|
|
4598
|
+
adapter is held to what the first was: the same provider (`drive.reload.manifest`), a snapshot that
|
|
4599
|
+
stands as any snapshot must, and a record that lost none of the entries the first had published --
|
|
4600
|
+
a record once read is not unread across a reload either. A reconnect on the same adapter object
|
|
4601
|
+
would prove nothing, since an in-process adapter's memory survives it; only a second object
|
|
4602
|
+
separates kept-in-the-store from never-lost. Outside the
|
|
4603
|
+
handling phases with `hold` still declared -- in
|
|
4594
4604
|
`confirmed`, where the provider publishes it, and in `completing` once the call has ended -- the
|
|
4595
4605
|
drive sends `hold` past the validator that would hold it back, and expects `failed`: the adapter
|
|
4596
4606
|
is the second gate on a control on the contact, and one that applies it where there is nothing to
|