trigger-dispatch.test.tsx13.4 KBView on GitHub /**
* The trigger callout's `?` explainer — and the mirror behind it.
*
* A `<trigger>` block fires one of two mechanisms, and which one is invisible in
* the editor today: `cron`/`webhook`/`field-change`/`before-meeting` run every
* agent in the block directly, while `any`/`email`/`meeting`/`slack`/`external_crm`
* hand the block to an orchestrator that reads the prose and decides. The prose in
* the body means opposite things under those two, so the header now names it.
*
* Three things are pinned here:
*
* 1. THE DRIFT TEST. `PLAYBOOK_TRIGGER_DISPATCH` in apps/mail is a copy of the
* `dispatch` column of `PLAYBOOK_TRIGGER_TYPES` in apps/server. apps/mail
* cannot import across the app boundary, so this test READS THE SERVER FILE
* and parses its keys out. Add a trigger type on the server and this fails —
* which is the whole reason a duplicated table is allowed to exist.
*
* 2. THE TYPE MAPPING. The editor's `TriggerConfig` vocabulary is not the XML
* one (`conversation_change` serializes as `field-change`), so the dispatch is
* resolved through `playbookTriggerTypeFor`, a mirror of `serializeTriggerNode`.
* Skipping that step would look up a tag the document never contains and
* silently fall through to the orchestrator default.
*
* 3. THE RENDER. A real TipTap editor with the real playbook extensions, because
* the line has to survive the node view — not just the copy table.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { useEffect, useRef } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEditor, EditorContent } from '@tiptap/react';
import type { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
jest.mock('@/modules/documents/playbook/usePlaybookAop', () => ({
usePlaybookAop: () => ({
aopName: 'Deals',
conversationFieldDefs: {},
customFieldDefs: {},
statusOptions: [],
}),
}));
import {
PLAYBOOK_TRIGGER_DISPATCH,
TRIGGER_DISPATCH_EXPLAINER,
TRIGGER_EVENT_TYPES,
TRIGGER_TYPE_EXPLAINER,
triggerDispatchFor,
} from '@/modules/agents/utils/trigger-events';
import {
playbookTriggerTypeFor,
triggerDispatchForConfig,
} from '@/modules/documents/playbook/TriggerNode';
import type { TriggerConfig } from '@/modules/aop/components/TriggerConfigEditor';
import { createPlaybookExtensions } from '@/modules/documents/playbook/playbookExtensions';
// ─── 1. Drift against the server constant ────────────────────────────────────
const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..');
const SERVER_TRIGGER_TYPES = join(
REPO_ROOT,
'apps/server/src/services/playbook/trigger-types.ts',
);
/**
* Pulls `{ key=[redacted] }` out of the server's `PLAYBOOK_TRIGGER_TYPES` literal.
*
* Text parsing rather than an import: apps/mail's tsconfig and jest roots stop at
* apps/mail, and pulling a server module into the browser bundle's type graph to
* satisfy a test is the tail wagging the dog. The parse is deliberately strict —
* a shape it does not recognise yields nothing, and the count assertion below
* turns that into a failure instead of a test that passes by finding zero keys.
*/
function parseServerDispatch(source: string): Record<string, string> {
const start = source.indexOf('export const PLAYBOOK_TRIGGER_TYPES = {');
const end = source.indexOf('\n} as const', start);
if (start === -1 || end === -1) {
throw new Error('PLAYBOOK_TRIGGER_TYPES literal not found in trigger-types.ts');
}
const body = source.slice(start, end);
const entries: Record<string, string> = {};
const entryRe = /^ {2}(?:'([^']+)'|([A-Za-z_$][\w$]*)):\s*\{([\s\S]*?)^ {2}\},$/gm;
let match = entryRe.exec(body);
while (match !== null) {
const key=[redacted] ?? match[2];
const dispatch = /dispatch:\s*'(direct|orchestrator)'/.exec(match[3]);
if (key && dispatch) entries[key] = dispatch[1];
match = entryRe.exec(body);
}
return entries;
}
describe('PLAYBOOK_TRIGGER_DISPATCH mirrors the server', () => {
const server = parseServerDispatch(readFileSync(SERVER_TRIGGER_TYPES, 'utf8'));
it('parses the server constant at all (guards a vacuously green drift test)', () => {
expect(Object.keys(server).length).toBeGreaterThan(5);
expect(server.cron).toBe('direct');
expect(server.meeting).toBe('orchestrator');
});
it('has exactly the server trigger types, no more and no fewer', () => {
expect(Object.keys(PLAYBOOK_TRIGGER_DISPATCH).sort()).toEqual(Object.keys(server).sort());
});
it('agrees with the server on every type dispatch', () => {
expect({ ...PLAYBOOK_TRIGGER_DISPATCH }).toEqual(server);
});
it('notices a type added server-side (the drift it exists to catch)', () => {
// The failure mode this guards is a green test, not a red one: if the parser
// stopped recognising the literal's shape, the two assertions above would
// compare the mirror against {} and the *addition* of a server type would go
// unnoticed. So drive the parser with a source that HAS the new type and
// assert both that it is seen and that the mirror is then unequal.
const withNewType = readFileSync(SERVER_TRIGGER_TYPES, 'utf8').replace(
'\n} as const',
"\n 'form-submit': {\n fires: 'a form submission',\n carries: [],\n dispatch: 'direct',\n },\n} as const",
);
const drifted = parseServerDispatch(withNewType);
expect(drifted['form-submit']).toBe('direct');
expect(Object.keys(PLAYBOOK_TRIGGER_DISPATCH).sort()).not.toEqual(Object.keys(drifted).sort());
});
it('sends an unenumerated event name to the orchestrator, as the server does', () => {
expect(triggerDispatchFor('call')).toBe('orchestrator');
expect(triggerDispatchFor('not-a-real-trigger')).toBe('orchestrator');
});
});
// ─── 2. TriggerConfig → the tag the document actually carries ────────────────
describe('playbookTriggerTypeFor', () => {
const cases: Array<[TriggerConfig, string, 'direct' | 'orchestrator']> = [
[{ type: 'event_occurred' }, 'any', 'orchestrator'],
[{ type: 'event_occurred', eventTypes: [] }, 'any', 'orchestrator'],
[{ type: 'event_occurred', eventTypes: ['meeting'] }, 'meeting', 'orchestrator'],
[{ type: 'event_occurred', eventTypes: ['external_crm'] }, 'external_crm', 'orchestrator'],
[{ type: 'cron', schedule: '0 16 * * 5' }, 'cron', 'direct'],
[{ type: 'before_meeting', minutesBefore: 15 }, 'before-meeting', 'direct'],
[{ type: 'conversation_change' }, 'field-change', 'direct'],
[{ type: 'webhook' }, 'webhook', 'direct'],
];
it.each(cases)('%j serializes as %s (%s)', (config, tag, dispatch) => {
expect(playbookTriggerTypeFor(config)).toBe(tag);
expect(triggerDispatchForConfig(config)).toBe(dispatch);
});
it('resolves every tag it can produce against the mirror, with no fallback', () => {
// If the editor ever emits a tag the mirror lacks, the fallback would quietly
// label a direct-fire block as orchestrator-mediated — the exact reversal this
// whole line exists to prevent.
for (const [, tag] of cases) {
expect(Object.keys(PLAYBOOK_TRIGGER_DISPATCH)).toContain(tag);
}
});
});
// ─── 3. The line, rendered by the real node view ─────────────────────────────
function TriggerHarness({ config }: { config: TriggerConfig }) {
const seeded = useRef(false);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
...createPlaybookExtensions({ aopId: 'test-aop' }),
],
content: '',
immediatelyRender: true,
});
useEffect(() => {
if (editor && !seeded.current) {
seeded.current = true;
// Deferred out of the effect for the same reason CompositePlaybookDocument
// defers: TipTap renders React node views through flushSync.
queueMicrotask(() => {
if (editor.isDestroyed) return;
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'triggerNode',
attrs: { config: JSON.stringify(config) },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'BLOCK_PROSE' }] }],
},
],
} as never);
});
}
}, [editor, config]);
return <EditorContent editor={editor as Editor} />;
}
/** Open the header's `?` explainer and hand back its content. */
async function openExplainer() {
const trigger = await screen.findByRole('button', { name: /what does this trigger do/i });
await userEvent.click(trigger);
return screen.findByText(/What happens then/i);
}
describe('trigger callout header', () => {
it('says nothing about dispatch until the ? is opened', async () => {
// The explainer is REFERENCE, not status — identical for every trigger of a kind and
// never changing. Shown inline it costs a line of height on every block in the document
// forever, and stops being read within a week. This is the assertion that keeps it from
// drifting back onto the row.
render(<TriggerHarness config={{ type: 'cron', schedule: '0 16 * * 5' }} />);
expect(await screen.findByRole('button', { name: /on: cron/ })).toBeInTheDocument();
expect(screen.queryByText(TRIGGER_DISPATCH_EXPLAINER.direct)).not.toBeInTheDocument();
expect(screen.queryByText(TRIGGER_DISPATCH_EXPLAINER.orchestrator)).not.toBeInTheDocument();
});
it('explains direct dispatch on a cron trigger', async () => {
render(<TriggerHarness config={{ type: 'cron', schedule: '0 16 * * 5' }} />);
await openExplainer();
expect(screen.getByText(TRIGGER_DISPATCH_EXPLAINER.direct)).toBeInTheDocument();
expect(screen.queryByText(TRIGGER_DISPATCH_EXPLAINER.orchestrator)).not.toBeInTheDocument();
// …and what wakes it, which the pill's `on: cron · …` does not say.
expect(screen.getByText(TRIGGER_TYPE_EXPLAINER.cron)).toBeInTheDocument();
});
it('explains orchestrator dispatch on a meeting trigger', async () => {
render(<TriggerHarness config={{ type: 'event_occurred', eventTypes: ['meeting'] }} />);
await openExplainer();
expect(screen.getByText(TRIGGER_DISPATCH_EXPLAINER.orchestrator)).toBeInTheDocument();
expect(screen.queryByText(TRIGGER_DISPATCH_EXPLAINER.direct)).not.toBeInTheDocument();
});
it('spells out what "every event" means, with the same glyphs the agent view uses', async () => {
// `on: any` is the one label that names no thing. The four event types answer it —
// sourced from TRIGGER_EVENT_TYPES, so a fifth event type appears here or nowhere.
render(<TriggerHarness config={{ type: 'event_occurred' }} />);
await openExplainer();
expect(screen.getByText(TRIGGER_DISPATCH_EXPLAINER.orchestrator)).toBeInTheDocument();
for (const event of TRIGGER_EVENT_TYPES) {
expect(screen.getByText(event.short)).toBeInTheDocument();
}
});
it('names external-CRM sync without inventing a glyph for it', async () => {
// Email and Slack wear real vendor logos and a meeting is a calendar, but "the external
// CRM" is whichever of four products this user connected — so any single mark either
// names the wrong vendor or names nothing. It is listed by NAME; the gap is deliberate.
render(<TriggerHarness config={{ type: 'event_occurred' }} />);
await openExplainer();
expect(screen.getByText('External CRM sync')).toBeInTheDocument();
const crmEntry = screen.getByText('External CRM sync').closest('span');
expect(crmEntry?.querySelector('svg')).toBeNull();
// …while the events that DO have one still carry it.
const meetingEntry = screen.getByText('Meeting').closest('span');
expect(meetingEntry?.querySelector('svg')).not.toBeNull();
});
it('does not list the event glyphs for a trigger that already names its event', async () => {
render(<TriggerHarness config={{ type: 'event_occurred', eventTypes: ['meeting'] }} />);
await openExplainer();
expect(screen.queryByText('External CRM sync')).not.toBeInTheDocument();
});
it('badges an external-CRM trigger with the tag the document really carries', async () => {
// It used to read `on: crm-sync`, a type the server says has never existed:
// authoring it compiles clean and then never matches an event. The badge is
// what an author copies when hand-writing XML, so it has to say `external_crm`.
render(<TriggerHarness config={{ type: 'event_occurred', eventTypes: ['external_crm'] }} />);
expect(await screen.findByRole('button', { name: /on: external_crm/ })).toBeInTheDocument();
expect(screen.queryByText(/crm-sync/)).not.toBeInTheDocument();
});
it('keeps the explainer out of the editable body', async () => {
// The header is contentEditable={false}; anything that lands inside the editable hole
// would be typed over, deleted, and serialized into the XML.
render(<TriggerHarness config={{ type: 'webhook' }} />);
const trigger = await screen.findByRole('button', { name: /what does this trigger do/i });
expect(trigger.closest('[contenteditable="false"]')).not.toBeNull();
await userEvent.click(trigger);
expect(screen.getByText(TRIGGER_DISPATCH_EXPLAINER.direct).dataset.triggerDispatch).toBe('direct');
// …and the body prose is still there, editable, next to it.
expect(screen.getByText('BLOCK_PROSE')).toBeInTheDocument();
});
});