@xema/omni-protocol 0.1.112 → 0.1.114
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 +5 -2
- package/dist/testing.d.ts +13 -13
- package/dist/testing.js +198 -176
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +11 -9
- package/guide/agent.md +12 -9
- package/guide/breaks.md +1 -1
- package/guide/phone.md +1 -1
- package/guide/voice.md +8 -1
- package/guide.md +52 -50
- 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
|
}
|
|
@@ -1159,7 +1159,7 @@ export class TaskStream {
|
|
|
1159
1159
|
}
|
|
1160
1160
|
const before = known.get(field);
|
|
1161
1161
|
if (before !== undefined) {
|
|
1162
|
-
|
|
1162
|
+
ruleTested("stream.countdown.copied");
|
|
1163
1163
|
const elapsed = Math.floor((atMs - before.atMs) / 1000);
|
|
1164
1164
|
if (elapsed > 0 && value > before.value - elapsed + 1) {
|
|
1165
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`);
|
|
@@ -1265,7 +1265,7 @@ export class TaskStream {
|
|
|
1265
1265
|
resync(snapshot, at) {
|
|
1266
1266
|
const found = [];
|
|
1267
1267
|
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
1268
|
-
|
|
1268
|
+
ruleTested("stream.snapshot.capabilitySource", "stream.snapshot.history", "stream.snapshot.phase", "stream.snapshot.audio");
|
|
1269
1269
|
if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
|
|
1270
1270
|
snapshot.tasks.forEach((task, index) => {
|
|
1271
1271
|
if (!isRecord(task) || typeof task.assignmentId !== "string")
|
|
@@ -1331,7 +1331,7 @@ export class TaskStream {
|
|
|
1331
1331
|
found.push(...this.resync(event.snapshot, `${at}.snapshot`));
|
|
1332
1332
|
break;
|
|
1333
1333
|
case "task-offered": {
|
|
1334
|
-
|
|
1334
|
+
ruleTested("stream.taskOffered.duplicate");
|
|
1335
1335
|
if (id === undefined)
|
|
1336
1336
|
break;
|
|
1337
1337
|
// An assignment is introduced once: not while it is open, and never again once it has ended,
|
|
@@ -1349,14 +1349,14 @@ export class TaskStream {
|
|
|
1349
1349
|
case "task-updated":
|
|
1350
1350
|
if (id === undefined)
|
|
1351
1351
|
break;
|
|
1352
|
-
|
|
1352
|
+
ruleTested("stream.taskUpdated.unknown");
|
|
1353
1353
|
if (known === undefined) {
|
|
1354
1354
|
refuse("stream.taskUpdated.unknown", `${at}.task.assignmentId`, `${id} was never offered or carried on a snapshot`);
|
|
1355
1355
|
break;
|
|
1356
1356
|
}
|
|
1357
1357
|
// The rules about a known task are evaluated only once there is one.
|
|
1358
1358
|
this.holdCountdowns(id, event.task, isRecord(envelope) ? envelope.occurredAt : undefined, `${at}.task`, refuse);
|
|
1359
|
-
|
|
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");
|
|
1360
1360
|
// Terms once read stay read. A re-read that fails is not a new fact about the task, so the
|
|
1361
1361
|
// last statement stands and the failure is a diagnostic; not-yet-read is a place a task
|
|
1362
1362
|
// starts from, never one it returns to.
|
|
@@ -1388,7 +1388,7 @@ export class TaskStream {
|
|
|
1388
1388
|
}
|
|
1389
1389
|
// A task completes after its audio ends, never around it: an update that moves a task to
|
|
1390
1390
|
// completing while the stream holds its audio as started is a call whose audio never ended,
|
|
1391
|
-
// whoever caused the ending -- the
|
|
1391
|
+
// whoever caused the ending -- the test's end-call, a take-over, the provider's own hand.
|
|
1392
1392
|
{
|
|
1393
1393
|
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1394
1394
|
if (to === "completing" && known.audio === "started" && known.phase !== "completing") {
|
|
@@ -1428,7 +1428,7 @@ export class TaskStream {
|
|
|
1428
1428
|
this.noteDials(event.task);
|
|
1429
1429
|
break;
|
|
1430
1430
|
case "task-audio-started":
|
|
1431
|
-
|
|
1431
|
+
ruleTested("stream.taskAudioStarted.beforeWork", "stream.taskAudioStarted.duplicate", "stream.taskAudio.channel", "stream.assignment.ended", "stream.assignment.unknown");
|
|
1432
1432
|
if (id === undefined)
|
|
1433
1433
|
break;
|
|
1434
1434
|
if (known === undefined) {
|
|
@@ -1450,7 +1450,7 @@ export class TaskStream {
|
|
|
1450
1450
|
known.audio = "started";
|
|
1451
1451
|
break;
|
|
1452
1452
|
case "task-audio-ended":
|
|
1453
|
-
|
|
1453
|
+
ruleTested("stream.taskAudioEnded.beforeWork", "stream.taskAudioEnded.silent", "stream.taskAudio.channel", "stream.assignment.ended", "stream.assignment.unknown");
|
|
1454
1454
|
if (id === undefined)
|
|
1455
1455
|
break;
|
|
1456
1456
|
if (known === undefined) {
|
|
@@ -1470,7 +1470,7 @@ export class TaskStream {
|
|
|
1470
1470
|
known.audio = "ended";
|
|
1471
1471
|
break;
|
|
1472
1472
|
case "task-ended":
|
|
1473
|
-
|
|
1473
|
+
ruleTested("stream.assignment.ended", "stream.assignment.unknown");
|
|
1474
1474
|
if (id === undefined)
|
|
1475
1475
|
break;
|
|
1476
1476
|
if (known === undefined) {
|
|
@@ -1480,7 +1480,7 @@ export class TaskStream {
|
|
|
1480
1480
|
// A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
|
|
1481
1481
|
// and a lead leaving included. An ending with the audio still up leaves the host holding an
|
|
1482
1482
|
// open audio session and an open leg on a task that no longer exists.
|
|
1483
|
-
|
|
1483
|
+
ruleTested("stream.taskEnded.audioOpen");
|
|
1484
1484
|
if (known.audio === "started") {
|
|
1485
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`);
|
|
1486
1486
|
}
|
|
@@ -1488,7 +1488,7 @@ export class TaskStream {
|
|
|
1488
1488
|
// provider's word that interaction ended and the moment the wrap allowance starts. A provider
|
|
1489
1489
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1490
1490
|
// completed from in-progress, the allowance it stated was never given.
|
|
1491
|
-
|
|
1491
|
+
ruleTested("stream.taskEnded.unwrapped");
|
|
1492
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.
|
|
1493
1493
|
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1494
1494
|
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed" && event.outcome.by === "provider") {
|
|
@@ -1501,7 +1501,7 @@ export class TaskStream {
|
|
|
1501
1501
|
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
1502
1502
|
// a dial placed late routinely outlives its call, which is why the dial has its own identity,
|
|
1503
1503
|
// and why the outcome names the assignment: the host routes it to that life, ended or not.
|
|
1504
|
-
|
|
1504
|
+
ruleTested("stream.assignment.unknown");
|
|
1505
1505
|
if (typeof event.assignmentId === "string" && !this.assignments.has(event.assignmentId)) {
|
|
1506
1506
|
refuse("stream.assignment.unknown", `${at}.assignmentId`, `${event.assignmentId} is not an assignment this stream has seen`);
|
|
1507
1507
|
}
|
|
@@ -1527,24 +1527,24 @@ export class TaskStream {
|
|
|
1527
1527
|
* One ordinary lifecycle on the first task offered, each step held to the rules a host holds a
|
|
1528
1528
|
* provider to: the command validated against the task as published, the result validated for the
|
|
1529
1529
|
* method, and the event the provider owes in return awaited -- a step that never arrives is a
|
|
1530
|
-
* violation naming what was owed. The
|
|
1530
|
+
* violation naming what was owed. The test stops where the task offers no way on (no `endCall`,
|
|
1531
1531
|
* a provider that completes for itself) and says nothing about what it could not reach.
|
|
1532
1532
|
*/
|
|
1533
|
-
async function
|
|
1533
|
+
async function testOneCall(call) {
|
|
1534
1534
|
const found = [];
|
|
1535
1535
|
const refuse = (rule, path, message) => found.push({ rule, path, message });
|
|
1536
1536
|
const isTask = (value) => isRecord(value) && typeof value.assignmentId === "string";
|
|
1537
1537
|
// Wait for an envelope that satisfies `matches`, looking first at what already arrived.
|
|
1538
|
-
const waitFor = (what, matches, from, within = { ms:
|
|
1539
|
-
for (let index = from; index <
|
|
1540
|
-
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]);
|
|
1541
1541
|
if (hit !== undefined) {
|
|
1542
1542
|
resolve({ found: hit, at: index + 1 });
|
|
1543
1543
|
return;
|
|
1544
1544
|
}
|
|
1545
1545
|
}
|
|
1546
1546
|
const timer = setTimeout(() => {
|
|
1547
|
-
|
|
1547
|
+
call.waiters.delete(waiter);
|
|
1548
1548
|
within.onExpiry();
|
|
1549
1549
|
resolve(undefined);
|
|
1550
1550
|
}, within.ms);
|
|
@@ -1553,45 +1553,45 @@ async function driveOneCall(drive) {
|
|
|
1553
1553
|
if (hit === undefined)
|
|
1554
1554
|
return;
|
|
1555
1555
|
clearTimeout(timer);
|
|
1556
|
-
|
|
1557
|
-
resolve({ found: hit, at:
|
|
1556
|
+
call.waiters.delete(waiter);
|
|
1557
|
+
resolve({ found: hit, at: call.events.length });
|
|
1558
1558
|
};
|
|
1559
|
-
|
|
1559
|
+
call.waiters.add(waiter);
|
|
1560
1560
|
});
|
|
1561
1561
|
// The call's audio session, opened on the first client and opened again on a reloaded second.
|
|
1562
1562
|
let session;
|
|
1563
1563
|
const recordSurvivesReload = async (at) => {
|
|
1564
1564
|
// Each reload rule is marked where it is decided, never up front: a rebuild that throws has looked at nothing else.
|
|
1565
|
-
|
|
1565
|
+
ruleTested("test.reload.rejected");
|
|
1566
1566
|
let again;
|
|
1567
1567
|
try {
|
|
1568
|
-
again =
|
|
1568
|
+
again = call.rebuild();
|
|
1569
1569
|
}
|
|
1570
1570
|
catch (error) {
|
|
1571
|
-
refuse("
|
|
1571
|
+
refuse("test.reload.rejected", "test.reload", `building the adapter again threw: ${String(error)}`);
|
|
1572
1572
|
return;
|
|
1573
1573
|
}
|
|
1574
1574
|
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
1575
|
-
|
|
1576
|
-
if (again.manifest.id !==
|
|
1577
|
-
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)}`);
|
|
1578
1578
|
return;
|
|
1579
1579
|
}
|
|
1580
1580
|
// The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
|
|
1581
|
-
|
|
1582
|
-
if (!await
|
|
1583
|
-
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");
|
|
1584
1584
|
}
|
|
1585
1585
|
try {
|
|
1586
|
-
const restoredSession = await again.createAuthenticationSession({ ...
|
|
1586
|
+
const restoredSession = await again.createAuthenticationSession({ ...call.context, secrets: call.secrets });
|
|
1587
1587
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1588
1588
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1589
1589
|
const restored = await restoredSession.state();
|
|
1590
|
-
found.push(...validateAuthenticationState(restored, "
|
|
1591
|
-
|
|
1592
|
-
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;
|
|
1593
1593
|
if (!same) {
|
|
1594
|
-
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)}`);
|
|
1595
1595
|
// A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
|
|
1596
1596
|
try {
|
|
1597
1597
|
await restoredSession.close();
|
|
@@ -1599,27 +1599,27 @@ async function driveOneCall(drive) {
|
|
|
1599
1599
|
catch { /* the session that would not restore is closed as far as it can be */ }
|
|
1600
1600
|
return;
|
|
1601
1601
|
}
|
|
1602
|
-
const second = await again.connect(
|
|
1602
|
+
const second = await again.connect(call.context);
|
|
1603
1603
|
// From here the second client is the run's connection, held to everything the first was on
|
|
1604
1604
|
// connect: what it publishes is validated as before.
|
|
1605
|
-
await
|
|
1606
|
-
|
|
1605
|
+
await call.handOver.up(restoredSession, second);
|
|
1606
|
+
call.connection = second;
|
|
1607
1607
|
const snapshot = await second.snapshot();
|
|
1608
1608
|
// The second adapter's snapshot is read as the connect snapshot was, and the streams take it
|
|
1609
1609
|
// as the state now the way they take any resync: held to what they knew, then replaced. A reload
|
|
1610
1610
|
// is a place the stream's rules keep working, not one where they all stop.
|
|
1611
|
-
await
|
|
1612
|
-
found.push(...
|
|
1613
|
-
found.push(...
|
|
1614
|
-
|
|
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);
|
|
1615
1615
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1616
1616
|
? snapshot.tasks.find(task => isRecord(task) && task.assignmentId === taskId) : undefined;
|
|
1617
|
-
|
|
1617
|
+
ruleTested("test.reload.snapshot");
|
|
1618
1618
|
if (carried === undefined) {
|
|
1619
|
-
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`);
|
|
1620
1620
|
return;
|
|
1621
1621
|
}
|
|
1622
|
-
|
|
1622
|
+
ruleTested("test.reload.history");
|
|
1623
1623
|
const history = carried.history;
|
|
1624
1624
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1625
1625
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
@@ -1628,41 +1628,41 @@ async function driveOneCall(drive) {
|
|
|
1628
1628
|
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
1629
1629
|
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
1630
1630
|
if (lost.length > 0) {
|
|
1631
|
-
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`);
|
|
1632
1632
|
}
|
|
1633
1633
|
const leg = steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1634
1634
|
if (leg === undefined) {
|
|
1635
|
-
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`);
|
|
1636
1636
|
}
|
|
1637
1637
|
else if (leg.mutedBy !== "host") {
|
|
1638
|
-
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`);
|
|
1639
1639
|
}
|
|
1640
|
-
// The
|
|
1640
|
+
// The test goes on with the task as the reloaded client holds it. The first client's audio
|
|
1641
1641
|
// session died with it: a host opens audio on a task arriving with audio started on a
|
|
1642
|
-
// snapshot, so the
|
|
1642
|
+
// snapshot, so the test opens it again on the second and holds the answer as it held the first.
|
|
1643
1643
|
task = carried;
|
|
1644
|
-
if (
|
|
1644
|
+
if (call.softphone && carried.audio === "started") {
|
|
1645
1645
|
if (session !== undefined && typeof session.close === "function") {
|
|
1646
1646
|
try {
|
|
1647
1647
|
session.close();
|
|
1648
1648
|
}
|
|
1649
1649
|
catch {
|
|
1650
|
-
refuse("
|
|
1650
|
+
refuse("test.openAudio.close", "test.openAudio", "the first client's audio session threw on close");
|
|
1651
1651
|
}
|
|
1652
1652
|
}
|
|
1653
1653
|
session = undefined;
|
|
1654
|
-
|
|
1655
|
-
const reopened = await second.openAudio?.({ assignmentId: taskId, localAudio:
|
|
1656
|
-
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");
|
|
1657
1657
|
found.push(...malformed);
|
|
1658
1658
|
if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
|
|
1659
1659
|
session = reopened.audio;
|
|
1660
1660
|
else if (malformed.length === 0)
|
|
1661
|
-
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`);
|
|
1662
1662
|
}
|
|
1663
1663
|
}
|
|
1664
1664
|
catch (error) {
|
|
1665
|
-
refuse("
|
|
1665
|
+
refuse("test.reload.rejected", "test.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
1666
1666
|
}
|
|
1667
1667
|
};
|
|
1668
1668
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
@@ -1675,28 +1675,28 @@ async function driveOneCall(drive) {
|
|
|
1675
1675
|
// A task's key names the task delimited, not as a run of characters inside another id, and the
|
|
1676
1676
|
// rule is evaluated only where the store was ever seen to hold such a key: an adapter whose keys
|
|
1677
1677
|
// name nothing the harness can see leaves the rule unasked, which the result says.
|
|
1678
|
-
const retained = [...
|
|
1678
|
+
const retained = [...call.held].filter(key => namesTask(key, taskId));
|
|
1679
1679
|
retainedAtEnd = new Set(retained);
|
|
1680
|
-
if (
|
|
1681
|
-
|
|
1680
|
+
if (call.everHeldForTask(taskId))
|
|
1681
|
+
ruleTested("test.store.retained");
|
|
1682
1682
|
if (retained.length > 0) {
|
|
1683
|
-
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`);
|
|
1684
1684
|
}
|
|
1685
1685
|
};
|
|
1686
1686
|
// A key written about the task after its end -- a persist hung off a timer, a late command -- is the
|
|
1687
|
-
// 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.
|
|
1688
1688
|
const nothingLate = () => {
|
|
1689
1689
|
if (retainedAtEnd === undefined)
|
|
1690
1690
|
return;
|
|
1691
|
-
|
|
1692
|
-
const late = [...
|
|
1691
|
+
ruleTested("test.store.late");
|
|
1692
|
+
const late = [...call.held].filter(key => namesTask(key, taskId) && !retainedAtEnd.has(key));
|
|
1693
1693
|
if (late.length > 0) {
|
|
1694
|
-
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`);
|
|
1695
1695
|
}
|
|
1696
1696
|
};
|
|
1697
1697
|
// The task, as first seen: on the snapshot, or on the first offer.
|
|
1698
1698
|
let cursor = 0;
|
|
1699
|
-
let task = isRecord(
|
|
1699
|
+
let task = isRecord(call.snapshot) && Array.isArray(call.snapshot.tasks) ? call.snapshot.tasks.find(isTask) : undefined;
|
|
1700
1700
|
if (task === undefined) {
|
|
1701
1701
|
const offered = await waitFor("a task-offered", envelope => {
|
|
1702
1702
|
const event = envelope.event;
|
|
@@ -1718,35 +1718,35 @@ async function driveOneCall(drive) {
|
|
|
1718
1718
|
return undefined;
|
|
1719
1719
|
}, cursor, within).then(hit => { if (hit)
|
|
1720
1720
|
cursor = hit.at; return hit; });
|
|
1721
|
-
const ended = (within) => waitFor("a task-ended for the
|
|
1721
|
+
const ended = (within) => waitFor("a task-ended for the tested task", envelope => {
|
|
1722
1722
|
const event = envelope.event;
|
|
1723
1723
|
return event.type === "task-ended" && event.assignmentId === taskId ? event : undefined;
|
|
1724
1724
|
}, cursor, within);
|
|
1725
|
-
// Every command the
|
|
1726
|
-
|
|
1725
|
+
// Every command the test sends is validated against the task as published, and its answer for its method.
|
|
1726
|
+
ruleTested("test.timeout");
|
|
1727
1727
|
const send = async (command, dialId) => {
|
|
1728
|
-
const own = validateTaskCommand(command, latestTask(), `
|
|
1729
|
-
levels: effectiveLevels(
|
|
1730
|
-
dialOutcomesDeclared:
|
|
1731
|
-
calendarDeclared:
|
|
1732
|
-
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,
|
|
1733
1733
|
});
|
|
1734
1734
|
found.push(...own);
|
|
1735
1735
|
if (own.length > 0)
|
|
1736
1736
|
return undefined;
|
|
1737
|
-
|
|
1737
|
+
ruleTested("test.command.rejected");
|
|
1738
1738
|
let result;
|
|
1739
1739
|
try {
|
|
1740
|
-
result = await
|
|
1740
|
+
result = await call.connection.execute({ assignmentId: taskId, command });
|
|
1741
1741
|
}
|
|
1742
1742
|
catch (error) {
|
|
1743
|
-
refuse("
|
|
1743
|
+
refuse("test.command.rejected", `test.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
1744
1744
|
return undefined;
|
|
1745
1745
|
}
|
|
1746
|
-
|
|
1747
|
-
found.push(...validateResult(result, "execute", `
|
|
1746
|
+
ruleTested("test.command.failed");
|
|
1747
|
+
found.push(...validateResult(result, "execute", `test.command.${String(command.type)}.result`, dialId));
|
|
1748
1748
|
if (isRecord(result) && result.status === "failed") {
|
|
1749
|
-
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)}`);
|
|
1750
1750
|
return undefined;
|
|
1751
1751
|
}
|
|
1752
1752
|
return isRecord(result) ? result : undefined;
|
|
@@ -1758,15 +1758,15 @@ async function driveOneCall(drive) {
|
|
|
1758
1758
|
};
|
|
1759
1759
|
// Audio may arrive any time after the accept, before or after the task's own update says
|
|
1760
1760
|
// in-progress: the event is the provider's word that the audio should attach, never a reply to
|
|
1761
|
-
// openAudio, so the
|
|
1761
|
+
// openAudio, so the test looks for it from the accept rather than from the last update it read.
|
|
1762
1762
|
// 0. A deadline stated is a deadline kept. Where the first offer says how long it has, within
|
|
1763
|
-
// what the
|
|
1764
|
-
// when it runs out; the next offer is the one
|
|
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
1765
|
// leave this rule unreached, and the result says so.
|
|
1766
|
-
const settleMs = Number(
|
|
1766
|
+
const settleMs = Number(call.manifest.settleMs);
|
|
1767
1767
|
const lapse = latestTask().phase === "pending" ? latestTask().expiresInSeconds : undefined;
|
|
1768
|
-
if (typeof lapse === "number" && lapse * 1000 <=
|
|
1769
|
-
|
|
1768
|
+
if (typeof lapse === "number" && lapse * 1000 <= call.timeoutMs) {
|
|
1769
|
+
ruleTested("test.offer.expired");
|
|
1770
1770
|
const lapsedId = taskId;
|
|
1771
1771
|
let missed = false;
|
|
1772
1772
|
const ending = await waitFor(`the expired ending of ${lapsedId}`, envelope => {
|
|
@@ -1775,14 +1775,14 @@ async function driveOneCall(drive) {
|
|
|
1775
1775
|
}, cursor, { ms: lapse * 1000 + settleMs, onExpiry: () => { missed = true; } });
|
|
1776
1776
|
if (ending === undefined) {
|
|
1777
1777
|
if (missed)
|
|
1778
|
-
refuse("
|
|
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
1779
|
return found;
|
|
1780
1780
|
}
|
|
1781
1781
|
const outcome = isRecord(ending.found.outcome) ? ending.found.outcome : {};
|
|
1782
1782
|
if (!(outcome.type === "expired" && outcome.phase === "pending")) {
|
|
1783
|
-
refuse("
|
|
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
1784
|
}
|
|
1785
|
-
// The next offer is the one
|
|
1785
|
+
// The next offer is the one tested; a provider with nothing more to offer ends the test here.
|
|
1786
1786
|
const next = await waitFor("a task-offered after the lapsed one", envelope => {
|
|
1787
1787
|
const event = envelope.event;
|
|
1788
1788
|
return event.type === "task-offered" && isTask(event.task) && event.task.assignmentId !== lapsedId ? event.task : undefined;
|
|
@@ -1793,25 +1793,25 @@ async function driveOneCall(drive) {
|
|
|
1793
1793
|
cursor = next.at;
|
|
1794
1794
|
taskId = task.assignmentId;
|
|
1795
1795
|
}
|
|
1796
|
-
const acceptedAt =
|
|
1796
|
+
const acceptedAt = call.events.length;
|
|
1797
1797
|
// 1. Accept the offer, if it is one.
|
|
1798
1798
|
if (latestTask().phase === "pending") {
|
|
1799
|
-
if (await send({ type:
|
|
1799
|
+
if (await send({ type: call.channel === "voice" ? "answer" : "accept" }) === undefined)
|
|
1800
1800
|
return found;
|
|
1801
1801
|
if (await updated(t => t.phase !== "pending", "the task leaving pending after it was accepted") === undefined)
|
|
1802
1802
|
return found;
|
|
1803
1803
|
}
|
|
1804
1804
|
// 2. A preview: press Call, which is a dial. Where the preview says how long it has, within what
|
|
1805
|
-
// the
|
|
1806
|
-
// provider dials when it runs out and the
|
|
1807
|
-
// preview stands until then, and the
|
|
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.
|
|
1808
1808
|
if (latestTask().phase === "preview") {
|
|
1809
1809
|
const left = latestTask().previewEndsInSeconds;
|
|
1810
1810
|
const atDeadline = latestTask().atDeadline;
|
|
1811
1811
|
// Under provider-dials the provider places the call, and the desk presses nothing.
|
|
1812
1812
|
let pressCall = true;
|
|
1813
|
-
if (typeof left === "number" && left * 1000 <=
|
|
1814
|
-
|
|
1813
|
+
if (typeof left === "number" && left * 1000 <= call.timeoutMs && typeof atDeadline === "string") {
|
|
1814
|
+
ruleTested("test.preview.deadline");
|
|
1815
1815
|
const ringing = (t) => t.phase !== "preview"
|
|
1816
1816
|
|| (Array.isArray(t.onCall) && t.onCall.some(who => isRecord(who) && who.role === "party" && who.stage === "ringing"));
|
|
1817
1817
|
if (atDeadline === "provider-dials") {
|
|
@@ -1820,24 +1820,24 @@ async function driveOneCall(drive) {
|
|
|
1820
1820
|
if (dialled === undefined) {
|
|
1821
1821
|
if (!missed)
|
|
1822
1822
|
return found;
|
|
1823
|
-
refuse("
|
|
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
1824
|
return found;
|
|
1825
1825
|
}
|
|
1826
1826
|
pressCall = false;
|
|
1827
1827
|
}
|
|
1828
1828
|
else {
|
|
1829
|
-
// Nothing may move the preview before its deadline under host-dials or waits: the
|
|
1829
|
+
// Nothing may move the preview before its deadline under host-dials or waits: the test
|
|
1830
1830
|
// waits the seconds out and expects the task where it left it.
|
|
1831
1831
|
const moved = await updated(t => t.phase !== "preview", "nothing", { ms: left * 1000, onExpiry: () => undefined });
|
|
1832
1832
|
if (moved !== undefined) {
|
|
1833
|
-
refuse("
|
|
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
1834
|
return found;
|
|
1835
1835
|
}
|
|
1836
1836
|
}
|
|
1837
1837
|
}
|
|
1838
1838
|
if (pressCall && latestTask().phase === "preview") {
|
|
1839
|
-
const dialId = `
|
|
1840
|
-
|
|
1839
|
+
const dialId = `test-${taskId}`;
|
|
1840
|
+
call.stream.dialled(dialId);
|
|
1841
1841
|
if (await send({ type: "dial", dialId }, dialId) === undefined)
|
|
1842
1842
|
return found;
|
|
1843
1843
|
}
|
|
@@ -1846,19 +1846,19 @@ async function driveOneCall(drive) {
|
|
|
1846
1846
|
}
|
|
1847
1847
|
// The other direction of step 4, wherever the task stands outside the interaction phases with the
|
|
1848
1848
|
// control still declared: a host holds it back (command.phase.interaction), and an adapter that
|
|
1849
|
-
// 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.
|
|
1850
1850
|
const holdRefusedOutsideInteraction = async () => {
|
|
1851
|
-
|
|
1851
|
+
ruleTested("test.command.rejected", "test.command.interaction");
|
|
1852
1852
|
const phase = String(latestTask().phase);
|
|
1853
1853
|
let answer;
|
|
1854
1854
|
try {
|
|
1855
|
-
answer = await
|
|
1855
|
+
answer = await call.connection.execute({ assignmentId: taskId, command: { type: "hold" } });
|
|
1856
1856
|
}
|
|
1857
1857
|
catch (error) {
|
|
1858
|
-
refuse("
|
|
1858
|
+
refuse("test.command.rejected", "test.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
1859
1859
|
}
|
|
1860
1860
|
if (isRecord(answer) && answer.status !== "failed") {
|
|
1861
|
-
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`);
|
|
1862
1862
|
}
|
|
1863
1863
|
};
|
|
1864
1864
|
if (latestTask().phase === "confirmed") {
|
|
@@ -1868,9 +1868,9 @@ async function driveOneCall(drive) {
|
|
|
1868
1868
|
return found;
|
|
1869
1869
|
}
|
|
1870
1870
|
// 3. On a softphone, the audio arrives and the host opens it.
|
|
1871
|
-
if (
|
|
1871
|
+
if (call.softphone && latestTask().phase === "in-progress") {
|
|
1872
1872
|
if (latestTask().audio !== "started") {
|
|
1873
|
-
const started = await waitFor("task-audio-started for the
|
|
1873
|
+
const started = await waitFor("task-audio-started for the tested task", envelope => {
|
|
1874
1874
|
const event = envelope.event;
|
|
1875
1875
|
return event.type === "task-audio-started" && event.assignmentId === taskId ? event : undefined;
|
|
1876
1876
|
}, acceptedAt);
|
|
@@ -1878,15 +1878,15 @@ async function driveOneCall(drive) {
|
|
|
1878
1878
|
return found;
|
|
1879
1879
|
cursor = Math.max(cursor, started.at);
|
|
1880
1880
|
}
|
|
1881
|
-
const opened = await
|
|
1882
|
-
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");
|
|
1883
1883
|
found.push(...malformed);
|
|
1884
1884
|
if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
|
|
1885
1885
|
session = opened.audio;
|
|
1886
1886
|
else if (malformed.length === 0 && isRecord(opened))
|
|
1887
|
-
refuse("
|
|
1887
|
+
refuse("test.openAudio.unavailable", "test.openAudio", "a softphone login's adapter could not open the call's audio");
|
|
1888
1888
|
}
|
|
1889
|
-
// 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
|
|
1890
1890
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1891
1891
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1892
1892
|
const canonicalTimes = new Map();
|
|
@@ -1898,34 +1898,34 @@ async function driveOneCall(drive) {
|
|
|
1898
1898
|
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
|
|
1899
1899
|
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
1900
1900
|
};
|
|
1901
|
-
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof
|
|
1901
|
+
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof call.connection.recordStep === "function";
|
|
1902
1902
|
const report = async (body) => {
|
|
1903
|
-
|
|
1904
|
-
// 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.
|
|
1905
1905
|
const leg = { assignmentId: taskId, step: "muted", mutedBy: "host", ...body };
|
|
1906
|
-
const own = validateHistoryReport(leg, "
|
|
1906
|
+
const own = validateHistoryReport(leg, "test.recordStep.report", call.manifest);
|
|
1907
1907
|
found.push(...own);
|
|
1908
1908
|
if (own.length > 0)
|
|
1909
1909
|
return;
|
|
1910
1910
|
let answer;
|
|
1911
1911
|
try {
|
|
1912
|
-
answer = await
|
|
1912
|
+
answer = await call.connection.recordStep(leg);
|
|
1913
1913
|
}
|
|
1914
1914
|
catch (error) {
|
|
1915
|
-
refuse("
|
|
1915
|
+
refuse("test.recordStep.rejected", "test.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1916
1916
|
return;
|
|
1917
1917
|
}
|
|
1918
|
-
const resultErrors = validateResult(answer, "recordStep", "
|
|
1918
|
+
const resultErrors = validateResult(answer, "recordStep", "test.recordStep.result");
|
|
1919
1919
|
found.push(...resultErrors);
|
|
1920
1920
|
if (!resultErrors.length && isRecord(answer) && answer.status === "recorded" && typeof answer.at === "string" && typeof body.at === "string") {
|
|
1921
1921
|
const prior = canonicalTimes.get(body.at);
|
|
1922
1922
|
if (prior !== undefined && prior !== answer.at)
|
|
1923
|
-
refuse("
|
|
1923
|
+
refuse("test.recordStep.identity", "test.recordStep.result.at", "one reported leg must retain its provider-assigned history instant");
|
|
1924
1924
|
else
|
|
1925
1925
|
canonicalTimes.set(body.at, answer.at);
|
|
1926
1926
|
}
|
|
1927
1927
|
if (isRecord(answer) && answer.status === "failed") {
|
|
1928
|
-
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)}`);
|
|
1929
1929
|
}
|
|
1930
1930
|
};
|
|
1931
1931
|
const setMuted = (muted) => {
|
|
@@ -1936,7 +1936,7 @@ async function driveOneCall(drive) {
|
|
|
1936
1936
|
session.setMuted(muted);
|
|
1937
1937
|
}
|
|
1938
1938
|
catch {
|
|
1939
|
-
refuse("
|
|
1939
|
+
refuse("test.openAudio.setMuted", "test.openAudio", `the audio session threw on setMuted(${String(muted)})`);
|
|
1940
1940
|
}
|
|
1941
1941
|
};
|
|
1942
1942
|
if (canRecordMute) {
|
|
@@ -1952,18 +1952,40 @@ async function driveOneCall(drive) {
|
|
|
1952
1952
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1953
1953
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1954
1954
|
// the store -- or it composed the record in memory and the record died with it.
|
|
1955
|
-
if (
|
|
1955
|
+
if (call.rebuild !== undefined && canonicalTimes.has(at)) {
|
|
1956
1956
|
await recordSurvivesReload(canonicalTimes.get(at));
|
|
1957
|
-
// A reload that stood no client ends the run: there is nothing left to
|
|
1958
|
-
if (!
|
|
1957
|
+
// A reload that stood no client ends the run: there is nothing left to call.
|
|
1958
|
+
if (!call.handOver.isLive())
|
|
1959
1959
|
return found;
|
|
1960
1960
|
}
|
|
1961
1961
|
}
|
|
1962
|
+
// Ending my part from hold would leave the caller in the hold with nobody coming back to them: a
|
|
1963
|
+
// host holds it back (command.endCall.held), and an adapter that receives it anyway must refuse
|
|
1964
|
+
// it -- so the test sends it past the validator and expects failed.
|
|
1965
|
+
const endCallRefusedOnHold = async () => {
|
|
1966
|
+
ruleTested("test.command.rejected", "test.command.held");
|
|
1967
|
+
let answer;
|
|
1968
|
+
try {
|
|
1969
|
+
answer = await call.connection.execute({ assignmentId: taskId, command: { type: "end-call" } });
|
|
1970
|
+
}
|
|
1971
|
+
catch (error) {
|
|
1972
|
+
refuse("test.command.rejected", "test.command.end-call", `execute rejected rather than answered: ${String(error)}`);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (isRecord(answer) && answer.status !== "failed") {
|
|
1976
|
+
refuse("test.command.held", "test.command.end-call", "the provider ended the agent's part with the caller on hold: end-call waits for resume, and the adapter is the second gate");
|
|
1977
|
+
// Applied, the task leaves paused; the run reads where the adapter put it rather than resuming a call it ended.
|
|
1978
|
+
await updated(t => t.phase !== "paused", "the task after an end-call applied from hold");
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1962
1981
|
// 4. Hold and resume, where offered.
|
|
1963
1982
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
1964
|
-
if (await send({ type:
|
|
1983
|
+
if (await send({ type: call.channel === "chat" ? "pause" : "hold" }) !== undefined) {
|
|
1965
1984
|
if (await updated(t => t.phase === "paused", "the task pausing after hold") !== undefined) {
|
|
1966
|
-
if (
|
|
1985
|
+
if (call.channel === "voice" && offers("endCall"))
|
|
1986
|
+
await endCallRefusedOnHold();
|
|
1987
|
+
// An adapter that ended the agent's part from hold has nothing left to resume; the run goes on from where it put the task.
|
|
1988
|
+
if (latestTask().phase === "paused" && await send({ type: "resume" }) !== undefined) {
|
|
1967
1989
|
await updated(t => t.phase === "in-progress", "the task resuming after resume");
|
|
1968
1990
|
}
|
|
1969
1991
|
}
|
|
@@ -1973,14 +1995,14 @@ async function driveOneCall(drive) {
|
|
|
1973
1995
|
// calendar-updated that shows it is owed within the manifest's bound. The time is the provider's
|
|
1974
1996
|
// own: a day after the instant it published the offer, so no clock of the runner's is read.
|
|
1975
1997
|
if (latestTask().phase === "in-progress" && offers("schedule")) {
|
|
1976
|
-
const publishedAt =
|
|
1998
|
+
const publishedAt = call.events.slice(0, cursor).reverse().find(envelope => {
|
|
1977
1999
|
const event = envelope.event;
|
|
1978
2000
|
return (event.type === "task-offered" || event.type === "task-updated") && isTask(event.task) && event.task.assignmentId === taskId;
|
|
1979
2001
|
})?.occurredAt;
|
|
1980
2002
|
const at = new Date(Date.parse(String(publishedAt ?? "2026-01-01T00:00:00Z")) + 24 * 60 * 60 * 1000).toISOString();
|
|
1981
2003
|
if (await send({ type: "schedule", at, note: "Follow-up promised on the call" }) !== undefined) {
|
|
1982
|
-
|
|
1983
|
-
const settle = Number(
|
|
2004
|
+
ruleTested("test.schedule.unsettled");
|
|
2005
|
+
const settle = Number(call.manifest.settleMs);
|
|
1984
2006
|
const carries = (calendar) => Array.isArray(calendar) && calendar.some(activity => isRecord(activity) && activity.startsAt === at);
|
|
1985
2007
|
let unsettled = false;
|
|
1986
2008
|
const shown = await waitFor("a calendar-updated carrying the follow-up", envelope => {
|
|
@@ -1990,13 +2012,13 @@ async function driveOneCall(drive) {
|
|
|
1990
2012
|
if (shown === undefined && unsettled) {
|
|
1991
2013
|
let resync;
|
|
1992
2014
|
try {
|
|
1993
|
-
resync = await
|
|
2015
|
+
resync = await call.connection.snapshot();
|
|
1994
2016
|
}
|
|
1995
2017
|
catch (error) {
|
|
1996
|
-
refuse("
|
|
2018
|
+
refuse("test.command.rejected", "test.schedule", `snapshot() after an unsettled schedule rejected: ${String(error)}`);
|
|
1997
2019
|
}
|
|
1998
2020
|
if (!(isRecord(resync) && carries(resync.calendar))) {
|
|
1999
|
-
refuse("
|
|
2021
|
+
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`);
|
|
2000
2022
|
}
|
|
2001
2023
|
}
|
|
2002
2024
|
}
|
|
@@ -2004,9 +2026,9 @@ async function driveOneCall(drive) {
|
|
|
2004
2026
|
// 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
|
|
2005
2027
|
// open at audio end is the provider's to close, since the provider knows the instant the audio
|
|
2006
2028
|
// ended; the host's own closing report can only follow what it hears, is answered recorded, and
|
|
2007
|
-
// changes nothing. So the
|
|
2029
|
+
// changes nothing. So the test mutes, does not report the end before end-call, and reports it
|
|
2008
2030
|
// after the audio has ended.
|
|
2009
|
-
if (
|
|
2031
|
+
if (call.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
2010
2032
|
let openLeg;
|
|
2011
2033
|
if (canRecordMute && session !== undefined) {
|
|
2012
2034
|
// The task as published before the call ends: the completing publication that follows is the restatement held to carrying this leg.
|
|
@@ -2016,7 +2038,7 @@ async function driveOneCall(drive) {
|
|
|
2016
2038
|
}
|
|
2017
2039
|
if (await send({ type: "end-call" }) !== undefined) {
|
|
2018
2040
|
// A task that completes with its audio still up is refused by the stream as it passes
|
|
2019
|
-
// (stream.taskUpdated.audioOpen); the
|
|
2041
|
+
// (stream.taskUpdated.audioOpen); the test does not also wait out the clock for an ending
|
|
2020
2042
|
// that is not coming, so the run reads by the rule and not by its timeout.
|
|
2021
2043
|
const audioEnded = await waitFor("task-audio-ended after end-call", envelope => {
|
|
2022
2044
|
const event = envelope.event;
|
|
@@ -2048,11 +2070,11 @@ async function driveOneCall(drive) {
|
|
|
2048
2070
|
session.close();
|
|
2049
2071
|
}
|
|
2050
2072
|
catch {
|
|
2051
|
-
refuse("
|
|
2073
|
+
refuse("test.openAudio.close", "test.openAudio", "the audio session threw on close");
|
|
2052
2074
|
}
|
|
2053
2075
|
}
|
|
2054
2076
|
// 6. Complete, under either mode: the agent may finish early and the provider is free to end at
|
|
2055
|
-
// once. Only a task with no wrap at all has nothing to complete, and there the
|
|
2077
|
+
// once. Only a task with no wrap at all has nothing to complete, and there the test waits for the
|
|
2056
2078
|
// provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
|
|
2057
2079
|
// email, and a voice task offering no end-call, is completed from where it stands.
|
|
2058
2080
|
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
@@ -2066,8 +2088,8 @@ async function driveOneCall(drive) {
|
|
|
2066
2088
|
// applied says the provider has completed the task, and its ending follows within the bound
|
|
2067
2089
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
2068
2090
|
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
2069
|
-
|
|
2070
|
-
const settle = Number(
|
|
2091
|
+
ruleTested("test.completion.unsettled");
|
|
2092
|
+
const settle = Number(call.manifest.settleMs);
|
|
2071
2093
|
let unsettled = false;
|
|
2072
2094
|
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
2073
2095
|
if (end !== undefined)
|
|
@@ -2075,13 +2097,13 @@ async function driveOneCall(drive) {
|
|
|
2075
2097
|
else if (unsettled) {
|
|
2076
2098
|
let resync;
|
|
2077
2099
|
try {
|
|
2078
|
-
resync = await
|
|
2100
|
+
resync = await call.connection.snapshot();
|
|
2079
2101
|
}
|
|
2080
2102
|
catch (error) {
|
|
2081
|
-
refuse("
|
|
2103
|
+
refuse("test.command.rejected", "test.completion", `snapshot() after an unsettled completion rejected: ${String(error)}`);
|
|
2082
2104
|
}
|
|
2083
2105
|
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.assignmentId === taskId);
|
|
2084
|
-
refuse("
|
|
2106
|
+
refuse("test.completion.unsettled", "test.completion", still
|
|
2085
2107
|
? `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`
|
|
2086
2108
|
: `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`);
|
|
2087
2109
|
}
|
|
@@ -2092,10 +2114,10 @@ async function driveOneCall(drive) {
|
|
|
2092
2114
|
await storeReleased();
|
|
2093
2115
|
}
|
|
2094
2116
|
// The record kept by the provider has every leg the host reported, wherever the provider restated it:
|
|
2095
|
-
// the last publication of the task is read, whether or not the
|
|
2117
|
+
// the last publication of the task is read, whether or not the test was waiting on it.
|
|
2096
2118
|
const lastPublished = () => {
|
|
2097
|
-
for (let index =
|
|
2098
|
-
const event =
|
|
2119
|
+
for (let index = call.events.length - 1; index >= 0; index -= 1) {
|
|
2120
|
+
const event = call.events[index].event;
|
|
2099
2121
|
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.assignmentId === taskId)
|
|
2100
2122
|
return event.task;
|
|
2101
2123
|
}
|
|
@@ -2112,15 +2134,15 @@ async function driveOneCall(drive) {
|
|
|
2112
2134
|
const { at } = reported;
|
|
2113
2135
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
2114
2136
|
if (leg === undefined) {
|
|
2115
|
-
refuse("
|
|
2137
|
+
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`);
|
|
2116
2138
|
}
|
|
2117
2139
|
else if (leg.mutedBy !== "host") {
|
|
2118
|
-
refuse("
|
|
2140
|
+
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`);
|
|
2119
2141
|
}
|
|
2120
2142
|
else if (reported.closedAs !== undefined) {
|
|
2121
|
-
|
|
2143
|
+
ruleTested("test.recordStep.overwritten");
|
|
2122
2144
|
if (leg.seconds !== reported.closedAs) {
|
|
2123
|
-
refuse("
|
|
2145
|
+
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`);
|
|
2124
2146
|
}
|
|
2125
2147
|
}
|
|
2126
2148
|
}
|
|
@@ -2169,7 +2191,7 @@ export class TeamStream {
|
|
|
2169
2191
|
case "team-member-updated":
|
|
2170
2192
|
case "team-member-removed":
|
|
2171
2193
|
case "team-policies-updated": {
|
|
2172
|
-
|
|
2194
|
+
ruleTested("stream.team.baseline");
|
|
2173
2195
|
if (this.members === undefined) {
|
|
2174
2196
|
return [{ rule: "stream.team.baseline", path: `${at}.type`,
|
|
2175
2197
|
message: `${event.type} arrived before the team: team-updated carries the whole team first, and it changes one member at a time after that` }];
|
|
@@ -2180,7 +2202,7 @@ export class TeamStream {
|
|
|
2180
2202
|
return [];
|
|
2181
2203
|
}
|
|
2182
2204
|
if (event.type === "team-member-removed") {
|
|
2183
|
-
|
|
2205
|
+
ruleTested("stream.teamMember.unknown");
|
|
2184
2206
|
const id = String(event.memberId);
|
|
2185
2207
|
if (!this.members.has(id)) {
|
|
2186
2208
|
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` }];
|
|
@@ -2273,7 +2295,7 @@ function watchStore(store) {
|
|
|
2273
2295
|
},
|
|
2274
2296
|
};
|
|
2275
2297
|
}
|
|
2276
|
-
/** A login store that lives in memory for the test: what most adapter tests hand `
|
|
2298
|
+
/** A login store that lives in memory for the test: what most adapter tests hand `testAdapter` as `context.store`. */
|
|
2277
2299
|
export function memoryStore() {
|
|
2278
2300
|
const kept = new Map();
|
|
2279
2301
|
return {
|