HCC Docs

Testing flows

@hcc/sdk/testing runs a flow in-process, with no network and no credentials. It checks every returned action against our instruction schema.

import { agent } from '@hcc/sdk';
import { matchers, testCall, type HccMatchers } from '@hcc/sdk/testing';
import { expect, it } from 'vitest';

expect.extend(matchers);
declare module 'vitest' {
  interface Matchers<
    R extends void | Promise<void> = void | Promise<void>,
    T = unknown,
  > extends HccMatchers<R> {}
}

it('qualifies', async () => {
  expect(qualify).toDeclareTool('book', { boundTo: 'qualifier' });
  const t = testCall(qualify, { input: { leadId: 'l1', name: 'Ana' } });
  await t.answer();
  expect(t.actions).toConnect(agent('agt_qualifier'));
  expect(await t.agentTool('book', { slot: '3pm' })).toReplyWith({ reply: 'Booked for 3pm' });
  expect(await t.agentTool('not_interested', {})).toHangup({ disposition: 'not_interested' });
});

it('falls back when the transfer fails', async () => {
  const t = testCall(route);
  await t.answer();
  expect(await t.agentTool('wants_human', {})).toTransferTo(number('+15550100003'));
  const failed = await t.transferFailed('no_answer'); // thrown at the `yield*`
  expect(failed).toSay('Nobody is available right now.');
});

t.agentTool(name, args) runs the flow's tools, and a tool that returns handoff takes the flow on to what comes after c.connect. expect(flow).toDeclareTool(name, { boundTo? }) checks a flow's declared tools and bindings.

t.transferFailed(reason) throws TransferFailed at the yield*. If the flow doesn't catch it, the step does what a live call does, and step.fallback says so: default_flow, for one.

testCall carries a flow's history from one event to the next, and t.history and step.history show it. t.repeat() sends the last event again with the same id, so a test can check it gets the same answer.

checkReplay(flow, script, { env, input }) runs the script, then replays each event, and returns what came out differently: other instructions, a fetch during a replay, or a throw. An empty list is what you want. It's a lint and not a guarantee: it can't see a module-level Map or a database socket.

expect(
  await checkReplay(support, [
    { type: 'call.answered' },
    { type: 'call.gather', data: { digits: '2' } },
  ]),
).toEqual([]);