@manablox/workflows 0.2.0

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.
@@ -0,0 +1,555 @@
1
+ import type { Manablox, WorkflowEvent, WorkflowStep, WorkflowTrigger } from '@manablox/core';
2
+ import type { Repositories } from '@manablox/db';
3
+ import { builtinFieldTypes } from '@manablox/fields';
4
+ import type { ContentService } from '@manablox/services';
5
+ import { createServiceContext } from '@manablox/services/testing';
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
7
+ import { WorkflowEngine } from '../src/engine.js';
8
+ import type { MailMessage } from '../src/mail.js';
9
+ import { WorkflowService } from '../src/service.js';
10
+
11
+ let manablox: Manablox;
12
+ let repos: Repositories;
13
+ let content: ContentService;
14
+ let engine: WorkflowEngine;
15
+ let service: WorkflowService;
16
+ let spaceId: string;
17
+ let pageType: string;
18
+ let noteType: string;
19
+ let close: () => Promise<void>;
20
+
21
+ const mails: MailMessage[] = [];
22
+ const calls: Array<{ url: string; init: RequestInit }> = [];
23
+ let clock = new Date('2026-09-06T14:29:30.000Z');
24
+ let fetchStatus = 200;
25
+
26
+ const step = <T extends WorkflowStep['type']>(
27
+ type: T,
28
+ extra: Omit<Extract<WorkflowStep, { type: T }>, keyof WorkflowStep | 'type'> &
29
+ Partial<WorkflowStep>,
30
+ ): WorkflowStep =>
31
+ ({ id: '', name: '', enabled: true, continueOnError: false, type, ...extra }) as WorkflowStep;
32
+
33
+ const email = (extra: Partial<Extract<WorkflowStep, { type: 'email' }>> = {}) =>
34
+ step('email', {
35
+ to: ['ops@example.com'],
36
+ toRoles: [],
37
+ subject: '{{ event }}: {{ content.title }}',
38
+ body: 'By {{ actor.email }}',
39
+ html: false,
40
+ ...extra,
41
+ });
42
+
43
+ const onEvents = (events: WorkflowEvent[], typeIds: string[] = []): WorkflowTrigger => ({
44
+ kind: 'event',
45
+ events,
46
+ typeIds,
47
+ locales: [],
48
+ });
49
+
50
+ async function workflow(
51
+ name: string,
52
+ trigger: WorkflowTrigger,
53
+ steps: WorkflowStep[],
54
+ enabled = true,
55
+ ) {
56
+ return service.create(spaceId, { name, trigger, steps, enabled });
57
+ }
58
+
59
+ async function page(title: string, fields: Record<string, unknown> = {}, typeId = pageType) {
60
+ return content.create({
61
+ spaceId,
62
+ typeId,
63
+ locale: 'en',
64
+ title,
65
+ slug: title.toLowerCase().replace(/\s+/g, '-'),
66
+ fields,
67
+ });
68
+ }
69
+
70
+ beforeAll(async () => {
71
+ const ctx = await createServiceContext('workflows', {
72
+ fieldTypes: builtinFieldTypes,
73
+ contentTypes: [
74
+ { name: 'page', fields: [{ name: 'category', type: 'string' }] },
75
+ { name: 'note', fields: [] },
76
+ ],
77
+ config: { server: { adminUrl: 'https://admin.test' } },
78
+ });
79
+ manablox = ctx.manablox;
80
+ repos = ctx.repos;
81
+ content = ctx.content;
82
+
83
+ engine = new WorkflowEngine(manablox, repos, {
84
+ mailer: {
85
+ async send(message) {
86
+ mails.push(message);
87
+ return { id: 'msg-1' };
88
+ },
89
+ },
90
+ pusher: null,
91
+ fetch: (async (url: string | URL | Request, init?: RequestInit) => {
92
+ calls.push({ url: String(url), init: init ?? {} });
93
+ return new Response(JSON.stringify({ ok: fetchStatus < 400 }), { status: fetchStatus });
94
+ }) as typeof fetch,
95
+ now: () => clock,
96
+ });
97
+ engine.attach();
98
+ service = new WorkflowService(manablox, repos, engine);
99
+
100
+ // The engine reads the space's URL for the links it hands to steps.
101
+ await repos.spaces.update(ctx.spaceId, { url: 'https://site.test' });
102
+ spaceId = ctx.spaceId;
103
+ pageType = ctx.ids.page as string;
104
+ noteType = ctx.ids.note as string;
105
+
106
+ close = ctx.close;
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await close?.();
111
+ });
112
+
113
+ beforeEach(async () => {
114
+ mails.length = 0;
115
+ calls.length = 0;
116
+ fetchStatus = 200;
117
+ for (const row of await repos.workflows.listBySpace(spaceId))
118
+ await repos.workflows.delete(row.id);
119
+ });
120
+
121
+ describe('event workflows', () => {
122
+ it('runs an email step when a matching document is saved', async () => {
123
+ const wf = await workflow('Notify', onEvents(['content.saved'], [pageType]), [email()]);
124
+ const user = await repos.users.create({
125
+ name: 'Ann',
126
+ email: 'ann@example.com',
127
+ role: 'editor',
128
+ passwordHash: 'x',
129
+ });
130
+
131
+ const row = await content.create(
132
+ { spaceId, typeId: pageType, locale: 'en', title: 'Launch', slug: 'launch', fields: {} },
133
+ { userId: user.id, roles: ['owner'] },
134
+ );
135
+ await engine.idle();
136
+
137
+ expect(mails).toHaveLength(1);
138
+ expect(mails[0]).toMatchObject({
139
+ to: ['ops@example.com'],
140
+ subject: 'content.created: Launch',
141
+ text: 'By ann@example.com',
142
+ });
143
+
144
+ const [run] = await service.runs(spaceId, wf.id);
145
+ expect(run?.status).toBe('succeeded');
146
+ expect(run?.trigger).toBe('content.created');
147
+ expect(run?.context.content).toMatchObject({ id: row.id, title: 'Launch' });
148
+ expect(run?.context.url).toBe(`https://admin.test/content/${row.id}`);
149
+ expect(run?.log.map((entry) => entry.status)).toEqual(['ok']);
150
+ });
151
+
152
+ it('ignores other types, other events and disabled workflows', async () => {
153
+ await workflow('Pages only', onEvents(['content.saved'], [pageType]), [email()]);
154
+ await workflow('Publish only', onEvents(['content.published']), [email()]);
155
+ await workflow('Off', onEvents(['content.saved']), [email()], false);
156
+
157
+ await page('A note', {}, noteType);
158
+ await engine.idle();
159
+ expect(mails).toHaveLength(0);
160
+ });
161
+
162
+ it('carries the previous row on update so conditions can see what changed', async () => {
163
+ const wf = await workflow('Category changed', onEvents(['content.updated']), [
164
+ step('condition', {
165
+ match: 'all',
166
+ rules: [{ field: 'content.fields.category', operator: 'changed', value: '' }],
167
+ }),
168
+ email({ subject: 'now {{ content.fields.category }}, was {{ previous.fields.category }}' }),
169
+ ]);
170
+ const row = await page('Cat', { category: 'news' });
171
+ await engine.idle();
172
+
173
+ await content.update(row.id, {
174
+ spaceId,
175
+ typeId: pageType,
176
+ locale: 'en',
177
+ title: 'Cat',
178
+ slug: 'cat',
179
+ fields: { category: 'news' },
180
+ });
181
+ await engine.idle();
182
+ expect(mails).toHaveLength(0);
183
+ let runs = await service.runs(spaceId, wf.id);
184
+ expect(runs[0]?.status).toBe('skipped');
185
+ expect(runs[0]?.log[0]).toMatchObject({ type: 'condition', status: 'stopped' });
186
+
187
+ await content.update(row.id, {
188
+ spaceId,
189
+ typeId: pageType,
190
+ locale: 'en',
191
+ title: 'Cat',
192
+ slug: 'cat',
193
+ fields: { category: 'sport' },
194
+ });
195
+ await engine.idle();
196
+ expect(mails.map((mail) => mail.subject)).toEqual(['now sport, was news']);
197
+ runs = await service.runs(spaceId, wf.id);
198
+ expect(runs[0]?.status).toBe('succeeded');
199
+ });
200
+
201
+ it('calls an API with the signed event body and records the response', async () => {
202
+ const wf = await workflow('Sync', onEvents(['content.deleted']), [
203
+ step('http', {
204
+ method: 'POST',
205
+ url: 'https://hooks.test/{{ event }}',
206
+ headers: [{ name: 'x-token', value: 'abc' }],
207
+ body: { mode: 'event', template: '' },
208
+ secret: 's3cret',
209
+ timeoutMs: 5000,
210
+ }),
211
+ ]);
212
+ const row = await page('Gone');
213
+ await engine.idle();
214
+ await content.delete(row.id);
215
+ await engine.idle();
216
+
217
+ expect(calls).toHaveLength(1);
218
+ const call = calls[0]!;
219
+ expect(call.url).toBe('https://hooks.test/content.deleted');
220
+ const headers = new Headers(call.init.headers);
221
+ expect(headers.get('x-token')).toBe('abc');
222
+ expect(headers.get('content-type')).toBe('application/json');
223
+ expect(headers.get('x-manablox-signature')).toMatch(/^sha256=[0-9a-f]{64}$/);
224
+ const body = JSON.parse(String(call.init.body));
225
+ expect(body.content).toMatchObject({ id: row.id, title: 'Gone' });
226
+ expect(body.event).toBe('content.deleted');
227
+
228
+ const [run] = await service.runs(spaceId, wf.id);
229
+ expect(run?.log[0]).toMatchObject({ type: 'http', status: 'ok', detail: { status: 200 } });
230
+ });
231
+
232
+ it('renders a custom JSON body with typed placeholders', async () => {
233
+ await workflow('Custom', onEvents(['content.created']), [
234
+ step('http', {
235
+ method: 'PUT',
236
+ url: 'https://hooks.test/x',
237
+ headers: [],
238
+ body: {
239
+ mode: 'custom',
240
+ template:
241
+ '{"title":"{{ content.title }}","doc":"{{ content }}","v":"{{ content.version }}"}',
242
+ },
243
+ secret: null,
244
+ timeoutMs: 5000,
245
+ }),
246
+ ]);
247
+ const row = await page('Typed');
248
+ await engine.idle();
249
+ const body = JSON.parse(String(calls[0]?.init.body));
250
+ expect(body.title).toBe('Typed');
251
+ expect(body.doc.id).toBe(row.id);
252
+ expect(body.v).toBe(1);
253
+ });
254
+
255
+ it('marks the run failed on an error and continues past one flagged continueOnError', async () => {
256
+ fetchStatus = 503;
257
+ const wf = await workflow('Fragile', onEvents(['content.created']), [
258
+ step('http', {
259
+ method: 'POST',
260
+ url: 'https://down.test',
261
+ headers: [],
262
+ body: { mode: 'none', template: '' },
263
+ secret: null,
264
+ timeoutMs: 5000,
265
+ continueOnError: true,
266
+ }),
267
+ step('http', {
268
+ method: 'POST',
269
+ url: 'https://down.test',
270
+ headers: [],
271
+ body: { mode: 'none', template: '' },
272
+ secret: null,
273
+ timeoutMs: 5000,
274
+ }),
275
+ email(),
276
+ ]);
277
+ await page('Breaks');
278
+ await engine.idle();
279
+
280
+ const [run] = await service.runs(spaceId, wf.id);
281
+ expect(run?.status).toBe('failed');
282
+ expect(run?.log.map((entry) => entry.status)).toEqual(['failed', 'failed']);
283
+ expect(run?.error).toContain('HTTP 503');
284
+ expect(mails).toHaveLength(0);
285
+ });
286
+
287
+ it('reports a missing mailer in the run rather than throwing at the save', async () => {
288
+ const bare = new WorkflowEngine(manablox, repos, { now: () => clock });
289
+ const wf = await workflow('No mail', onEvents(['content.created']), [email()]);
290
+ const run = await new WorkflowService(manablox, repos, bare).runNow(
291
+ spaceId,
292
+ wf.id,
293
+ (await page('Plain')).id,
294
+ );
295
+ expect(run.status).toBe('failed');
296
+ expect(run.error).toBe('workflow.mail.notConfigured');
297
+ });
298
+
299
+ it('sends to members by role and skips a step that is switched off', async () => {
300
+ const editor = await repos.users.create({
301
+ name: 'Ed',
302
+ email: 'ed@example.com',
303
+ role: 'editor',
304
+ passwordHash: 'x',
305
+ });
306
+ const viewer = await repos.users.create({
307
+ name: 'Vi',
308
+ email: 'vi@example.com',
309
+ role: 'editor',
310
+ passwordHash: 'x',
311
+ });
312
+ await repos.users.grant(editor.id, spaceId, 'editor');
313
+ await repos.users.grant(viewer.id, spaceId, 'viewer');
314
+
315
+ const wf = await workflow('Roles', onEvents(['content.created']), [
316
+ email({ to: [], toRoles: ['editor'] }),
317
+ email({ enabled: false }),
318
+ ]);
319
+ await page('Roles');
320
+ await engine.idle();
321
+
322
+ expect(mails).toHaveLength(1);
323
+ expect(mails[0]?.to).toEqual(['ed@example.com']);
324
+ const [run] = await service.runs(spaceId, wf.id);
325
+ expect(run?.log.map((entry) => entry.status)).toEqual(['ok', 'skipped']);
326
+ });
327
+ });
328
+
329
+ describe('forks', () => {
330
+ const fork = (then: WorkflowStep[], otherwise: WorkflowStep[]): WorkflowStep =>
331
+ step('branch', {
332
+ match: 'all',
333
+ rules: [{ field: 'content.fields.category', operator: 'equals', value: 'news' }],
334
+ then,
335
+ else: otherwise,
336
+ });
337
+
338
+ it('takes the yes side when the rules hold, the no side otherwise, then carries on', async () => {
339
+ const wf = await workflow('Fork', onEvents(['content.created']), [
340
+ fork([email({ subject: 'yes' })], [email({ subject: 'no' })]),
341
+ email({ subject: 'after' }),
342
+ ]);
343
+ await page('News', { category: 'news' });
344
+ await engine.idle();
345
+ expect(mails.map((mail) => mail.subject)).toEqual(['yes', 'after']);
346
+
347
+ mails.length = 0;
348
+ await page('Sport', { category: 'sport' });
349
+ await engine.idle();
350
+ expect(mails.map((mail) => mail.subject)).toEqual(['no', 'after']);
351
+
352
+ const [run] = await service.runs(spaceId, wf.id);
353
+ expect(run?.status).toBe('succeeded');
354
+ expect(run?.log.map((entry) => [entry.type, entry.status])).toEqual([
355
+ ['branch', 'ok'],
356
+ ['email', 'ok'],
357
+ ['email', 'ok'],
358
+ ]);
359
+ expect(run?.log[0]?.detail).toEqual({ branch: 'else' });
360
+ });
361
+
362
+ it('resumes inside a side after a delay, without re-deciding the fork', async () => {
363
+ const wf = await workflow('Fork wait', onEvents(['content.created']), [
364
+ fork([step('delay', { minutes: 5 }), email({ subject: 'yes, later' })], []),
365
+ email({ subject: 'after' }),
366
+ ]);
367
+ const row = await page('Delayed news', { category: 'news' });
368
+ await engine.idle();
369
+ let [run] = await service.runs(spaceId, wf.id);
370
+ expect(run?.status).toBe('waiting');
371
+ expect(run?.cursor).toEqual([0, 'then', 1]);
372
+ expect(mails).toHaveLength(0);
373
+
374
+ // The document changes its mind meanwhile; the fork stays decided.
375
+ await content.update(row.id, {
376
+ spaceId,
377
+ typeId: pageType,
378
+ locale: 'en',
379
+ title: 'Delayed news',
380
+ slug: 'delayed-news',
381
+ fields: { category: 'sport' },
382
+ });
383
+ clock = new Date('2026-09-06T14:40:00.000Z');
384
+ await engine.tick(clock);
385
+ await engine.idle();
386
+ expect(mails.map((mail) => mail.subject)).toEqual(['yes, later', 'after']);
387
+ [run] = await service.runs(spaceId, wf.id);
388
+ expect(run?.status).toBe('succeeded');
389
+ clock = new Date('2026-09-06T14:29:30.000Z');
390
+ });
391
+
392
+ it('stops or fails inside a side like anywhere else', async () => {
393
+ fetchStatus = 500;
394
+ const wf = await workflow('Fork fail', onEvents(['content.created']), [
395
+ fork(
396
+ [
397
+ step('http', {
398
+ method: 'POST',
399
+ url: 'https://down.test',
400
+ headers: [],
401
+ body: { mode: 'none', template: '' },
402
+ secret: null,
403
+ timeoutMs: 5000,
404
+ }),
405
+ ],
406
+ [],
407
+ ),
408
+ email({ subject: 'never' }),
409
+ ]);
410
+ await page('Fails', { category: 'news' });
411
+ await engine.idle();
412
+ const [run] = await service.runs(spaceId, wf.id);
413
+ expect(run?.status).toBe('failed');
414
+ expect(run?.cursor).toEqual([0, 'then', 0]);
415
+ expect(mails).toHaveLength(0);
416
+ });
417
+
418
+ it('validates the steps inside both sides with their paths', async () => {
419
+ await expect(
420
+ workflow('Bad fork', onEvents(['content.created']), [
421
+ fork([email({ subject: '' })], [step('delay', { minutes: 0 })]),
422
+ ]),
423
+ ).rejects.toMatchObject({
424
+ details: [
425
+ { key: 'workflow.step.email.subjectRequired', path: ['steps', 0, 'then', 0, 'subject'] },
426
+ { key: 'workflow.step.delay.minutesInvalid', path: ['steps', 0, 'else', 0, 'minutes'] },
427
+ ],
428
+ });
429
+ });
430
+ });
431
+
432
+ describe('delays', () => {
433
+ it('pauses the run and resumes it from the tick once the time has come', async () => {
434
+ const wf = await workflow('Later', onEvents(['content.created']), [
435
+ step('delay', { minutes: 5 }),
436
+ email({ subject: 'after the wait' }),
437
+ ]);
438
+ await page('Patience');
439
+ await engine.idle();
440
+
441
+ let [run] = await service.runs(spaceId, wf.id);
442
+ expect(run?.status).toBe('waiting');
443
+ expect(run?.cursor).toEqual([1]);
444
+ expect(run?.resumeAt?.toISOString()).toBe('2026-09-06T14:34:30.000Z');
445
+ expect(mails).toHaveLength(0);
446
+
447
+ await engine.tick(new Date('2026-09-06T14:33:00.000Z'));
448
+ await engine.idle();
449
+ expect(mails).toHaveLength(0);
450
+
451
+ clock = new Date('2026-09-06T14:35:00.000Z');
452
+ await engine.tick(clock);
453
+ await engine.idle();
454
+ expect(mails.map((mail) => mail.subject)).toEqual(['after the wait']);
455
+ [run] = await service.runs(spaceId, wf.id);
456
+ expect(run?.status).toBe('succeeded');
457
+ expect(run?.log.map((entry) => entry.status)).toEqual(['waiting', 'ok']);
458
+ clock = new Date('2026-09-06T14:29:30.000Z');
459
+ });
460
+ });
461
+
462
+ describe('scheduled workflows', () => {
463
+ it('starts at the cron minute, once, with the selected documents', async () => {
464
+ await page('Fresh one', { category: 'x' });
465
+ await page('Fresh two', { category: 'y' });
466
+ await engine.idle();
467
+
468
+ const wf = await workflow(
469
+ 'Digest',
470
+ {
471
+ kind: 'schedule',
472
+ cron: '30 14 * * *',
473
+ timezone: 'UTC',
474
+ selection: { typeIds: [pageType], status: 'any', changedWithinHours: 24, locale: null },
475
+ perDocument: false,
476
+ },
477
+ [email({ subject: '{{ documents.length }} documents' })],
478
+ );
479
+
480
+ await engine.tick(new Date('2026-09-06T14:29:59.000Z'));
481
+ await engine.idle();
482
+ expect(mails).toHaveLength(0);
483
+
484
+ await engine.tick(new Date('2026-09-06T14:30:05.000Z'));
485
+ await engine.tick(new Date('2026-09-06T14:30:45.000Z'));
486
+ await engine.idle();
487
+ expect(mails).toHaveLength(1);
488
+ expect(Number(mails[0]?.subject.split(' ')[0])).toBeGreaterThanOrEqual(2);
489
+
490
+ const runs = await service.runs(spaceId, wf.id);
491
+ expect(runs).toHaveLength(1);
492
+ expect(runs[0]?.trigger).toBe('schedule');
493
+ expect(runs[0]?.context.documents.length).toBeGreaterThanOrEqual(2);
494
+ });
495
+
496
+ it('runs once per document when asked', async () => {
497
+ const wf = await workflow(
498
+ 'Each',
499
+ {
500
+ kind: 'schedule',
501
+ cron: '0 9 * * 1',
502
+ timezone: 'Europe/Vienna',
503
+ selection: { typeIds: [noteType], status: 'draft', changedWithinHours: null, locale: 'en' },
504
+ perDocument: true,
505
+ },
506
+ [email({ subject: 'about {{ content.title }}' })],
507
+ );
508
+ await page('Note A', {}, noteType);
509
+ await page('Note B', {}, noteType);
510
+ await engine.idle();
511
+
512
+ // Monday 7 September 2026, 09:00 Vienna summer time = 07:00 UTC.
513
+ await engine.tick(new Date('2026-09-07T07:00:10.000Z'));
514
+ await engine.idle();
515
+
516
+ expect(mails.map((mail) => mail.subject).sort()).toEqual([
517
+ 'about A note',
518
+ 'about Note A',
519
+ 'about Note B',
520
+ ]);
521
+ expect((await service.runs(spaceId, wf.id)).every((run) => run.status === 'succeeded')).toBe(
522
+ true,
523
+ );
524
+ });
525
+
526
+ it('can be run by hand, and an event workflow needs a document for that', async () => {
527
+ const scheduled = await workflow(
528
+ 'Manual',
529
+ { kind: 'schedule', cron: '0 0 * * *', timezone: 'UTC', selection: null, perDocument: false },
530
+ [email({ subject: 'manual {{ event }}' })],
531
+ );
532
+ const run = await service.runNow(spaceId, scheduled.id, null);
533
+ expect(run.status).toBe('succeeded');
534
+ expect(run.trigger).toBe('manual');
535
+ expect(mails[0]?.subject).toBe('manual manual');
536
+
537
+ const evented = await workflow('Needs doc', onEvents(['content.created']), [email()]);
538
+ await expect(service.runNow(spaceId, evented.id, null)).rejects.toMatchObject({
539
+ key: 'workflow.run.documentRequired',
540
+ });
541
+ });
542
+ });
543
+
544
+ describe('service rules', () => {
545
+ it('rejects a workflow from another space and validates on save', async () => {
546
+ const other = await repos.spaces.create({ name: 'O', machineName: 'o', url: 'https://o.test' });
547
+ const wf = await workflow('Mine', onEvents(['content.created']), [email()]);
548
+ await expect(service.get(other.id, wf.id)).rejects.toMatchObject({ key: 'workflow.notFound' });
549
+ await expect(
550
+ service.update(spaceId, wf.id, { name: '', trigger: wf.trigger, steps: [] }),
551
+ ).rejects.toMatchObject({ key: 'workflow.validation.failed' });
552
+ const flipped = await service.setEnabled(spaceId, wf.id, false);
553
+ expect(flipped.enabled).toBe(false);
554
+ });
555
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { placeholders, render, renderJson, resolvePath } from '../src/template.js';
3
+
4
+ const context = {
5
+ event: 'content.updated',
6
+ content: { title: 'Hello', fields: { tags: ['a', 'b'], count: 3, meta: { ok: true } } },
7
+ actor: null,
8
+ documents: [{ title: 'One' }, { title: 'Two' }],
9
+ };
10
+
11
+ describe('render', () => {
12
+ it('substitutes paths, with objects as JSON and gaps as empty', () => {
13
+ expect(render('{{ content.title }}!', context)).toBe('Hello!');
14
+ expect(render('{{content.fields.count}}', context)).toBe('3');
15
+ expect(render('{{ content.fields.tags }}', context)).toBe('["a","b"]');
16
+ expect(render('{{ content.fields.tags[1] }}', context)).toBe('b');
17
+ expect(render('{{ actor.email }}', context)).toBe('');
18
+ expect(render('{{ nothing.here }}', context)).toBe('');
19
+ expect(render('{{ documents.length }}', context)).toBe('2');
20
+ });
21
+
22
+ it('lists placeholders', () => {
23
+ expect(placeholders('{{ a.b }} and {{c}}')).toEqual(['a.b', 'c']);
24
+ });
25
+
26
+ it('resolves array indices', () => {
27
+ expect(resolvePath(context, 'documents.0.title')).toBe('One');
28
+ });
29
+ });
30
+
31
+ describe('renderJson', () => {
32
+ it('keeps types for a placeholder that stands alone', () => {
33
+ const out = JSON.parse(
34
+ renderJson(
35
+ '{"title":"{{ content.title }}","n":"{{ content.fields.count }}","tags":"{{ content.fields.tags }}","text":"n={{ content.fields.count }}","missing":"{{ nope }}"}',
36
+ context,
37
+ ),
38
+ );
39
+ expect(out).toEqual({ title: 'Hello', n: 3, tags: ['a', 'b'], text: 'n=3', missing: null });
40
+ });
41
+ });