ui-steps-cross-tier.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. /**
  2. * Cross-tier channels (`src/resolution/tier-synthesizer.ts`) and the Steps
  3. * picture they make: a monorepo with a Next.js client (`apps/web`) and an
  4. * Express + NestJS API (`apps/api`) in one indexed fixture. The page's form
  5. * posts to its own route, a service puts a job on a queue that a processor
  6. * consumes, a service emits an event a listener handles, and a chat component
  7. * talks to a gateway over a socket in both directions. Mirrors
  8. * `ui-steps-api-servers.test.ts` (the servers) and `ui-steps-api.test.ts`
  9. * (the mobile app).
  10. */
  11. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as os from 'os';
  14. import * as path from 'path';
  15. import { CodeGraph } from '../src';
  16. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  17. import { buildSteps } from '../src/ui-server/api/steps';
  18. import type { Edge, Node } from '../src/types';
  19. let tmpDir: string;
  20. let cg: CodeGraph;
  21. function write(rel: string, content: string): void {
  22. const full = path.join(tmpDir, rel);
  23. fs.mkdirSync(path.dirname(full), { recursive: true });
  24. fs.writeFileSync(full, content);
  25. }
  26. beforeAll(async () => {
  27. await initGrammars();
  28. await loadAllGrammars();
  29. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-tier-'));
  30. write(
  31. 'package.json',
  32. JSON.stringify({
  33. name: 'mono',
  34. workspaces: ['apps/*'],
  35. dependencies: {
  36. next: '15',
  37. react: '19',
  38. express: '4',
  39. axios: '1',
  40. bullmq: '5',
  41. '@nestjs/common': '10',
  42. '@nestjs/core': '10',
  43. '@nestjs/bull': '10',
  44. '@nestjs/event-emitter': '2',
  45. '@nestjs/websockets': '10',
  46. 'socket.io': '4',
  47. 'socket.io-client': '4',
  48. '@prisma/client': '5',
  49. },
  50. })
  51. );
  52. // ---- The web app: a page, a client form that posts to the API and calls a
  53. // server action, a card that reads through an axios instance, a chat.
  54. write(
  55. 'apps/web/app/users/page.tsx',
  56. "import { NewUserForm } from '../../components/new-user-form'\n" +
  57. 'export default function UsersPage() {\n' +
  58. ' return <NewUserForm />\n' +
  59. '}\n'
  60. );
  61. write(
  62. 'apps/web/components/new-user-form.tsx',
  63. "'use client'\n" +
  64. "import { useCallback, useState } from 'react'\n" +
  65. "import { createUserAction } from '../app/actions'\n" +
  66. 'export function NewUserForm() {\n' +
  67. " const [email, setEmail] = useState('')\n" +
  68. ' const handleSubmit = useCallback(async (e) => {\n' +
  69. ' e.preventDefault()\n' +
  70. ' if (!email) return\n' +
  71. " const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify({ email }) })\n" +
  72. ' if (res.ok) await createUserAction({ email })\n' +
  73. ' }, [email])\n' +
  74. ' return <form onSubmit={handleSubmit}><input value={email} onChange={(e) => setEmail(e.target.value)} /></form>\n' +
  75. '}\n'
  76. );
  77. write(
  78. 'apps/web/app/actions.ts',
  79. "'use server'\n" +
  80. "import { prisma } from '../lib/db'\n" +
  81. "import { redirect } from 'next/navigation'\n" +
  82. 'export async function createUserAction(data) {\n' +
  83. ' await prisma.user.create({ data })\n' +
  84. " redirect('/users')\n" +
  85. '}\n'
  86. );
  87. write('apps/web/lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
  88. write('apps/web/lib/api.ts', "import axios from 'axios'\nexport const api = axios.create({ baseURL: '/api' })\n");
  89. write(
  90. 'apps/web/components/user-card.tsx',
  91. "'use client'\n" +
  92. "import { api } from '../lib/api'\n" +
  93. 'export function UserCard({ id, url }) {\n' +
  94. ' async function load() {\n' +
  95. ' const { data } = await api.get(`/users/${id}`)\n' +
  96. " const external = await fetch('https://api.stripe.com/v1/charges')\n" +
  97. ' const dynamic = await fetch(url)\n' +
  98. ' const orders = await fetch(`${process.env.API_URL}/api/users/${id}/orders`)\n' +
  99. ' return [data, external, dynamic, orders]\n' +
  100. ' }\n' +
  101. ' return null\n' +
  102. '}\n'
  103. );
  104. write(
  105. 'apps/web/components/chat.tsx',
  106. "'use client'\n" +
  107. "import { useEffect, useState } from 'react'\n" +
  108. "import { io } from 'socket.io-client'\n" +
  109. 'const socket = io()\n' +
  110. 'export function Chat() {\n' +
  111. ' const [messages, setMessages] = useState([])\n' +
  112. ' useEffect(() => {\n' +
  113. " socket.on('message', (m) => {\n" +
  114. ' setMessages((prev) => [...prev, m])\n' +
  115. ' })\n' +
  116. ' }, [])\n' +
  117. ' function send(text) {\n' +
  118. " socket.emit('message', text)\n" +
  119. ' }\n' +
  120. ' return null\n' +
  121. '}\n'
  122. );
  123. // ---- The API: Express routes, a queue and its Nest processor, a Nest
  124. // service emitting an event and its listener, a gateway, a BullMQ worker.
  125. write(
  126. 'apps/api/src/app.ts',
  127. "import express from 'express'\n" +
  128. "import { createUser, getUser, listOrders } from './users'\n" +
  129. 'const app = express()\n' +
  130. "app.post('/api/users', createUser)\n" +
  131. "app.get('/api/users/:id', getUser)\n" +
  132. "app.get('/api/users/:id/orders', listOrders)\n" +
  133. "const v1 = require('./v1')\n" +
  134. "app.use('/api/v1', authenticate, v1)\n" +
  135. 'export default app\n'
  136. );
  137. // A mounted router, two levels deep: its routes are written relative to the mount.
  138. write(
  139. 'apps/api/src/v1/index.ts',
  140. "import { Router } from 'express'\n" +
  141. "import ordersRouter from '../orders.routes'\n" +
  142. 'const router = Router()\n' +
  143. "router.use('/orders', ordersRouter)\n" +
  144. 'export default router\n'
  145. );
  146. write(
  147. 'apps/api/src/orders.routes.ts',
  148. "import { Router } from 'express'\n" +
  149. "import { prisma } from './db'\n" +
  150. 'const router = Router()\n' +
  151. "router.get('/', listAllOrders)\n" +
  152. "router.post('/:id/refund', refund)\n" +
  153. 'export async function listAllOrders(req, res) {\n' +
  154. ' res.json(await prisma.order.findMany())\n' +
  155. '}\n' +
  156. 'export async function refund(req, res) {\n' +
  157. ' res.status(202).end()\n' +
  158. '}\n' +
  159. 'export default router\n'
  160. );
  161. write(
  162. 'apps/web/components/orders.tsx',
  163. "'use client'\n" +
  164. "import useSWR from 'swr'\n" +
  165. 'export function Orders() {\n' +
  166. " const { data } = useSWR<Order[]>('/api/v1/orders', fetcher)\n" +
  167. ' async function loadOrders() {\n' +
  168. " const res = await fetch('/api/v1/orders')\n" +
  169. ' return res.json()\n' +
  170. ' }\n' +
  171. ' return data\n' +
  172. '}\n'
  173. );
  174. write(
  175. 'apps/api/src/users.ts',
  176. "import { prisma } from './db'\n" +
  177. "import { emailQueue, reportQueue } from './queue'\n" +
  178. 'export async function createUser(req, res) {\n' +
  179. ' const user = await prisma.user.create({ data: req.body })\n' +
  180. " await emailQueue.add('welcome', { userId: user.id })\n" +
  181. ' if (req.body.plan) {\n' +
  182. " await reportQueue.add('monthly', { userId: user.id })\n" +
  183. ' }\n' +
  184. ' res.status(201).json(user)\n' +
  185. '}\n' +
  186. 'export async function getUser(req, res) {\n' +
  187. ' const user = await prisma.user.findUnique({ where: { id: req.params.id } })\n' +
  188. ' res.json(user)\n' +
  189. '}\n' +
  190. 'export async function listOrders(req, res) {\n' +
  191. ' res.json(await prisma.order.findMany({ where: { userId: req.params.id } }))\n' +
  192. '}\n'
  193. );
  194. write('apps/api/src/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
  195. write('apps/api/src/queue.ts', "import { Queue } from 'bullmq'\nexport const emailQueue = new Queue('email')\nexport const reportQueue = new Queue('reports')\n");
  196. write(
  197. 'apps/api/src/email.processor.ts',
  198. "import { Processor, Process } from '@nestjs/bull'\n" +
  199. "@Processor('email')\n" +
  200. 'export class EmailProcessor {\n' +
  201. ' constructor(private readonly mailer: MailerService) {}\n' +
  202. " @Process('welcome')\n" +
  203. ' async sendWelcome(job) {\n' +
  204. ' await this.mailer.sendMail({ to: job.data.email })\n' +
  205. ' }\n' +
  206. '}\n'
  207. );
  208. write(
  209. 'apps/api/src/reports.worker.ts',
  210. "import { Worker } from 'bullmq'\n" +
  211. "export const reportWorker = new Worker('reports', async (job) => {\n" +
  212. ' await buildReport(job.data)\n' +
  213. '})\n' +
  214. 'export async function buildReport(data) {\n' +
  215. ' return data\n' +
  216. '}\n'
  217. );
  218. write(
  219. 'apps/api/src/users.service.ts',
  220. "import { Injectable } from '@nestjs/common'\n" +
  221. "import { EventEmitter2 } from '@nestjs/event-emitter'\n" +
  222. '@Injectable()\n' +
  223. 'export class UsersService {\n' +
  224. ' constructor(private readonly eventEmitter: EventEmitter2) {}\n' +
  225. ' async create(dto) {\n' +
  226. ' const user = { id: 1, ...dto }\n' +
  227. " this.eventEmitter.emit('user.created', user)\n" +
  228. ' return user\n' +
  229. ' }\n' +
  230. '}\n'
  231. );
  232. write(
  233. 'apps/api/src/notifications.listener.ts',
  234. "import { Injectable } from '@nestjs/common'\n" +
  235. "import { OnEvent } from '@nestjs/event-emitter'\n" +
  236. '@Injectable()\n' +
  237. 'export class NotificationsListener {\n' +
  238. " @OnEvent('user.created')\n" +
  239. ' handleUserCreated(user) {\n' +
  240. ' return notify(user)\n' +
  241. ' }\n' +
  242. " @OnEvent('user.*')\n" +
  243. ' audit(payload) {\n' +
  244. ' return log(payload)\n' +
  245. ' }\n' +
  246. " @OnEvent('order.paid')\n" +
  247. ' handleOrderPaid(order) {\n' +
  248. ' return order\n' +
  249. ' }\n' +
  250. '}\n'
  251. );
  252. write(
  253. 'apps/api/src/chat.gateway.ts',
  254. "import { WebSocketGateway, SubscribeMessage, WebSocketServer } from '@nestjs/websockets'\n" +
  255. '@WebSocketGateway()\n' +
  256. 'export class ChatGateway {\n' +
  257. ' @WebSocketServer() server\n' +
  258. " @SubscribeMessage('message')\n" +
  259. ' handleMessage(client, payload) {\n' +
  260. " this.server.emit('message', payload)\n" +
  261. ' return payload\n' +
  262. ' }\n' +
  263. '}\n'
  264. );
  265. // A test suite calling the API is the test's story: never a source.
  266. write(
  267. 'apps/api/src/__tests__/users.test.ts',
  268. "import { it } from 'vitest'\n" +
  269. "it('creates a user', async () => {\n" +
  270. " await fetch('/api/users', { method: 'POST' })\n" +
  271. '})\n'
  272. );
  273. cg = CodeGraph.initSync(tmpDir);
  274. await cg.indexAll();
  275. });
  276. afterAll(() => {
  277. cg?.close();
  278. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  279. });
  280. const q = (params: Record<string, string>) => new URLSearchParams(params);
  281. const sym = (name: string, file?: string): Node => {
  282. const found = cg.getNodesByName(name).filter((n) => n.kind !== 'route' && n.kind !== 'file' && (!file || n.filePath.endsWith(file)));
  283. if (!found[0]) throw new Error(`no symbol ${name}`);
  284. return found[0];
  285. };
  286. const route = (name: string): Node => {
  287. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  288. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  289. return r;
  290. };
  291. const synthesized = (from: Node, by: string): Edge[] =>
  292. cg.getOutgoingEdges(from.id).filter((e) => e.provenance === 'heuristic' && (e.metadata as Record<string, unknown>)?.synthesizedBy === by);
  293. const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
  294. describe('http-client: a literal path in a client call reaches its own route', () => {
  295. it('binds fetch("/api/users", { method: "POST" }) to POST /api/users, remembering the registration', () => {
  296. const edges = synthesized(sym('handleSubmit'), 'http-client');
  297. expect(edges).toHaveLength(1);
  298. expect(edges[0]!.target).toBe(route('POST /api/users').id);
  299. expect(edges[0]!.kind).toBe('calls');
  300. expect(edges[0]!.line).toBe(9);
  301. expect(edges[0]!.metadata).toEqual({
  302. synthesizedBy: 'http-client',
  303. channel: 'http',
  304. callee: 'fetch',
  305. tier: 'client→server',
  306. method: 'POST',
  307. href: '/api/users',
  308. registeredAt: 'apps/api/src/app.ts:4',
  309. });
  310. });
  311. it('joins an axios instance’s literal baseURL, matches a template hole to a :param, and a base-URL hole by the tail', () => {
  312. const edges = synthesized(sym('load'), 'http-client');
  313. const byHref = new Map(edges.map((e) => [(e.metadata as Record<string, unknown>).href, e]));
  314. expect([...byHref.keys()].sort()).toEqual(['/api/users/${…}', '/api/users/${…}/orders']);
  315. expect(byHref.get('/api/users/${…}')!.target).toBe(route('GET /api/users/:id').id);
  316. expect((byHref.get('/api/users/${…}')!.metadata as Record<string, unknown>).method).toBe('GET');
  317. expect(byHref.get('/api/users/${…}/orders')!.target).toBe(route('GET /api/users/:id/orders').id);
  318. });
  319. it('produces nothing for an external URL, a variable url, or a call in a test suite', () => {
  320. // `load` makes four calls; only two name a route (asserted above).
  321. expect(synthesized(sym('load'), 'http-client')).toHaveLength(2);
  322. const testFns = cg.getNodesInFile('apps/api/src/__tests__/users.test.ts');
  323. for (const n of testFns) expect(synthesized(n, 'http-client')).toHaveLength(0);
  324. const incoming = cg.getIncomingEdgesTo([route('POST /api/users').id], ['calls']).filter((e) => e.provenance === 'heuristic');
  325. expect(incoming.map((e) => e.source)).toEqual([sym('handleSubmit').id]);
  326. });
  327. });
  328. describe('express mounts: a mounted router’s routes are named by the path a request takes', () => {
  329. it('composes app.use("/api/v1") and router.use("/orders") onto the routes, and a client path binds to the composed name', () => {
  330. const names = cg.getNodesByKind('route').map((r) => r.name);
  331. expect(names).toContain('GET /api/v1/orders');
  332. expect(names).toContain('POST /api/v1/orders/:id/refund');
  333. expect(names).not.toContain('GET /');
  334. const edges = synthesized(sym('loadOrders'), 'http-client');
  335. expect(edges).toHaveLength(1);
  336. expect(edges[0]!.target).toBe(route('GET /api/v1/orders').id);
  337. expect((edges[0]!.metadata as Record<string, unknown>).registeredAt).toBe('apps/api/src/orders.routes.ts:4');
  338. // `useSWR<Order[]>('/api/v1/orders')` — a type argument between the name and the call.
  339. const hook = synthesized(sym('Orders'), 'http-client');
  340. expect(hook).toHaveLength(1);
  341. expect(hook[0]!.target).toBe(route('GET /api/v1/orders').id);
  342. expect((hook[0]!.metadata as Record<string, unknown>).callee).toBe('useSWR');
  343. });
  344. });
  345. describe('queue-job: a job put on a named queue reaches its consumer', () => {
  346. it('pairs emailQueue.add("welcome") with the @Process("welcome") method of the @Processor("email") class', () => {
  347. const edges = synthesized(sym('createUser'), 'queue-job');
  348. const welcome = edges.find((e) => (e.metadata as Record<string, unknown>).event === 'welcome')!;
  349. expect(welcome).toBeDefined();
  350. expect(welcome.target).toBe(sym('sendWelcome').id);
  351. expect(welcome.line).toBe(5);
  352. expect(welcome.metadata).toEqual({ synthesizedBy: 'queue-job', channel: 'queue', callee: 'emailQueue.add', event: 'welcome', queue: 'email', registeredAt: 'apps/api/src/email.processor.ts:5' });
  353. });
  354. it('pairs reportQueue.add("monthly") with the BullMQ Worker on that queue', () => {
  355. const edges = synthesized(sym('createUser'), 'queue-job');
  356. const monthly = edges.find((e) => (e.metadata as Record<string, unknown>).event === 'monthly')!;
  357. expect(monthly).toBeDefined();
  358. const target = cg.getNode(monthly.target)!;
  359. expect(target.filePath).toBe('apps/api/src/reports.worker.ts');
  360. expect((monthly.metadata as Record<string, unknown>).queue).toBe('reports');
  361. expect((monthly.metadata as Record<string, unknown>).registeredAt).toBe('apps/api/src/reports.worker.ts:2');
  362. });
  363. });
  364. describe('event-bus: an emitted event reaches its listeners; a socket message crosses tiers both ways', () => {
  365. it('pairs eventEmitter.emit("user.created") with @OnEvent("user.created") and the "user.*" glob, not "order.paid"', () => {
  366. const edges = synthesized(sym('create', 'users.service.ts'), 'event-bus');
  367. const targets = edges.map((e) => cg.getNode(e.target)!.name).sort();
  368. expect(targets).toEqual(['audit', 'handleUserCreated']);
  369. const direct = edges.find((e) => e.target === sym('handleUserCreated').id)!;
  370. expect(direct.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'event', callee: 'this.eventEmitter.emit', event: 'user.created', registeredAt: 'apps/api/src/notifications.listener.ts:5' });
  371. });
  372. it('a client’s socket.emit lands on the gateway’s @SubscribeMessage, client → server', () => {
  373. const edges = synthesized(sym('send'), 'event-bus');
  374. expect(edges).toHaveLength(1);
  375. expect(edges[0]!.target).toBe(sym('handleMessage').id);
  376. expect(edges[0]!.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'socket', callee: 'socket.emit', event: 'message', tier: 'client→server', registeredAt: 'apps/api/src/chat.gateway.ts:5' });
  377. });
  378. it('the gateway’s server.emit lands in the component that registered socket.on inline, server → client', () => {
  379. const edges = synthesized(sym('handleMessage'), 'event-bus');
  380. expect(edges).toHaveLength(1);
  381. expect(edges[0]!.target).toBe(sym('Chat').id);
  382. expect(edges[0]!.metadata).toEqual({ synthesizedBy: 'event-bus', channel: 'socket', callee: 'this.server.emit', event: 'message', tier: 'server→client', registeredAt: 'apps/web/components/chat.tsx:8' });
  383. });
  384. });
  385. describe('the Steps picture across the tiers', () => {
  386. it('draws the route as a boundary the form crosses to (⇢), and enters it on request', async () => {
  387. const boundary = await buildSteps(cg, tmpDir, q({ symbol: 'UsersPage' }));
  388. expect(boundary.project).toBe('web');
  389. const handler = boundary.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!;
  390. expect(handler).toBeDefined();
  391. expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' });
  392. const bridge = boundary.steps.find((s) => s.kind === 'bridge' && s.screen?.path === 'POST /api/users')!;
  393. expect(bridge).toBeDefined();
  394. expect(bridge.cut).toBe('screen');
  395. expect(bridge.sub).toBe('createUser');
  396. expect(bridge.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'app.ts' });
  397. const link = boundary.links.find((l) => l.from === handler.id && l.to === bridge.id)!;
  398. expect(link.kind).toBe('bridge');
  399. expect(link.synthesized).toBe(true);
  400. expect(link.when).toBe('email');
  401. expect(link.sites[0]).toMatchObject({ text: 'fetch', args: "'/api/users', { method, body }", line: 9 });
  402. expect(link.label).toContain('POST /api/users');
  403. expect(link.label).toContain('to the server');
  404. expect(link.label).toContain('registered at apps/api/src/app.ts:4');
  405. // The fetch is the crossing, not also a network call outside the index;
  406. // the route is not entered, so the handler's write is not drawn — the
  407. // server action's is, since a function the code crosses to is walked.
  408. expect(effect(boundary, 'network')).toBeUndefined();
  409. const writes = boundary.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'database');
  410. expect(writes.map((s) => s.effect!.by.name)).toEqual(['createUserAction']);
  411. const through = await buildSteps(cg, tmpDir, q({ symbol: 'UsersPage', through: '1' }));
  412. const entered = through.steps.find((s) => s.kind === 'bridge' && s.screen?.path === 'POST /api/users')!;
  413. expect(entered.cut).toBeNull();
  414. const db = through.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'database');
  415. expect(db.map((s) => s.effect!.by.name).sort()).toEqual(['createUser', 'createUserAction']);
  416. const res = effect(through, 'response')!;
  417. expect(res.label).toBe('201');
  418. expect(res.effect!.by.name).toBe('createUser');
  419. const welcome = through.steps.find((s) => s.kind === 'event' && s.event === 'welcome')!;
  420. expect(welcome).toBeDefined();
  421. expect(welcome.node!.name).toBe('sendWelcome');
  422. expect(welcome.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'welcome'", in: 'email.processor.ts' });
  423. const toWelcome = through.links.find((l) => l.to === welcome.id)!;
  424. expect(toWelcome.kind).toBe('event');
  425. expect(toWelcome.sites[0]).toMatchObject({ text: 'emailQueue.add', args: "'welcome', { userId }", line: 5 });
  426. expect(toWelcome.label).toBe('via queue-job · job welcome · queue email · registered at apps/api/src/email.processor.ts:5');
  427. expect(effect(through, 'queue')?.effect?.apis ?? []).not.toContain('emailQueue.add');
  428. const mail = effect(through, 'email')!;
  429. expect(mail.effect!.by.name).toBe('sendWelcome');
  430. });
  431. it('a server action called from a client component is a crossing to the server, by its directive', async () => {
  432. const p = await buildSteps(cg, tmpDir, q({ symbol: 'NewUserForm' }));
  433. const action = p.steps.find((s) => s.node?.name === 'createUserAction')!;
  434. expect(action).toBeDefined();
  435. expect(action.kind).toBe('bridge');
  436. const link = p.links.find((l) => l.to === action.id)!;
  437. expect(link.kind).toBe('bridge');
  438. expect(link.when).toBe('email && res.ok');
  439. expect(link.label).toContain('server action');
  440. expect(link.sites[0]).toMatchObject({ text: 'calls createUserAction', args: '{ email }' });
  441. expect(effect(p, 'database')?.effect?.by.name).toBe('createUserAction');
  442. });
  443. it('a socket message arriving in a component is an event landing, drawn as a boundary', async () => {
  444. const p = await buildSteps(cg, tmpDir, q({ symbol: 'handleMessage' }));
  445. const chat = p.steps.find((s) => s.kind === 'event' && s.node?.name === 'Chat')!;
  446. expect(chat).toBeDefined();
  447. expect(chat.event).toBe('message');
  448. expect(chat.cut).toBe('component');
  449. const link = p.links.find((l) => l.to === chat.id)!;
  450. expect(link.kind).toBe('event');
  451. expect(link.label).toContain('from the server');
  452. });
  453. });