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();
  });
});