@xema/omni-protocol 0.1.111 → 0.1.113
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 +6 -6
- package/dist/index.d.ts +6 -0
- package/dist/testing.d.ts +21 -13
- package/dist/testing.js +270 -165
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +9 -9
- package/guide/agent.md +14 -8
- package/guide/breaks.md +1 -1
- package/guide/phone.md +1 -1
- package/guide/voice.md +1 -1
- package/guide.md +64 -42
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules,
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleTested, validateHostMute, validateHostRecording, validateLoginStore, validateHistoryReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -212,9 +212,9 @@ function observeEvent(envelope, seen) {
|
|
|
212
212
|
* for a synchronous emitter, and is swallowed as an unhandled rejection for an
|
|
213
213
|
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
214
214
|
*/
|
|
215
|
-
export async function
|
|
216
|
-
const
|
|
217
|
-
const stopObserving = observeRules(rule => {
|
|
215
|
+
export async function testAdapter(adapter, context, options = {}) {
|
|
216
|
+
const rulesTested = new Set();
|
|
217
|
+
const stopObserving = observeRules(rule => { rulesTested.add(rule); });
|
|
218
218
|
const violations = [...validateManifest(adapter.manifest)];
|
|
219
219
|
const events = [];
|
|
220
220
|
const seen = new Set();
|
|
@@ -388,7 +388,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
388
388
|
const tellRefused = (report) => {
|
|
389
389
|
if (typeof live.refused !== "function")
|
|
390
390
|
return;
|
|
391
|
-
|
|
391
|
+
ruleTested("connection.refused.rejected");
|
|
392
392
|
try {
|
|
393
393
|
live.refused(report);
|
|
394
394
|
}
|
|
@@ -398,7 +398,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
398
398
|
};
|
|
399
399
|
const eventPayloads = new Map();
|
|
400
400
|
let capacityStated;
|
|
401
|
-
// The
|
|
401
|
+
// The test waits on events: each waiter is offered every envelope as it lands.
|
|
402
402
|
const waiters = new Set();
|
|
403
403
|
const onEnvelope = (envelope) => {
|
|
404
404
|
observeEvent(envelope, seen);
|
|
@@ -408,10 +408,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
408
408
|
tellRefused({ artefact: "event", envelopeId: typeof envelope?.id === "string" ? envelope.id : undefined, violations: broken });
|
|
409
409
|
// A diagnostic is informational to a host and a failure to a conformance run: the platform
|
|
410
410
|
// under test broke a rule the adapter relies on, and a green result must not paper over it.
|
|
411
|
-
// A reload in life has no first connection: the page is gone. The
|
|
411
|
+
// A reload in life has no first connection: the page is gone. The test keeps its first
|
|
412
412
|
// connection up while the second reads, so a platform that pushes the open task to the new
|
|
413
413
|
// client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
|
|
414
|
-
// defect is right to. That diagnostic is the
|
|
414
|
+
// defect is right to. That diagnostic is the test's own artefact, and is not counted while the
|
|
415
415
|
// second adapter is up; every other moment it is.
|
|
416
416
|
if (isRecord(envelope?.event) && envelope.event.type === "diagnostic") {
|
|
417
417
|
violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
|
|
@@ -469,7 +469,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
469
469
|
waiter(envelope);
|
|
470
470
|
};
|
|
471
471
|
unsubscribe = connection.subscribe(onEnvelope);
|
|
472
|
-
// A host reload is the first client dying and a second coming up in its place. The
|
|
472
|
+
// A host reload is the first client dying and a second coming up in its place. The test hands
|
|
473
473
|
// the run over: the first connection is unsubscribed, disconnected and its session closed before
|
|
474
474
|
// the second connects, and the run continues on the second -- so there is no first client for a
|
|
475
475
|
// platform to push the open task back to, and nothing to exempt.
|
|
@@ -519,8 +519,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
519
519
|
// holds its connect snapshot to no team until it is sent again, after its capacity.
|
|
520
520
|
leadFeaturesOn = false;
|
|
521
521
|
const restated = await second.setCapacity({ count: capacityStated ?? 1 });
|
|
522
|
-
violations.push(...validateResult(restated, "setCapacity", "
|
|
523
|
-
await switchOnLeadFeatures(second, "
|
|
522
|
+
violations.push(...validateResult(restated, "setCapacity", "test.reload.setCapacity"));
|
|
523
|
+
await switchOnLeadFeatures(second, "test.reload");
|
|
524
524
|
},
|
|
525
525
|
};
|
|
526
526
|
// Every snapshot the run reads, the connect snapshot and a reloaded client's, is read the same
|
|
@@ -578,7 +578,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
578
578
|
continue;
|
|
579
579
|
}
|
|
580
580
|
// Evaluated where a superseded event was held: with none, the question was never asked.
|
|
581
|
-
|
|
581
|
+
ruleTested("snapshot.accounts.task", "snapshot.accounts.ended");
|
|
582
582
|
if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.assignmentId === "string" && !carried.has(event.task.assignmentId)) {
|
|
583
583
|
violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
|
|
584
584
|
message: `${String(event.task.assignmentId)} (${event.task.assignmentId}) was published while the snapshot was read and the snapshot does not carry it: a snapshot accounts for everything the adapter emitted before it resolved` });
|
|
@@ -596,7 +596,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
596
596
|
// ready, and on every change. An adapter that never asked cannot have.
|
|
597
597
|
// The obligation has two halves: read the report before declaring the agent ready, and hear of
|
|
598
598
|
// every change after. An adapter that did one and not the other consulted the host once, or never.
|
|
599
|
-
|
|
599
|
+
ruleTested("connection.host.consulted", "connection.host.subscribed");
|
|
600
600
|
if (adapter.manifest.channel === "voice" && !consulted.report) {
|
|
601
601
|
violations.push({ rule: "connection.host.consulted", path: "connection.host",
|
|
602
602
|
message: "a voice adapter reads the host's report before it declares the agent ready to its platform, and this one never asked" });
|
|
@@ -642,8 +642,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
642
642
|
if (switched === undefined)
|
|
643
643
|
return;
|
|
644
644
|
violations.push(...validateResult(switched, "executeTeam", `${at}.executeTeam`));
|
|
645
|
-
|
|
646
|
-
const ms = options.
|
|
645
|
+
ruleTested("team.required");
|
|
646
|
+
const ms = options.timeoutMs ?? 5000;
|
|
647
647
|
const isBaseline = (envelope) => isRecord(envelope?.event) && envelope.event.type === "team-updated";
|
|
648
648
|
const baseline = events.slice(from).some(isBaseline) || await new Promise(resolve => {
|
|
649
649
|
const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, ms);
|
|
@@ -661,19 +661,19 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
661
661
|
message: `the application switched the team feature on and no team-updated followed within ${ms}ms: the whole team is owed once it is on, members: [] when nobody is in it` });
|
|
662
662
|
};
|
|
663
663
|
await switchOnLeadFeatures(live, "connection");
|
|
664
|
-
if (options.
|
|
664
|
+
if (options.withCall) {
|
|
665
665
|
const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
|
|
666
666
|
? first.audio.input.localAudio : undefined;
|
|
667
|
-
violations.push(...await
|
|
667
|
+
violations.push(...await testOneCall({
|
|
668
668
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
669
669
|
everHeldForTask: (taskId) => [...watched.everHeld].some(key => namesTask(key, taskId)), snapshotRead,
|
|
670
|
-
timeoutMs: options.
|
|
670
|
+
timeoutMs: options.timeoutMs ?? 5000,
|
|
671
671
|
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
|
|
672
672
|
streams: { stream, breaks, team },
|
|
673
673
|
}));
|
|
674
|
-
// Every offer is owed an ending. The
|
|
674
|
+
// Every offer is owed an ending. The test ends the call it drove; anything else the
|
|
675
675
|
// provider offered and left open is an assignment whose ending never came.
|
|
676
|
-
|
|
676
|
+
ruleTested("stream.taskOffered.unended");
|
|
677
677
|
for (const id of stream.unended()) {
|
|
678
678
|
violations.push({ rule: "stream.taskOffered.unended", path: "event.task-ended",
|
|
679
679
|
message: `${id} was offered and never ended: every offer is owed a task-ended, whatever became of the call` });
|
|
@@ -750,10 +750,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
750
750
|
events: events,
|
|
751
751
|
authenticationState: authenticationState,
|
|
752
752
|
login: (login ?? authenticationState),
|
|
753
|
-
|
|
753
|
+
notTested: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
|
|
754
754
|
disconnectWasClean,
|
|
755
755
|
violations,
|
|
756
|
-
|
|
756
|
+
rulesTested,
|
|
757
757
|
};
|
|
758
758
|
}
|
|
759
759
|
/**
|
|
@@ -1036,7 +1036,7 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
|
|
|
1036
1036
|
* so a fixture that never produced a team member list cannot pass a test that meant to check one.
|
|
1037
1037
|
*/
|
|
1038
1038
|
export function assertReached(result, subjects) {
|
|
1039
|
-
const missed = subjects.filter(subject => result.
|
|
1039
|
+
const missed = subjects.filter(subject => result.notTested.includes(subject));
|
|
1040
1040
|
if (missed.length > 0) {
|
|
1041
1041
|
throw new Error(`The exercise never reached ${missed.join(", ")}: its clean result says nothing about them`);
|
|
1042
1042
|
}
|
|
@@ -1139,6 +1139,36 @@ export class TaskStream {
|
|
|
1139
1139
|
endedAssignments = new Set();
|
|
1140
1140
|
/** Every assignment a `task-offered` introduced, as against one a snapshot carried: each is owed its ending. */
|
|
1141
1141
|
offered = new Set();
|
|
1142
|
+
/** The last seconds-left value the stream read per task and field, with the provider instant it was published at. */
|
|
1143
|
+
countdowns = new Map();
|
|
1144
|
+
/**
|
|
1145
|
+
* A seconds-left value is the provider's arithmetic at the instant of the publication, never a
|
|
1146
|
+
* value copied from an earlier one: between two publications the countdown loses at least the
|
|
1147
|
+
* seconds the provider's own clock says passed, within a second of rounding.
|
|
1148
|
+
*/
|
|
1149
|
+
holdCountdowns(id, task, publishedAt, at, refuse) {
|
|
1150
|
+
const atMs = typeof publishedAt === "string" ? Date.parse(publishedAt) : Number.NaN;
|
|
1151
|
+
if (!isRecord(task) || Number.isNaN(atMs))
|
|
1152
|
+
return;
|
|
1153
|
+
const known = this.countdowns.get(id) ?? new Map();
|
|
1154
|
+
for (const field of ["expiresInSeconds", "previewEndsInSeconds", "wrapEndsInSeconds"]) {
|
|
1155
|
+
const value = task[field];
|
|
1156
|
+
if (typeof value !== "number") {
|
|
1157
|
+
known.delete(field);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
const before = known.get(field);
|
|
1161
|
+
if (before !== undefined) {
|
|
1162
|
+
ruleTested("stream.countdown.copied");
|
|
1163
|
+
const elapsed = Math.floor((atMs - before.atMs) / 1000);
|
|
1164
|
+
if (elapsed > 0 && value > before.value - elapsed + 1) {
|
|
1165
|
+
refuse("stream.countdown.copied", `${at}.${field}`, `${id} said ${field} ${before.value} at ${new Date(before.atMs).toISOString()} and ${value} at ${new Date(atMs).toISOString()}: ${elapsed}s passed on the provider's clock and the countdown lost fewer; seconds left are worked out at each publication, never copied from the one before`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
known.set(field, { value, atMs });
|
|
1169
|
+
}
|
|
1170
|
+
this.countdowns.set(id, known);
|
|
1171
|
+
}
|
|
1142
1172
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
1143
1173
|
// task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
|
|
1144
1174
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
@@ -1235,11 +1265,16 @@ export class TaskStream {
|
|
|
1235
1265
|
resync(snapshot, at) {
|
|
1236
1266
|
const found = [];
|
|
1237
1267
|
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
1238
|
-
|
|
1268
|
+
ruleTested("stream.snapshot.capabilitySource", "stream.snapshot.history", "stream.snapshot.phase", "stream.snapshot.audio");
|
|
1239
1269
|
if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
|
|
1240
1270
|
snapshot.tasks.forEach((task, index) => {
|
|
1241
1271
|
if (!isRecord(task) || typeof task.assignmentId !== "string")
|
|
1242
1272
|
return;
|
|
1273
|
+
// A snapshot from a provider with a clock is a publication at providerTime; the countdowns it carries are held to it.
|
|
1274
|
+
if (typeof snapshot.providerTime === "string")
|
|
1275
|
+
this.holdCountdowns(task.assignmentId, task, snapshot.providerTime, `${at}.tasks[${index}]`, refuse);
|
|
1276
|
+
else
|
|
1277
|
+
this.countdowns.delete(task.assignmentId);
|
|
1243
1278
|
const was = this.tasks.get(task.assignmentId);
|
|
1244
1279
|
if (was === undefined)
|
|
1245
1280
|
return;
|
|
@@ -1296,7 +1331,7 @@ export class TaskStream {
|
|
|
1296
1331
|
found.push(...this.resync(event.snapshot, `${at}.snapshot`));
|
|
1297
1332
|
break;
|
|
1298
1333
|
case "task-offered": {
|
|
1299
|
-
|
|
1334
|
+
ruleTested("stream.taskOffered.duplicate");
|
|
1300
1335
|
if (id === undefined)
|
|
1301
1336
|
break;
|
|
1302
1337
|
// An assignment is introduced once: not while it is open, and never again once it has ended,
|
|
@@ -1308,18 +1343,20 @@ export class TaskStream {
|
|
|
1308
1343
|
this.offered.add(id);
|
|
1309
1344
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
1310
1345
|
this.noteDials(event.task);
|
|
1346
|
+
this.holdCountdowns(id, event.task, isRecord(envelope) ? envelope.occurredAt : undefined, `${at}.task`, refuse);
|
|
1311
1347
|
break;
|
|
1312
1348
|
}
|
|
1313
1349
|
case "task-updated":
|
|
1314
1350
|
if (id === undefined)
|
|
1315
1351
|
break;
|
|
1316
|
-
|
|
1352
|
+
ruleTested("stream.taskUpdated.unknown");
|
|
1317
1353
|
if (known === undefined) {
|
|
1318
1354
|
refuse("stream.taskUpdated.unknown", `${at}.task.assignmentId`, `${id} was never offered or carried on a snapshot`);
|
|
1319
1355
|
break;
|
|
1320
1356
|
}
|
|
1321
1357
|
// The rules about a known task are evaluated only once there is one.
|
|
1322
|
-
|
|
1358
|
+
this.holdCountdowns(id, event.task, isRecord(envelope) ? envelope.occurredAt : undefined, `${at}.task`, refuse);
|
|
1359
|
+
ruleTested("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.audioOpen", "stream.taskUpdated.history", "stream.taskAudioEnded.follow", "stream.taskUpdated.audio", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
|
|
1323
1360
|
// Terms once read stay read. A re-read that fails is not a new fact about the task, so the
|
|
1324
1361
|
// last statement stands and the failure is a diagnostic; not-yet-read is a place a task
|
|
1325
1362
|
// starts from, never one it returns to.
|
|
@@ -1351,7 +1388,7 @@ export class TaskStream {
|
|
|
1351
1388
|
}
|
|
1352
1389
|
// A task completes after its audio ends, never around it: an update that moves a task to
|
|
1353
1390
|
// completing while the stream holds its audio as started is a call whose audio never ended,
|
|
1354
|
-
// whoever caused the ending -- the
|
|
1391
|
+
// whoever caused the ending -- the test's end-call, a take-over, the provider's own hand.
|
|
1355
1392
|
{
|
|
1356
1393
|
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1357
1394
|
if (to === "completing" && known.audio === "started" && known.phase !== "completing") {
|
|
@@ -1391,7 +1428,7 @@ export class TaskStream {
|
|
|
1391
1428
|
this.noteDials(event.task);
|
|
1392
1429
|
break;
|
|
1393
1430
|
case "task-audio-started":
|
|
1394
|
-
|
|
1431
|
+
ruleTested("stream.taskAudioStarted.beforeWork", "stream.taskAudioStarted.duplicate", "stream.taskAudio.channel", "stream.assignment.ended", "stream.assignment.unknown");
|
|
1395
1432
|
if (id === undefined)
|
|
1396
1433
|
break;
|
|
1397
1434
|
if (known === undefined) {
|
|
@@ -1413,7 +1450,7 @@ export class TaskStream {
|
|
|
1413
1450
|
known.audio = "started";
|
|
1414
1451
|
break;
|
|
1415
1452
|
case "task-audio-ended":
|
|
1416
|
-
|
|
1453
|
+
ruleTested("stream.taskAudioEnded.beforeWork", "stream.taskAudioEnded.silent", "stream.taskAudio.channel", "stream.assignment.ended", "stream.assignment.unknown");
|
|
1417
1454
|
if (id === undefined)
|
|
1418
1455
|
break;
|
|
1419
1456
|
if (known === undefined) {
|
|
@@ -1433,7 +1470,7 @@ export class TaskStream {
|
|
|
1433
1470
|
known.audio = "ended";
|
|
1434
1471
|
break;
|
|
1435
1472
|
case "task-ended":
|
|
1436
|
-
|
|
1473
|
+
ruleTested("stream.assignment.ended", "stream.assignment.unknown");
|
|
1437
1474
|
if (id === undefined)
|
|
1438
1475
|
break;
|
|
1439
1476
|
if (known === undefined) {
|
|
@@ -1443,7 +1480,7 @@ export class TaskStream {
|
|
|
1443
1480
|
// A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
|
|
1444
1481
|
// and a lead leaving included. An ending with the audio still up leaves the host holding an
|
|
1445
1482
|
// open audio session and an open leg on a task that no longer exists.
|
|
1446
|
-
|
|
1483
|
+
ruleTested("stream.taskEnded.audioOpen");
|
|
1447
1484
|
if (known.audio === "started") {
|
|
1448
1485
|
refuse("stream.taskEnded.audioOpen", `${at}.assignmentId`, `${id} ended with its audio still started: the audio ends first, on task-audio-ended, whatever the outcome`);
|
|
1449
1486
|
}
|
|
@@ -1451,7 +1488,7 @@ export class TaskStream {
|
|
|
1451
1488
|
// provider's word that interaction ended and the moment the wrap allowance starts. A provider
|
|
1452
1489
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1453
1490
|
// completed from in-progress, the allowance it stated was never given.
|
|
1454
|
-
|
|
1491
|
+
ruleTested("stream.taskEnded.unwrapped");
|
|
1455
1492
|
// An agent's own complete cuts the wrap short from wherever the task stands, so that ending says by agent and is not this.
|
|
1456
1493
|
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1457
1494
|
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed" && event.outcome.by === "provider") {
|
|
@@ -1464,7 +1501,7 @@ export class TaskStream {
|
|
|
1464
1501
|
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
1465
1502
|
// a dial placed late routinely outlives its call, which is why the dial has its own identity,
|
|
1466
1503
|
// and why the outcome names the assignment: the host routes it to that life, ended or not.
|
|
1467
|
-
|
|
1504
|
+
ruleTested("stream.assignment.unknown");
|
|
1468
1505
|
if (typeof event.assignmentId === "string" && !this.assignments.has(event.assignmentId)) {
|
|
1469
1506
|
refuse("stream.assignment.unknown", `${at}.assignmentId`, `${event.assignmentId} is not an assignment this stream has seen`);
|
|
1470
1507
|
}
|
|
@@ -1490,24 +1527,24 @@ export class TaskStream {
|
|
|
1490
1527
|
* One ordinary lifecycle on the first task offered, each step held to the rules a host holds a
|
|
1491
1528
|
* provider to: the command validated against the task as published, the result validated for the
|
|
1492
1529
|
* method, and the event the provider owes in return awaited -- a step that never arrives is a
|
|
1493
|
-
* violation naming what was owed. The
|
|
1530
|
+
* violation naming what was owed. The test stops where the task offers no way on (no `endCall`,
|
|
1494
1531
|
* a provider that completes for itself) and says nothing about what it could not reach.
|
|
1495
1532
|
*/
|
|
1496
|
-
async function
|
|
1533
|
+
async function testOneCall(call) {
|
|
1497
1534
|
const found = [];
|
|
1498
1535
|
const refuse = (rule, path, message) => found.push({ rule, path, message });
|
|
1499
1536
|
const isTask = (value) => isRecord(value) && typeof value.assignmentId === "string";
|
|
1500
1537
|
// Wait for an envelope that satisfies `matches`, looking first at what already arrived.
|
|
1501
|
-
const waitFor = (what, matches, from, within = { ms:
|
|
1502
|
-
for (let index = from; index <
|
|
1503
|
-
const hit = matches(
|
|
1538
|
+
const waitFor = (what, matches, from, within = { ms: call.timeoutMs, onExpiry: () => refuse("test.timeout", "test", `the provider owed ${what} within ${call.timeoutMs}ms and it never arrived`) }) => new Promise(resolve => {
|
|
1539
|
+
for (let index = from; index < call.events.length; index += 1) {
|
|
1540
|
+
const hit = matches(call.events[index]);
|
|
1504
1541
|
if (hit !== undefined) {
|
|
1505
1542
|
resolve({ found: hit, at: index + 1 });
|
|
1506
1543
|
return;
|
|
1507
1544
|
}
|
|
1508
1545
|
}
|
|
1509
1546
|
const timer = setTimeout(() => {
|
|
1510
|
-
|
|
1547
|
+
call.waiters.delete(waiter);
|
|
1511
1548
|
within.onExpiry();
|
|
1512
1549
|
resolve(undefined);
|
|
1513
1550
|
}, within.ms);
|
|
@@ -1516,45 +1553,45 @@ async function driveOneCall(drive) {
|
|
|
1516
1553
|
if (hit === undefined)
|
|
1517
1554
|
return;
|
|
1518
1555
|
clearTimeout(timer);
|
|
1519
|
-
|
|
1520
|
-
resolve({ found: hit, at:
|
|
1556
|
+
call.waiters.delete(waiter);
|
|
1557
|
+
resolve({ found: hit, at: call.events.length });
|
|
1521
1558
|
};
|
|
1522
|
-
|
|
1559
|
+
call.waiters.add(waiter);
|
|
1523
1560
|
});
|
|
1524
1561
|
// The call's audio session, opened on the first client and opened again on a reloaded second.
|
|
1525
1562
|
let session;
|
|
1526
1563
|
const recordSurvivesReload = async (at) => {
|
|
1527
1564
|
// Each reload rule is marked where it is decided, never up front: a rebuild that throws has looked at nothing else.
|
|
1528
|
-
|
|
1565
|
+
ruleTested("test.reload.rejected");
|
|
1529
1566
|
let again;
|
|
1530
1567
|
try {
|
|
1531
|
-
again =
|
|
1568
|
+
again = call.rebuild();
|
|
1532
1569
|
}
|
|
1533
1570
|
catch (error) {
|
|
1534
|
-
refuse("
|
|
1571
|
+
refuse("test.reload.rejected", "test.reload", `building the adapter again threw: ${String(error)}`);
|
|
1535
1572
|
return;
|
|
1536
1573
|
}
|
|
1537
1574
|
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
1538
|
-
|
|
1539
|
-
if (again.manifest.id !==
|
|
1540
|
-
refuse("
|
|
1575
|
+
ruleTested("test.reload.manifest");
|
|
1576
|
+
if (again.manifest.id !== call.manifest.id) {
|
|
1577
|
+
refuse("test.reload.manifest", "test.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(call.manifest.id)}`);
|
|
1541
1578
|
return;
|
|
1542
1579
|
}
|
|
1543
1580
|
// The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
|
|
1544
|
-
|
|
1545
|
-
if (!await
|
|
1546
|
-
refuse("
|
|
1581
|
+
ruleTested("test.reload.handover");
|
|
1582
|
+
if (!await call.handOver.down()) {
|
|
1583
|
+
refuse("test.reload.handover", "test.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
|
|
1547
1584
|
}
|
|
1548
1585
|
try {
|
|
1549
|
-
const restoredSession = await again.createAuthenticationSession({ ...
|
|
1586
|
+
const restoredSession = await again.createAuthenticationSession({ ...call.context, secrets: call.secrets });
|
|
1550
1587
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1551
1588
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1552
1589
|
const restored = await restoredSession.state();
|
|
1553
|
-
found.push(...validateAuthenticationState(restored, "
|
|
1554
|
-
|
|
1555
|
-
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id ===
|
|
1590
|
+
found.push(...validateAuthenticationState(restored, "test.reload.login"));
|
|
1591
|
+
ruleTested("test.reload.login");
|
|
1592
|
+
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === call.reader().self;
|
|
1556
1593
|
if (!same) {
|
|
1557
|
-
refuse("
|
|
1594
|
+
refuse("test.reload.login", "test.reload.login", `a second adapter built from the same login and secrets did not restore it: the session says ${String(isRecord(restored) ? restored.status : restored)}${isRecord(restored) && isRecord(restored.identity) ? ` as ${String(restored.identity.id)}` : ""}, and the login is ${String(call.reader().self)}`);
|
|
1558
1595
|
// A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
|
|
1559
1596
|
try {
|
|
1560
1597
|
await restoredSession.close();
|
|
@@ -1562,27 +1599,27 @@ async function driveOneCall(drive) {
|
|
|
1562
1599
|
catch { /* the session that would not restore is closed as far as it can be */ }
|
|
1563
1600
|
return;
|
|
1564
1601
|
}
|
|
1565
|
-
const second = await again.connect(
|
|
1602
|
+
const second = await again.connect(call.context);
|
|
1566
1603
|
// From here the second client is the run's connection, held to everything the first was on
|
|
1567
1604
|
// connect: what it publishes is validated as before.
|
|
1568
|
-
await
|
|
1569
|
-
|
|
1605
|
+
await call.handOver.up(restoredSession, second);
|
|
1606
|
+
call.connection = second;
|
|
1570
1607
|
const snapshot = await second.snapshot();
|
|
1571
1608
|
// The second adapter's snapshot is read as the connect snapshot was, and the streams take it
|
|
1572
1609
|
// as the state now the way they take any resync: held to what they knew, then replaced. A reload
|
|
1573
1610
|
// is a place the stream's rules keep working, not one where they all stop.
|
|
1574
|
-
await
|
|
1575
|
-
found.push(...
|
|
1576
|
-
found.push(...
|
|
1577
|
-
|
|
1611
|
+
await call.snapshotRead(second, snapshot, "test.reload.snapshot");
|
|
1612
|
+
found.push(...call.streams.stream.resync(snapshot, "test.reload.snapshot"));
|
|
1613
|
+
found.push(...call.streams.breaks.seed(snapshot));
|
|
1614
|
+
call.streams.team.seed(snapshot);
|
|
1578
1615
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1579
1616
|
? snapshot.tasks.find(task => isRecord(task) && task.assignmentId === taskId) : undefined;
|
|
1580
|
-
|
|
1617
|
+
ruleTested("test.reload.snapshot");
|
|
1581
1618
|
if (carried === undefined) {
|
|
1582
|
-
refuse("
|
|
1619
|
+
refuse("test.reload.snapshot", "test.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
|
|
1583
1620
|
return;
|
|
1584
1621
|
}
|
|
1585
|
-
|
|
1622
|
+
ruleTested("test.reload.history");
|
|
1586
1623
|
const history = carried.history;
|
|
1587
1624
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1588
1625
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
@@ -1591,41 +1628,41 @@ async function driveOneCall(drive) {
|
|
|
1591
1628
|
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
1592
1629
|
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
1593
1630
|
if (lost.length > 0) {
|
|
1594
|
-
refuse("
|
|
1631
|
+
refuse("test.reload.history", "test.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`);
|
|
1595
1632
|
}
|
|
1596
1633
|
const leg = steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1597
1634
|
if (leg === undefined) {
|
|
1598
|
-
refuse("
|
|
1635
|
+
refuse("test.reload.history", "test.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`);
|
|
1599
1636
|
}
|
|
1600
1637
|
else if (leg.mutedBy !== "host") {
|
|
1601
|
-
refuse("
|
|
1638
|
+
refuse("test.reload.history", "test.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
|
|
1602
1639
|
}
|
|
1603
|
-
// The
|
|
1640
|
+
// The test goes on with the task as the reloaded client holds it. The first client's audio
|
|
1604
1641
|
// session died with it: a host opens audio on a task arriving with audio started on a
|
|
1605
|
-
// snapshot, so the
|
|
1642
|
+
// snapshot, so the test opens it again on the second and holds the answer as it held the first.
|
|
1606
1643
|
task = carried;
|
|
1607
|
-
if (
|
|
1644
|
+
if (call.softphone && carried.audio === "started") {
|
|
1608
1645
|
if (session !== undefined && typeof session.close === "function") {
|
|
1609
1646
|
try {
|
|
1610
1647
|
session.close();
|
|
1611
1648
|
}
|
|
1612
1649
|
catch {
|
|
1613
|
-
refuse("
|
|
1650
|
+
refuse("test.openAudio.close", "test.openAudio", "the first client's audio session threw on close");
|
|
1614
1651
|
}
|
|
1615
1652
|
}
|
|
1616
1653
|
session = undefined;
|
|
1617
|
-
|
|
1618
|
-
const reopened = await second.openAudio?.({ assignmentId: taskId, localAudio:
|
|
1619
|
-
const malformed = validateResult(reopened, "openAudio", "
|
|
1654
|
+
ruleTested("test.reload.openAudio");
|
|
1655
|
+
const reopened = await second.openAudio?.({ assignmentId: taskId, localAudio: call.localAudio });
|
|
1656
|
+
const malformed = validateResult(reopened, "openAudio", "test.reload.openAudio");
|
|
1620
1657
|
found.push(...malformed);
|
|
1621
1658
|
if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
|
|
1622
1659
|
session = reopened.audio;
|
|
1623
1660
|
else if (malformed.length === 0)
|
|
1624
|
-
refuse("
|
|
1661
|
+
refuse("test.reload.openAudio", "test.reload.openAudio", `a second adapter built from the same login could not open the audio of ${taskId}, which its own snapshot carries with audio started`);
|
|
1625
1662
|
}
|
|
1626
1663
|
}
|
|
1627
1664
|
catch (error) {
|
|
1628
|
-
refuse("
|
|
1665
|
+
refuse("test.reload.rejected", "test.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
1629
1666
|
}
|
|
1630
1667
|
};
|
|
1631
1668
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
@@ -1638,28 +1675,28 @@ async function driveOneCall(drive) {
|
|
|
1638
1675
|
// A task's key names the task delimited, not as a run of characters inside another id, and the
|
|
1639
1676
|
// rule is evaluated only where the store was ever seen to hold such a key: an adapter whose keys
|
|
1640
1677
|
// name nothing the harness can see leaves the rule unasked, which the result says.
|
|
1641
|
-
const retained = [...
|
|
1678
|
+
const retained = [...call.held].filter(key => namesTask(key, taskId));
|
|
1642
1679
|
retainedAtEnd = new Set(retained);
|
|
1643
|
-
if (
|
|
1644
|
-
|
|
1680
|
+
if (call.everHeldForTask(taskId))
|
|
1681
|
+
ruleTested("test.store.retained");
|
|
1645
1682
|
if (retained.length > 0) {
|
|
1646
|
-
refuse("
|
|
1683
|
+
refuse("test.store.retained", "test.store", `${taskId} has ended and the login's store still holds ${retained.join(", ")}: a task's keys go with the task, or the next offer of the same id inherits them`);
|
|
1647
1684
|
}
|
|
1648
1685
|
};
|
|
1649
1686
|
// A key written about the task after its end -- a persist hung off a timer, a late command -- is the
|
|
1650
|
-
// same hazard arriving later, so the store is read once more when the
|
|
1687
|
+
// same hazard arriving later, so the store is read once more when the test is done.
|
|
1651
1688
|
const nothingLate = () => {
|
|
1652
1689
|
if (retainedAtEnd === undefined)
|
|
1653
1690
|
return;
|
|
1654
|
-
|
|
1655
|
-
const late = [...
|
|
1691
|
+
ruleTested("test.store.late");
|
|
1692
|
+
const late = [...call.held].filter(key => namesTask(key, taskId) && !retainedAtEnd.has(key));
|
|
1656
1693
|
if (late.length > 0) {
|
|
1657
|
-
refuse("
|
|
1694
|
+
refuse("test.store.late", "test.store", `${taskId} had ended with its keys gone, and the login's store now holds ${late.join(", ")}: something wrote about the task after its end`);
|
|
1658
1695
|
}
|
|
1659
1696
|
};
|
|
1660
1697
|
// The task, as first seen: on the snapshot, or on the first offer.
|
|
1661
1698
|
let cursor = 0;
|
|
1662
|
-
let task = isRecord(
|
|
1699
|
+
let task = isRecord(call.snapshot) && Array.isArray(call.snapshot.tasks) ? call.snapshot.tasks.find(isTask) : undefined;
|
|
1663
1700
|
if (task === undefined) {
|
|
1664
1701
|
const offered = await waitFor("a task-offered", envelope => {
|
|
1665
1702
|
const event = envelope.event;
|
|
@@ -1670,46 +1707,46 @@ async function driveOneCall(drive) {
|
|
|
1670
1707
|
task = offered.found;
|
|
1671
1708
|
cursor = offered.at;
|
|
1672
1709
|
}
|
|
1673
|
-
|
|
1710
|
+
let taskId = task.assignmentId;
|
|
1674
1711
|
const latestTask = () => task;
|
|
1675
|
-
const updated = (until, what) => waitFor(what, envelope => {
|
|
1712
|
+
const updated = (until, what, within) => waitFor(what, envelope => {
|
|
1676
1713
|
const event = envelope.event;
|
|
1677
1714
|
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.assignmentId === taskId) {
|
|
1678
1715
|
task = event.task;
|
|
1679
1716
|
return until(event.task) ? event.task : undefined;
|
|
1680
1717
|
}
|
|
1681
1718
|
return undefined;
|
|
1682
|
-
}, cursor).then(hit => { if (hit)
|
|
1719
|
+
}, cursor, within).then(hit => { if (hit)
|
|
1683
1720
|
cursor = hit.at; return hit; });
|
|
1684
|
-
const ended = (within) => waitFor("a task-ended for the
|
|
1721
|
+
const ended = (within) => waitFor("a task-ended for the tested task", envelope => {
|
|
1685
1722
|
const event = envelope.event;
|
|
1686
1723
|
return event.type === "task-ended" && event.assignmentId === taskId ? event : undefined;
|
|
1687
1724
|
}, cursor, within);
|
|
1688
|
-
// Every command the
|
|
1689
|
-
|
|
1725
|
+
// Every command the test sends is validated against the task as published, and its answer for its method.
|
|
1726
|
+
ruleTested("test.timeout");
|
|
1690
1727
|
const send = async (command, dialId) => {
|
|
1691
|
-
const own = validateTaskCommand(command, latestTask(), `
|
|
1692
|
-
levels: effectiveLevels(
|
|
1693
|
-
dialOutcomesDeclared:
|
|
1694
|
-
calendarDeclared:
|
|
1695
|
-
autoAcceptTasks:
|
|
1728
|
+
const own = validateTaskCommand(command, latestTask(), `test.command.${String(command.type)}`, {
|
|
1729
|
+
levels: effectiveLevels(call.manifest.orgLevels).map(level => level.id),
|
|
1730
|
+
dialOutcomesDeclared: call.manifest.dialOutcomes !== undefined,
|
|
1731
|
+
calendarDeclared: call.manifest.idleCapabilities?.calendar === true,
|
|
1732
|
+
autoAcceptTasks: call.context.autoAcceptTasks,
|
|
1696
1733
|
});
|
|
1697
1734
|
found.push(...own);
|
|
1698
1735
|
if (own.length > 0)
|
|
1699
1736
|
return undefined;
|
|
1700
|
-
|
|
1737
|
+
ruleTested("test.command.rejected");
|
|
1701
1738
|
let result;
|
|
1702
1739
|
try {
|
|
1703
|
-
result = await
|
|
1740
|
+
result = await call.connection.execute({ assignmentId: taskId, command });
|
|
1704
1741
|
}
|
|
1705
1742
|
catch (error) {
|
|
1706
|
-
refuse("
|
|
1743
|
+
refuse("test.command.rejected", `test.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
1707
1744
|
return undefined;
|
|
1708
1745
|
}
|
|
1709
|
-
|
|
1710
|
-
found.push(...validateResult(result, "execute", `
|
|
1746
|
+
ruleTested("test.command.failed");
|
|
1747
|
+
found.push(...validateResult(result, "execute", `test.command.${String(command.type)}.result`, dialId));
|
|
1711
1748
|
if (isRecord(result) && result.status === "failed") {
|
|
1712
|
-
refuse("
|
|
1749
|
+
refuse("test.command.failed", `test.command.${String(command.type)}`, `the provider refused ${String(command.type)} on a task that offered it: ${String(isRecord(result.failure) ? result.failure.code : result.failure)}`);
|
|
1713
1750
|
return undefined;
|
|
1714
1751
|
}
|
|
1715
1752
|
return isRecord(result) ? result : undefined;
|
|
@@ -1721,39 +1758,107 @@ async function driveOneCall(drive) {
|
|
|
1721
1758
|
};
|
|
1722
1759
|
// Audio may arrive any time after the accept, before or after the task's own update says
|
|
1723
1760
|
// in-progress: the event is the provider's word that the audio should attach, never a reply to
|
|
1724
|
-
// openAudio, so the
|
|
1725
|
-
|
|
1761
|
+
// openAudio, so the test looks for it from the accept rather than from the last update it read.
|
|
1762
|
+
// 0. A deadline stated is a deadline kept. Where the first offer says how long it has, within
|
|
1763
|
+
// what the test will wait, the test leaves it alone and expects the provider to end it expired
|
|
1764
|
+
// when it runs out; the next offer is the one tested. Offers with no deadline, or a longer one,
|
|
1765
|
+
// leave this rule unreached, and the result says so.
|
|
1766
|
+
const settleMs = Number(call.manifest.settleMs);
|
|
1767
|
+
const lapse = latestTask().phase === "pending" ? latestTask().expiresInSeconds : undefined;
|
|
1768
|
+
if (typeof lapse === "number" && lapse * 1000 <= call.timeoutMs) {
|
|
1769
|
+
ruleTested("test.offer.expired");
|
|
1770
|
+
const lapsedId = taskId;
|
|
1771
|
+
let missed = false;
|
|
1772
|
+
const ending = await waitFor(`the expired ending of ${lapsedId}`, envelope => {
|
|
1773
|
+
const event = envelope.event;
|
|
1774
|
+
return event.type === "task-ended" && event.assignmentId === lapsedId ? event : undefined;
|
|
1775
|
+
}, cursor, { ms: lapse * 1000 + settleMs, onExpiry: () => { missed = true; } });
|
|
1776
|
+
if (ending === undefined) {
|
|
1777
|
+
if (missed)
|
|
1778
|
+
refuse("test.offer.expired", "test.offer", `${lapsedId} was offered with ${lapse}s to answer and ${lapse * 1000 + settleMs}ms later it has not ended: a deadline stated is a deadline kept, and the offer ends expired naming pending`);
|
|
1779
|
+
return found;
|
|
1780
|
+
}
|
|
1781
|
+
const outcome = isRecord(ending.found.outcome) ? ending.found.outcome : {};
|
|
1782
|
+
if (!(outcome.type === "expired" && outcome.phase === "pending")) {
|
|
1783
|
+
refuse("test.offer.expired", "test.offer", `${lapsedId} lapsed unanswered and ended ${String(outcome.type)}: an offer nobody answered before its deadline ends expired naming pending`);
|
|
1784
|
+
}
|
|
1785
|
+
// The next offer is the one tested; a provider with nothing more to offer ends the test here.
|
|
1786
|
+
const next = await waitFor("a task-offered after the lapsed one", envelope => {
|
|
1787
|
+
const event = envelope.event;
|
|
1788
|
+
return event.type === "task-offered" && isTask(event.task) && event.task.assignmentId !== lapsedId ? event.task : undefined;
|
|
1789
|
+
}, ending.at);
|
|
1790
|
+
if (next === undefined)
|
|
1791
|
+
return found;
|
|
1792
|
+
task = next.found;
|
|
1793
|
+
cursor = next.at;
|
|
1794
|
+
taskId = task.assignmentId;
|
|
1795
|
+
}
|
|
1796
|
+
const acceptedAt = call.events.length;
|
|
1726
1797
|
// 1. Accept the offer, if it is one.
|
|
1727
1798
|
if (latestTask().phase === "pending") {
|
|
1728
|
-
if (await send({ type:
|
|
1799
|
+
if (await send({ type: call.channel === "voice" ? "answer" : "accept" }) === undefined)
|
|
1729
1800
|
return found;
|
|
1730
1801
|
if (await updated(t => t.phase !== "pending", "the task leaving pending after it was accepted") === undefined)
|
|
1731
1802
|
return found;
|
|
1732
1803
|
}
|
|
1733
|
-
// 2. A preview: press Call, which is a dial.
|
|
1804
|
+
// 2. A preview: press Call, which is a dial. Where the preview says how long it has, within what
|
|
1805
|
+
// the test will wait, the deadline is kept to its atDeadline first: under provider-dials the
|
|
1806
|
+
// provider dials when it runs out and the test presses nothing; under host-dials and waits the
|
|
1807
|
+
// preview stands until then, and the test presses Call at zero as the desk would.
|
|
1734
1808
|
if (latestTask().phase === "preview") {
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
if (
|
|
1809
|
+
const left = latestTask().previewEndsInSeconds;
|
|
1810
|
+
const atDeadline = latestTask().atDeadline;
|
|
1811
|
+
// Under provider-dials the provider places the call, and the desk presses nothing.
|
|
1812
|
+
let pressCall = true;
|
|
1813
|
+
if (typeof left === "number" && left * 1000 <= call.timeoutMs && typeof atDeadline === "string") {
|
|
1814
|
+
ruleTested("test.preview.deadline");
|
|
1815
|
+
const ringing = (t) => t.phase !== "preview"
|
|
1816
|
+
|| (Array.isArray(t.onCall) && t.onCall.some(who => isRecord(who) && who.role === "party" && who.stage === "ringing"));
|
|
1817
|
+
if (atDeadline === "provider-dials") {
|
|
1818
|
+
let missed = false;
|
|
1819
|
+
const dialled = await updated(ringing, "the provider dialling at the preview's deadline", { ms: left * 1000 + settleMs, onExpiry: () => { missed = true; } });
|
|
1820
|
+
if (dialled === undefined) {
|
|
1821
|
+
if (!missed)
|
|
1822
|
+
return found;
|
|
1823
|
+
refuse("test.preview.deadline", "test.preview", `${taskId} is a preview under provider-dials with ${left}s left, and the provider did not dial when it ran out: the party rings on onCall, or the task leaves preview`);
|
|
1824
|
+
return found;
|
|
1825
|
+
}
|
|
1826
|
+
pressCall = false;
|
|
1827
|
+
}
|
|
1828
|
+
else {
|
|
1829
|
+
// Nothing may move the preview before its deadline under host-dials or waits: the test
|
|
1830
|
+
// waits the seconds out and expects the task where it left it.
|
|
1831
|
+
const moved = await updated(t => t.phase !== "preview", "nothing", { ms: left * 1000, onExpiry: () => undefined });
|
|
1832
|
+
if (moved !== undefined) {
|
|
1833
|
+
refuse("test.preview.deadline", "test.preview", `${taskId} is a preview under ${atDeadline} with ${left}s left, and the provider moved it to ${String(moved.found.phase)} before the deadline: under ${atDeadline} the preview stands until the desk dials, or for as long as the agent needs`);
|
|
1834
|
+
return found;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (pressCall && latestTask().phase === "preview") {
|
|
1839
|
+
const dialId = `test-${taskId}`;
|
|
1840
|
+
call.stream.dialled(dialId);
|
|
1841
|
+
if (await send({ type: "dial", dialId }, dialId) === undefined)
|
|
1842
|
+
return found;
|
|
1843
|
+
}
|
|
1844
|
+
if (latestTask().phase === "preview" && await updated(t => t.phase === "in-progress" || t.phase === "completing", pressCall ? "the task leaving preview after Call" : "the task leaving preview after the provider's dial") === undefined)
|
|
1740
1845
|
return found;
|
|
1741
1846
|
}
|
|
1742
1847
|
// The other direction of step 4, wherever the task stands outside the interaction phases with the
|
|
1743
1848
|
// control still declared: a host holds it back (command.phase.interaction), and an adapter that
|
|
1744
|
-
// receives it anyway must refuse it -- so the
|
|
1849
|
+
// receives it anyway must refuse it -- so the test sends it past the validator and expects failed.
|
|
1745
1850
|
const holdRefusedOutsideInteraction = async () => {
|
|
1746
|
-
|
|
1851
|
+
ruleTested("test.command.rejected", "test.command.interaction");
|
|
1747
1852
|
const phase = String(latestTask().phase);
|
|
1748
1853
|
let answer;
|
|
1749
1854
|
try {
|
|
1750
|
-
answer = await
|
|
1855
|
+
answer = await call.connection.execute({ assignmentId: taskId, command: { type: "hold" } });
|
|
1751
1856
|
}
|
|
1752
1857
|
catch (error) {
|
|
1753
|
-
refuse("
|
|
1858
|
+
refuse("test.command.rejected", "test.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
1754
1859
|
}
|
|
1755
1860
|
if (isRecord(answer) && answer.status !== "failed") {
|
|
1756
|
-
refuse("
|
|
1861
|
+
refuse("test.command.interaction", "test.command.hold", `the provider applied hold on a ${phase} task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
|
|
1757
1862
|
}
|
|
1758
1863
|
};
|
|
1759
1864
|
if (latestTask().phase === "confirmed") {
|
|
@@ -1763,9 +1868,9 @@ async function driveOneCall(drive) {
|
|
|
1763
1868
|
return found;
|
|
1764
1869
|
}
|
|
1765
1870
|
// 3. On a softphone, the audio arrives and the host opens it.
|
|
1766
|
-
if (
|
|
1871
|
+
if (call.softphone && latestTask().phase === "in-progress") {
|
|
1767
1872
|
if (latestTask().audio !== "started") {
|
|
1768
|
-
const started = await waitFor("task-audio-started for the
|
|
1873
|
+
const started = await waitFor("task-audio-started for the tested task", envelope => {
|
|
1769
1874
|
const event = envelope.event;
|
|
1770
1875
|
return event.type === "task-audio-started" && event.assignmentId === taskId ? event : undefined;
|
|
1771
1876
|
}, acceptedAt);
|
|
@@ -1773,15 +1878,15 @@ async function driveOneCall(drive) {
|
|
|
1773
1878
|
return found;
|
|
1774
1879
|
cursor = Math.max(cursor, started.at);
|
|
1775
1880
|
}
|
|
1776
|
-
const opened = await
|
|
1777
|
-
const malformed = validateResult(opened, "openAudio", "
|
|
1881
|
+
const opened = await call.connection.openAudio?.({ assignmentId: taskId, localAudio: call.localAudio });
|
|
1882
|
+
const malformed = validateResult(opened, "openAudio", "test.openAudio");
|
|
1778
1883
|
found.push(...malformed);
|
|
1779
1884
|
if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
|
|
1780
1885
|
session = opened.audio;
|
|
1781
1886
|
else if (malformed.length === 0 && isRecord(opened))
|
|
1782
|
-
refuse("
|
|
1887
|
+
refuse("test.openAudio.unavailable", "test.openAudio", "a softphone login's adapter could not open the call's audio");
|
|
1783
1888
|
}
|
|
1784
|
-
// 3b. The microphone is the host's. With the audio open, the
|
|
1889
|
+
// 3b. The microphone is the host's. With the audio open, the test mutes it for a moment and
|
|
1785
1890
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1786
1891
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1787
1892
|
const canonicalTimes = new Map();
|
|
@@ -1793,34 +1898,34 @@ async function driveOneCall(drive) {
|
|
|
1793
1898
|
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
|
|
1794
1899
|
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
1795
1900
|
};
|
|
1796
|
-
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof
|
|
1901
|
+
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof call.connection.recordStep === "function";
|
|
1797
1902
|
const report = async (body) => {
|
|
1798
|
-
|
|
1799
|
-
// The
|
|
1903
|
+
ruleTested("test.recordStep.rejected", "test.recordStep.failed");
|
|
1904
|
+
// The test holds its own report to the contract before it crosses, as a host must.
|
|
1800
1905
|
const leg = { assignmentId: taskId, step: "muted", mutedBy: "host", ...body };
|
|
1801
|
-
const own = validateHistoryReport(leg, "
|
|
1906
|
+
const own = validateHistoryReport(leg, "test.recordStep.report", call.manifest);
|
|
1802
1907
|
found.push(...own);
|
|
1803
1908
|
if (own.length > 0)
|
|
1804
1909
|
return;
|
|
1805
1910
|
let answer;
|
|
1806
1911
|
try {
|
|
1807
|
-
answer = await
|
|
1912
|
+
answer = await call.connection.recordStep(leg);
|
|
1808
1913
|
}
|
|
1809
1914
|
catch (error) {
|
|
1810
|
-
refuse("
|
|
1915
|
+
refuse("test.recordStep.rejected", "test.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1811
1916
|
return;
|
|
1812
1917
|
}
|
|
1813
|
-
const resultErrors = validateResult(answer, "recordStep", "
|
|
1918
|
+
const resultErrors = validateResult(answer, "recordStep", "test.recordStep.result");
|
|
1814
1919
|
found.push(...resultErrors);
|
|
1815
1920
|
if (!resultErrors.length && isRecord(answer) && answer.status === "recorded" && typeof answer.at === "string" && typeof body.at === "string") {
|
|
1816
1921
|
const prior = canonicalTimes.get(body.at);
|
|
1817
1922
|
if (prior !== undefined && prior !== answer.at)
|
|
1818
|
-
refuse("
|
|
1923
|
+
refuse("test.recordStep.identity", "test.recordStep.result.at", "one reported leg must retain its provider-assigned history instant");
|
|
1819
1924
|
else
|
|
1820
1925
|
canonicalTimes.set(body.at, answer.at);
|
|
1821
1926
|
}
|
|
1822
1927
|
if (isRecord(answer) && answer.status === "failed") {
|
|
1823
|
-
refuse("
|
|
1928
|
+
refuse("test.recordStep.failed", "test.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
|
|
1824
1929
|
}
|
|
1825
1930
|
};
|
|
1826
1931
|
const setMuted = (muted) => {
|
|
@@ -1831,7 +1936,7 @@ async function driveOneCall(drive) {
|
|
|
1831
1936
|
session.setMuted(muted);
|
|
1832
1937
|
}
|
|
1833
1938
|
catch {
|
|
1834
|
-
refuse("
|
|
1939
|
+
refuse("test.openAudio.setMuted", "test.openAudio", `the audio session threw on setMuted(${String(muted)})`);
|
|
1835
1940
|
}
|
|
1836
1941
|
};
|
|
1837
1942
|
if (canRecordMute) {
|
|
@@ -1847,16 +1952,16 @@ async function driveOneCall(drive) {
|
|
|
1847
1952
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1848
1953
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1849
1954
|
// the store -- or it composed the record in memory and the record died with it.
|
|
1850
|
-
if (
|
|
1955
|
+
if (call.rebuild !== undefined && canonicalTimes.has(at)) {
|
|
1851
1956
|
await recordSurvivesReload(canonicalTimes.get(at));
|
|
1852
|
-
// A reload that stood no client ends the run: there is nothing left to
|
|
1853
|
-
if (!
|
|
1957
|
+
// A reload that stood no client ends the run: there is nothing left to call.
|
|
1958
|
+
if (!call.handOver.isLive())
|
|
1854
1959
|
return found;
|
|
1855
1960
|
}
|
|
1856
1961
|
}
|
|
1857
1962
|
// 4. Hold and resume, where offered.
|
|
1858
1963
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
1859
|
-
if (await send({ type:
|
|
1964
|
+
if (await send({ type: call.channel === "chat" ? "pause" : "hold" }) !== undefined) {
|
|
1860
1965
|
if (await updated(t => t.phase === "paused", "the task pausing after hold") !== undefined) {
|
|
1861
1966
|
if (await send({ type: "resume" }) !== undefined) {
|
|
1862
1967
|
await updated(t => t.phase === "in-progress", "the task resuming after resume");
|
|
@@ -1868,14 +1973,14 @@ async function driveOneCall(drive) {
|
|
|
1868
1973
|
// calendar-updated that shows it is owed within the manifest's bound. The time is the provider's
|
|
1869
1974
|
// own: a day after the instant it published the offer, so no clock of the runner's is read.
|
|
1870
1975
|
if (latestTask().phase === "in-progress" && offers("schedule")) {
|
|
1871
|
-
const publishedAt =
|
|
1976
|
+
const publishedAt = call.events.slice(0, cursor).reverse().find(envelope => {
|
|
1872
1977
|
const event = envelope.event;
|
|
1873
1978
|
return (event.type === "task-offered" || event.type === "task-updated") && isTask(event.task) && event.task.assignmentId === taskId;
|
|
1874
1979
|
})?.occurredAt;
|
|
1875
1980
|
const at = new Date(Date.parse(String(publishedAt ?? "2026-01-01T00:00:00Z")) + 24 * 60 * 60 * 1000).toISOString();
|
|
1876
1981
|
if (await send({ type: "schedule", at, note: "Follow-up promised on the call" }) !== undefined) {
|
|
1877
|
-
|
|
1878
|
-
const settle = Number(
|
|
1982
|
+
ruleTested("test.schedule.unsettled");
|
|
1983
|
+
const settle = Number(call.manifest.settleMs);
|
|
1879
1984
|
const carries = (calendar) => Array.isArray(calendar) && calendar.some(activity => isRecord(activity) && activity.startsAt === at);
|
|
1880
1985
|
let unsettled = false;
|
|
1881
1986
|
const shown = await waitFor("a calendar-updated carrying the follow-up", envelope => {
|
|
@@ -1885,13 +1990,13 @@ async function driveOneCall(drive) {
|
|
|
1885
1990
|
if (shown === undefined && unsettled) {
|
|
1886
1991
|
let resync;
|
|
1887
1992
|
try {
|
|
1888
|
-
resync = await
|
|
1993
|
+
resync = await call.connection.snapshot();
|
|
1889
1994
|
}
|
|
1890
1995
|
catch (error) {
|
|
1891
|
-
refuse("
|
|
1996
|
+
refuse("test.command.rejected", "test.schedule", `snapshot() after an unsettled schedule rejected: ${String(error)}`);
|
|
1892
1997
|
}
|
|
1893
1998
|
if (!(isRecord(resync) && carries(resync.calendar))) {
|
|
1894
|
-
refuse("
|
|
1999
|
+
refuse("test.schedule.unsettled", "test.schedule", `schedule was applied and ${settle}ms later no calendar-updated carries a follow-up at ${at}, and a snapshot does not either: applied says the follow-up is on the calendar, and it is not`);
|
|
1895
2000
|
}
|
|
1896
2001
|
}
|
|
1897
2002
|
}
|
|
@@ -1899,9 +2004,9 @@ async function driveOneCall(drive) {
|
|
|
1899
2004
|
// 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
|
|
1900
2005
|
// open at audio end is the provider's to close, since the provider knows the instant the audio
|
|
1901
2006
|
// ended; the host's own closing report can only follow what it hears, is answered recorded, and
|
|
1902
|
-
// changes nothing. So the
|
|
2007
|
+
// changes nothing. So the test mutes, does not report the end before end-call, and reports it
|
|
1903
2008
|
// after the audio has ended.
|
|
1904
|
-
if (
|
|
2009
|
+
if (call.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
1905
2010
|
let openLeg;
|
|
1906
2011
|
if (canRecordMute && session !== undefined) {
|
|
1907
2012
|
// The task as published before the call ends: the completing publication that follows is the restatement held to carrying this leg.
|
|
@@ -1911,7 +2016,7 @@ async function driveOneCall(drive) {
|
|
|
1911
2016
|
}
|
|
1912
2017
|
if (await send({ type: "end-call" }) !== undefined) {
|
|
1913
2018
|
// A task that completes with its audio still up is refused by the stream as it passes
|
|
1914
|
-
// (stream.taskUpdated.audioOpen); the
|
|
2019
|
+
// (stream.taskUpdated.audioOpen); the test does not also wait out the clock for an ending
|
|
1915
2020
|
// that is not coming, so the run reads by the rule and not by its timeout.
|
|
1916
2021
|
const audioEnded = await waitFor("task-audio-ended after end-call", envelope => {
|
|
1917
2022
|
const event = envelope.event;
|
|
@@ -1943,11 +2048,11 @@ async function driveOneCall(drive) {
|
|
|
1943
2048
|
session.close();
|
|
1944
2049
|
}
|
|
1945
2050
|
catch {
|
|
1946
|
-
refuse("
|
|
2051
|
+
refuse("test.openAudio.close", "test.openAudio", "the audio session threw on close");
|
|
1947
2052
|
}
|
|
1948
2053
|
}
|
|
1949
2054
|
// 6. Complete, under either mode: the agent may finish early and the provider is free to end at
|
|
1950
|
-
// once. Only a task with no wrap at all has nothing to complete, and there the
|
|
2055
|
+
// once. Only a task with no wrap at all has nothing to complete, and there the test waits for the
|
|
1951
2056
|
// provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
|
|
1952
2057
|
// email, and a voice task offering no end-call, is completed from where it stands.
|
|
1953
2058
|
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
@@ -1961,8 +2066,8 @@ async function driveOneCall(drive) {
|
|
|
1961
2066
|
// applied says the provider has completed the task, and its ending follows within the bound
|
|
1962
2067
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1963
2068
|
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1964
|
-
|
|
1965
|
-
const settle = Number(
|
|
2069
|
+
ruleTested("test.completion.unsettled");
|
|
2070
|
+
const settle = Number(call.manifest.settleMs);
|
|
1966
2071
|
let unsettled = false;
|
|
1967
2072
|
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1968
2073
|
if (end !== undefined)
|
|
@@ -1970,13 +2075,13 @@ async function driveOneCall(drive) {
|
|
|
1970
2075
|
else if (unsettled) {
|
|
1971
2076
|
let resync;
|
|
1972
2077
|
try {
|
|
1973
|
-
resync = await
|
|
2078
|
+
resync = await call.connection.snapshot();
|
|
1974
2079
|
}
|
|
1975
2080
|
catch (error) {
|
|
1976
|
-
refuse("
|
|
2081
|
+
refuse("test.command.rejected", "test.completion", `snapshot() after an unsettled completion rejected: ${String(error)}`);
|
|
1977
2082
|
}
|
|
1978
2083
|
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.assignmentId === taskId);
|
|
1979
|
-
refuse("
|
|
2084
|
+
refuse("test.completion.unsettled", "test.completion", still
|
|
1980
2085
|
? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider completed the task, and it has not`
|
|
1981
2086
|
: `complete was applied and ${settle}ms later the task-ended has not come; a snapshot no longer carries ${taskId}, so the ending was owed and never sent`);
|
|
1982
2087
|
}
|
|
@@ -1987,10 +2092,10 @@ async function driveOneCall(drive) {
|
|
|
1987
2092
|
await storeReleased();
|
|
1988
2093
|
}
|
|
1989
2094
|
// The record kept by the provider has every leg the host reported, wherever the provider restated it:
|
|
1990
|
-
// the last publication of the task is read, whether or not the
|
|
2095
|
+
// the last publication of the task is read, whether or not the test was waiting on it.
|
|
1991
2096
|
const lastPublished = () => {
|
|
1992
|
-
for (let index =
|
|
1993
|
-
const event =
|
|
2097
|
+
for (let index = call.events.length - 1; index >= 0; index -= 1) {
|
|
2098
|
+
const event = call.events[index].event;
|
|
1994
2099
|
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.assignmentId === taskId)
|
|
1995
2100
|
return event.task;
|
|
1996
2101
|
}
|
|
@@ -2007,15 +2112,15 @@ async function driveOneCall(drive) {
|
|
|
2007
2112
|
const { at } = reported;
|
|
2008
2113
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
2009
2114
|
if (leg === undefined) {
|
|
2010
|
-
refuse("
|
|
2115
|
+
refuse("test.recordStep.history", "test.recordStep", `the provider restated the task's record after the host reported a muted leg at ${at}, and the leg is not in it`);
|
|
2011
2116
|
}
|
|
2012
2117
|
else if (leg.mutedBy !== "host") {
|
|
2013
|
-
refuse("
|
|
2118
|
+
refuse("test.recordStep.history", "test.recordStep", `the record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host, and the record keeps the host's word`);
|
|
2014
2119
|
}
|
|
2015
2120
|
else if (reported.closedAs !== undefined) {
|
|
2016
|
-
|
|
2121
|
+
ruleTested("test.recordStep.overwritten");
|
|
2017
2122
|
if (leg.seconds !== reported.closedAs) {
|
|
2018
|
-
refuse("
|
|
2123
|
+
refuse("test.recordStep.overwritten", "test.recordStep", `the provider closed the leg at ${at} with ${reported.closedAs}s at audio end and the record now says ${String(leg.seconds)}s: the host's closing report changes nothing`);
|
|
2019
2124
|
}
|
|
2020
2125
|
}
|
|
2021
2126
|
}
|
|
@@ -2064,7 +2169,7 @@ export class TeamStream {
|
|
|
2064
2169
|
case "team-member-updated":
|
|
2065
2170
|
case "team-member-removed":
|
|
2066
2171
|
case "team-policies-updated": {
|
|
2067
|
-
|
|
2172
|
+
ruleTested("stream.team.baseline");
|
|
2068
2173
|
if (this.members === undefined) {
|
|
2069
2174
|
return [{ rule: "stream.team.baseline", path: `${at}.type`,
|
|
2070
2175
|
message: `${event.type} arrived before the team: team-updated carries the whole team first, and it changes one member at a time after that` }];
|
|
@@ -2075,7 +2180,7 @@ export class TeamStream {
|
|
|
2075
2180
|
return [];
|
|
2076
2181
|
}
|
|
2077
2182
|
if (event.type === "team-member-removed") {
|
|
2078
|
-
|
|
2183
|
+
ruleTested("stream.teamMember.unknown");
|
|
2079
2184
|
const id = String(event.memberId);
|
|
2080
2185
|
if (!this.members.has(id)) {
|
|
2081
2186
|
return [{ rule: "stream.teamMember.unknown", path: `${at}.memberId`, message: `${id} is not a member the team carried: a removal takes off somebody the lead was given` }];
|
|
@@ -2168,7 +2273,7 @@ function watchStore(store) {
|
|
|
2168
2273
|
},
|
|
2169
2274
|
};
|
|
2170
2275
|
}
|
|
2171
|
-
/** A login store that lives in memory for the test: what most adapter tests hand `
|
|
2276
|
+
/** A login store that lives in memory for the test: what most adapter tests hand `testAdapter` as `context.store`. */
|
|
2172
2277
|
export function memoryStore() {
|
|
2173
2278
|
const kept = new Map();
|
|
2174
2279
|
return {
|