backend-manager 5.1.2 → 5.1.4

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +15 -0
  3. package/docs/marketing-campaigns.md +41 -4
  4. package/docs/testing.md +45 -0
  5. package/package.json +1 -1
  6. package/src/cli/commands/emulator.js +18 -1
  7. package/src/cli/commands/test.js +18 -0
  8. package/src/defaults/CLAUDE.md +7 -5
  9. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  10. package/src/manager/index.js +82 -5
  11. package/src/manager/libraries/ai/index.js +21 -0
  12. package/src/manager/libraries/ai/providers/openai.js +75 -0
  13. package/src/manager/libraries/email/data/disposable-domains.json +12 -0
  14. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  15. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  16. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  17. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  18. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  19. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  20. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  21. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  22. package/src/manager/libraries/email/generators/newsletter.js +152 -5
  23. package/src/manager/libraries/email/providers/beehiiv.js +7 -1
  24. package/src/manager/routes/admin/post/post.js +3 -3
  25. package/src/manager/routes/test/health/get.js +17 -0
  26. package/src/test/run-tests.js +30 -0
  27. package/src/test/runner.js +11 -8
  28. package/src/test/utils/test-mode-file.js +192 -0
  29. package/test/marketing/fixtures/clean.json +2 -3
  30. package/test/marketing/fixtures/editorial.json +2 -3
  31. package/test/marketing/fixtures/field-report.json +3 -4
  32. package/test/marketing/newsletter-generate.js +62 -48
  33. package/test/marketing/newsletter-templates.js +12 -33
  34. package/test/routes/admin/create-post.js +2 -2
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Content schema (template-owned — different from the classic schema):
18
18
  * { tldr, dateline, dispatches: [{ kicker, headline, byline, location,
19
- * lede, dispatch, dataPoints?, cta?, image_prompt }] }
19
+ * lede, dispatch, dataPoints?, image_prompt }] }
20
20
  */
21
21
  const {
22
22
  shell,
@@ -337,7 +337,7 @@ const FIELD_REPORT_SCHEMA = {
337
337
  items: {
338
338
  type: 'object',
339
339
  additionalProperties: false,
340
- required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'cta', 'dataPoints'],
340
+ required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'dataPoints'],
341
341
  properties: {
342
342
  kicker: { type: 'string', maxLength: 30 }, // "DISPATCH" / "FIELD NOTES" / "WATCH" / "BRIEF"
343
343
  headline: { type: 'string', maxLength: 90 }, // Tight, declarative
@@ -358,15 +358,10 @@ const FIELD_REPORT_SCHEMA = {
358
358
  },
359
359
  },
360
360
  },
361
- cta: {
362
- type: ['object', 'null'],
363
- additionalProperties: false,
364
- required: ['label', 'url'],
365
- properties: {
366
- label: { type: 'string' },
367
- url: { type: 'string' },
368
- },
369
- },
361
+ // CTAs intentionally not part of the contract — the AI cannot author URLs
362
+ // reliably (no source URLs available, no brand-site knowledge). Newsletters
363
+ // are self-contained; outbound links come from sponsorship blocks rendered
364
+ // by the template shell, not from generated dispatch bodies.
370
365
  image_prompt: { type: 'string' },
371
366
  },
372
367
  },
@@ -390,7 +385,6 @@ function normalizeFieldReport(structure, { brand } = {}) {
390
385
  lede: d.lede || '',
391
386
  dispatch: d.dispatch || '',
392
387
  dataPoints: Array.isArray(d.dataPoints) ? d.dataPoints.slice(0, 4) : [],
393
- cta: d.cta || null,
394
388
  image_prompt: d.image_prompt || '',
395
389
  }));
396
390
 
@@ -439,6 +433,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
439
433
  'CONTENT REQUIREMENTS:',
440
434
  '- subject: ≤60 chars, declarative, no clickbait. Reads like a wire-service headline.',
441
435
  '- preheader: ≤100 chars, complements subject.',
436
+ '- summary: 2-3 sentences, plain text, no markdown. An editorial recap of the whole issue (distinct from the in-template `tldr` strip — the summary is consumed by external surfaces like the share preview / summary.md file).',
437
+ '- tags: 3-5 topical tags. Lowercase, kebab-case, no spaces. Examples: "linkedin", "creator-economy", "platform-policy". Empty array OK if nothing fits cleanly.',
442
438
  '- tldr: 2 short sentences max, ~200 chars total. Present tense. Reads like a terminal briefing — what changed, why it matters.',
443
439
  '- dateline: one city or "REMOTE" — sets where the issue is filed from. UPPERCASE. Example: "LOS ANGELES" / "REMOTE" / "NEW YORK".',
444
440
  '- dispatches: 3-5 items, each is a discrete filed story.',
@@ -449,8 +445,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
449
445
  ' - lede: one paragraph (1-2 sentences), italic-serif quality, sets the scene in present tense. Reads like the opening of a New Yorker article.',
450
446
  ' - dispatch: the body. 90-160 words. Markdown allowed. Present tense. Specific. End with the practical implication for the reader.',
451
447
  ' - dataPoints: 2-4 short label/value pairs IF there are meaningful numbers in the topic. Example: [{label:"USERS REACHED",value:"12.4K"},{label:"WoW GROWTH",value:"+38%"}]. SKIP this (empty array) if the topic has no quantifiable data.',
452
- ' - cta: { label, url } OR null. Label is mono, uppercase, short (≤24 chars). Examples: "READ THE BRIEF", "SEE THE NUMBERS".',
453
448
  ' - image_prompt: one-sentence visual brief for an illustrator. Specific. Think editorial illustration, not stock photo.',
449
+ ' - DO NOT invent CTAs, "read more" links, or any URLs anywhere in the dispatch. The dispatch must stand on its own without sending the reader off-property.',
454
450
  `- signoff: render as TWO LINES with a literal \\n between them. Format: a short closing phrase + the desk name. Examples:\n "— Stay sharp,\\nThe ${brandName} Desk"\n "— Until next dispatch,\\nThe ${brandName} Editorial Desk"\n Do NOT write a summary, motto, or thematic sentence. This is a literal sign-off.`,
455
451
  '',
456
452
  'OUTPUT:',
@@ -488,7 +484,7 @@ module.exports = {
488
484
  name: 'field-report',
489
485
  description: 'Wire-service correspondent × Bloomberg terminal. Dispatch kickers, datelines, mono data callouts, end-of-dispatch terminators.',
490
486
  requires: ['subject', 'preheader', 'tldr', 'dateline', 'dispatches', 'signoff'],
491
- optional: ['citations', 'dataPoints', 'cta', 'image_prompt'],
487
+ optional: ['citations', 'dataPoints', 'image_prompt'],
492
488
  supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
493
489
  },
494
490
  schema: FIELD_REPORT_SCHEMA,
@@ -24,6 +24,7 @@ const { filterSources } = require('./lib/filter.js');
24
24
  const { generateStructure } = require('./lib/structure.js');
25
25
  const { generateSectionImage } = require('./lib/svg-illustrator.js');
26
26
  const { renderNewsletter } = require('./lib/mjml-template.js');
27
+ const { renderMarkdown } = require('./lib/markdown-renderer.js');
27
28
  const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
28
29
 
29
30
  /**
@@ -208,16 +209,34 @@ async function generate(Manager, assistant, settings, opts = {}) {
208
209
  sponsorships,
209
210
  });
210
211
 
211
- // 3b. Upload the rendered HTML to GitHub alongside the images. Lives in the
212
- // same {brandId}/{campaignId}/ folder as newsletter.html. The folder URL
213
- // becomes the canonical archive of the issue, browseable + downloadable.
212
+ // 3a. Programmatic markdown view same `structure`, walked by code (no AI).
213
+ // The markdown is split into ## blocks per section so it can be pasted
214
+ // into Beehiiv's block editor with ad blocks inserted between dispatches.
215
+ // `summary` is a separate short editorial recap, written by the AI
216
+ // during the structure step.
217
+ const markdown = renderMarkdown({
218
+ structure,
219
+ brand,
220
+ imagePaths,
221
+ sponsorships,
222
+ });
223
+
224
+ const summaryText = (structure.summary || '').trim();
225
+
226
+ // 3b. Upload the rendered HTML + markdown + summary to GitHub alongside the
227
+ // images. All four kinds live in the same {brandId}/{campaignId}/ folder.
228
+ // The folder URL becomes the canonical archive of the issue.
214
229
  let assetsFolderUrl = null;
215
230
  let htmlUrl = null;
231
+ let markdownUrl = null;
232
+ let summaryUrl = null;
216
233
 
217
234
  if (host === 'github') {
218
235
  try {
219
236
  const upload = await uploadAssets({
220
237
  html,
238
+ markdown,
239
+ summary: summaryText || undefined,
221
240
  brandId: brand?.id,
222
241
  campaignId,
223
242
  subject: structure.subject,
@@ -225,6 +244,8 @@ async function generate(Manager, assistant, settings, opts = {}) {
225
244
  });
226
245
  assetsFolderUrl = upload.folderUrl;
227
246
  htmlUrl = upload.htmlUrl;
247
+ markdownUrl = upload.markdownUrl || null;
248
+ summaryUrl = upload.summaryUrl || null;
228
249
  } catch (e) {
229
250
  assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
230
251
  }
@@ -238,6 +259,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
238
259
  // succeeds regardless. beehiivConfig was already resolved at the top
239
260
  // of the function for the initial enabled-check.
240
261
  let beehiivPostId = null;
262
+ let beehiivFailureReason = null;
241
263
 
242
264
  if (host === 'github' && beehiivConfig?.enabled) {
243
265
  try {
@@ -248,6 +270,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
248
270
  subject: structure.subject,
249
271
  preheader: structure.preheader,
250
272
  content: html,
273
+ contentTags: Array.isArray(structure.tags) ? structure.tags : [],
251
274
  status: 'draft',
252
275
  });
253
276
 
@@ -256,13 +279,36 @@ async function generate(Manager, assistant, settings, opts = {}) {
256
279
  assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
257
280
  } else {
258
281
  // Expected today until Enterprise plan — log, do not throw.
259
- assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${result?.error || 'unknown'}`);
282
+ beehiivFailureReason = result?.error || 'unknown error';
283
+ assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${beehiivFailureReason}`);
260
284
  }
261
285
  } catch (e) {
286
+ beehiivFailureReason = e.message;
262
287
  assistant.error(`Newsletter generator: Beehiiv draft upload threw — ${e.message}`);
263
288
  }
264
289
  }
265
290
 
291
+ // 3d. Fallback alert email — sent to the brand's internal alerts inbox when
292
+ // Beehiiv draft creation fails. The newsletter is fully generated and
293
+ // archived to GitHub at this point, so the email contains everything
294
+ // needed for a human to manually upload to Beehiiv: HTML URL (one-shot
295
+ // paste), markdown URL (per-section blocks for ad insertion), summary
296
+ // URL, and the tags to set. Failure of THIS email is logged but never
297
+ // blocks the cron — the campaign doc is still written either way.
298
+ if (beehiivFailureReason && htmlUrl) {
299
+ await sendBeehiivFallbackEmail(Manager, assistant, {
300
+ brand,
301
+ subject: structure.subject,
302
+ preheader: structure.preheader,
303
+ tags: Array.isArray(structure.tags) ? structure.tags : [],
304
+ htmlUrl,
305
+ markdownUrl,
306
+ summaryUrl,
307
+ folderUrl: assetsFolderUrl,
308
+ reason: beehiivFailureReason,
309
+ });
310
+ }
311
+
266
312
  // 4. Mark sources as used on parent server (unless caller opted out)
267
313
  if (!opts.skipClaim) {
268
314
  const parentUrl = Manager.config?.parent;
@@ -308,8 +354,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
308
354
  campaignId,
309
355
  folderUrl: assetsFolderUrl,
310
356
  htmlUrl,
357
+ markdownUrl,
358
+ summaryUrl,
311
359
  imageUrls: imagePaths,
312
360
  beehiivPostId,
361
+ tags: Array.isArray(structure.tags) ? structure.tags : [],
313
362
  } : null;
314
363
 
315
364
  return {
@@ -318,14 +367,112 @@ async function generate(Manager, assistant, settings, opts = {}) {
318
367
  preheader: structure.preheader,
319
368
  content: '', // legacy markdown field, unused when contentHtml is set
320
369
  contentHtml: html, // pre-rendered email-safe HTML
370
+ contentMarkdown: markdown, // programmatic markdown view (per-section blocks for Beehiiv paste)
371
+ summary: summaryText, // editorial recap (separate from preheader)
372
+ tags: Array.isArray(structure.tags) ? structure.tags : [],
321
373
  structure, // structured copy for debugging / migration
322
374
  mjml, // raw MJML for debugging
323
375
  images: opts._lastImages || [], // image buffers for the iteration test to persist locally
324
- assets, // GitHub asset URLs (folder, html, images) — null in local mode
376
+ assets, // GitHub asset URLs (folder, html, md, summary, images) — null in local mode
325
377
  meta, // per-step provider/model/cost/timing telemetry
326
378
  };
327
379
  }
328
380
 
381
+ /**
382
+ * Send an internal alert email when Beehiiv draft creation fails so the
383
+ * brand team knows there's a ready newsletter waiting for manual upload.
384
+ *
385
+ * Uses `sender: 'internal'` which auto-resolves to `alerts@{brandDomain}` via
386
+ * the SENDERS table in email/constants.js. Recipient is the same alerts@
387
+ * address — a self-addressed operational alert, no human inbox involved.
388
+ *
389
+ * Errors here are logged but never thrown — the alert is best-effort. If
390
+ * brand.url is unset, the email is skipped entirely.
391
+ *
392
+ * @param {object} Manager
393
+ * @param {object} assistant
394
+ * @param {object} args
395
+ * @param {object} args.brand
396
+ * @param {string} args.subject - The newsletter's subject (used in the alert subject)
397
+ * @param {string} args.preheader
398
+ * @param {string[]} args.tags
399
+ * @param {string} args.htmlUrl - GitHub raw URL to the fully-rendered HTML
400
+ * @param {string} [args.markdownUrl] - GitHub raw URL to the per-section markdown
401
+ * @param {string} [args.summaryUrl] - GitHub raw URL to the 2-3 sentence summary
402
+ * @param {string} [args.folderUrl] - GitHub folder URL (browseable archive)
403
+ * @param {string} args.reason - Why Beehiiv upload failed (API error message)
404
+ */
405
+ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
406
+ // Send TO and FROM the same internal alerts inbox — alerts@{brandDomain}.
407
+ // The `sender: 'internal'` SENDERS entry already resolves the FROM address
408
+ // to this; we mirror the same domain for the TO so it's a self-addressed
409
+ // operational alert (no human inbox involved).
410
+ const brandDomain = (Manager.config?.brand?.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
411
+
412
+ if (!brandDomain) {
413
+ assistant.log('Newsletter generator: Beehiiv fallback email skipped — no brand.url');
414
+ return;
415
+ }
416
+
417
+ const alertsEmail = `alerts@${brandDomain}`;
418
+
419
+ try {
420
+ const email = Manager.Email(assistant);
421
+ const messageLines = [];
422
+
423
+ messageLines.push(`<strong>Beehiiv draft creation failed</strong> — the newsletter is generated and archived, but needs to be manually uploaded to Beehiiv.`);
424
+ messageLines.push('');
425
+ messageLines.push(`<strong>Failure reason:</strong> ${args.reason}`);
426
+ messageLines.push('');
427
+ messageLines.push('<strong>Newsletter details:</strong>');
428
+ messageLines.push('<ul>');
429
+ messageLines.push(`<li><strong>Subject:</strong> ${args.subject}</li>`);
430
+ messageLines.push(`<li><strong>Preheader:</strong> ${args.preheader || '(none)'}</li>`);
431
+ if (args.tags?.length) {
432
+ messageLines.push(`<li><strong>Tags:</strong> ${args.tags.join(', ')}</li>`);
433
+ }
434
+ messageLines.push('</ul>');
435
+ messageLines.push('');
436
+ messageLines.push('<strong>Assets (manual upload links):</strong>');
437
+ messageLines.push('<ul>');
438
+ messageLines.push(`<li><strong>Full HTML</strong> (one-shot paste): <a href="${args.htmlUrl}">${args.htmlUrl}</a></li>`);
439
+ if (args.markdownUrl) {
440
+ messageLines.push(`<li><strong>Per-section markdown</strong> (paste as separate blocks, ads between): <a href="${args.markdownUrl}">${args.markdownUrl}</a></li>`);
441
+ }
442
+ if (args.summaryUrl) {
443
+ messageLines.push(`<li><strong>Summary</strong> (2-3 sentence recap): <a href="${args.summaryUrl}">${args.summaryUrl}</a></li>`);
444
+ }
445
+ if (args.folderUrl) {
446
+ messageLines.push(`<li><strong>All assets</strong>: <a href="${args.folderUrl}">${args.folderUrl}</a></li>`);
447
+ }
448
+ messageLines.push('</ul>');
449
+
450
+ await email.send({
451
+ sender: 'internal', // resolves to alerts@{brandDomain}
452
+ to: alertsEmail,
453
+ copy: false, // self-addressed operational alert — no CC/BCC clutter
454
+ subject: `Newsletter ready for manual Beehiiv upload: "${args.subject}"`,
455
+ template: 'core/card',
456
+ categories: ['marketing/newsletter-manual-upload'],
457
+ data: {
458
+ email: {
459
+ preview: `Beehiiv upload failed — newsletter awaiting manual upload from ${args.folderUrl || 'GitHub archive'}`,
460
+ },
461
+ body: {
462
+ title: 'Newsletter Ready for Manual Upload',
463
+ message: messageLines.join('\n'),
464
+ },
465
+ },
466
+ });
467
+
468
+ assistant.log(`Newsletter generator: Beehiiv fallback alert sent to ${alertsEmail}`);
469
+ } catch (e) {
470
+ // Best-effort — log and move on. We don't want a misconfigured email
471
+ // setup to break the cron's Firestore write.
472
+ assistant.error(`Newsletter generator: Beehiiv fallback email failed — ${e.message}`);
473
+ }
474
+ }
475
+
329
476
  /**
330
477
  * Sum tokens + cost across all steps (filter, structure, all SVGs).
331
478
  */
@@ -329,6 +329,7 @@ async function resolveSegmentIds() {
329
329
  * @param {string} [options.subject] - Email subject line (defaults to title)
330
330
  * @param {string} [options.preheader] - Email preview text
331
331
  * @param {string} [options.content] - HTML content body
332
+ * @param {Array<string>} [options.contentTags] - Topical tags attached to the post (Beehiiv `content_tags`)
332
333
  * @param {string} [options.status] - 'draft' or 'confirmed' (default: confirmed = send)
333
334
  * @param {string} [options.sendAt] - ISO datetime to schedule, or null for immediate
334
335
  * @param {Array<string>} [options.segments] - Segment IDs to include
@@ -342,7 +343,7 @@ async function createPost(options) {
342
343
  return { success: false, error: 'Publication not found' };
343
344
  }
344
345
 
345
- const { title, subject, preheader, content, status, sendAt, segments, excludeSegments } = options;
346
+ const { title, subject, preheader, content, contentTags, status, sendAt, segments, excludeSegments } = options;
346
347
 
347
348
  try {
348
349
  const body = {
@@ -355,6 +356,11 @@ async function createPost(options) {
355
356
  body.body_content = content;
356
357
  }
357
358
 
359
+ // Tags (Beehiiv field is `content_tags`, array of strings)
360
+ if (Array.isArray(contentTags) && contentTags.length) {
361
+ body.content_tags = contentTags;
362
+ }
363
+
358
364
  // Scheduling
359
365
  if (sendAt && sendAt !== 'now') {
360
366
  body.scheduled_at = new Date(sendAt).toISOString();
@@ -82,7 +82,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
82
82
  settings.layout = settings.layout;
83
83
  settings.date = moment(settings.date || now).subtract(1, 'days').format('YYYY-MM-DD');
84
84
  settings.id = settings.id || Math.round(new Date(now).getTime() / 1000);
85
- settings.path = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
85
+ settings.directory = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
86
86
  settings.githubUser = settings.githubUser || bemRepo.user;
87
87
  settings.githubRepo = settings.githubRepo || bemRepo.name;
88
88
 
@@ -108,7 +108,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
108
108
  );
109
109
 
110
110
  // Build post file entry
111
- const postFilename = `${settings.path}/${settings.date}-${settings.url}.md`;
111
+ settings.path = `${settings.directory}/${settings.date}-${settings.url}.md`;
112
112
  const allFiles = [
113
113
  ...imageFiles.map(img => ({
114
114
  path: img.githubPath,
@@ -116,7 +116,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
116
116
  encoding: 'base64',
117
117
  })),
118
118
  {
119
- path: postFilename,
119
+ path: settings.path,
120
120
  content: Buffer.from(formattedContent).toString('base64'),
121
121
  encoding: 'base64',
122
122
  },
@@ -1,5 +1,22 @@
1
+ const path = require('path');
2
+ const { readTestMode, applyEnvFromFile } = require('../../../../test/utils/test-mode-file.js');
3
+
1
4
  module.exports = async ({ assistant, Manager }) => {
2
5
 
6
+ // Belt-and-suspenders freshness check: re-read the test-mode file before
7
+ // reporting `testExtendedMode`. fs.watch installed in Manager.init usually
8
+ // catches changes within ~50ms, but this handler hits the disk directly to
9
+ // guarantee the runner sees the actual current value even if the watcher
10
+ // missed an event. ~1ms cost on a debug endpoint.
11
+ try {
12
+ const projectDir = path.dirname(Manager.cwd);
13
+ const data = readTestMode(projectDir);
14
+ applyEnvFromFile(data);
15
+ } catch (e) {
16
+ // Non-fatal — if the file can't be read, fall through to whatever
17
+ // process.env already has.
18
+ }
19
+
3
20
  const response = {
4
21
  status: 'healthy',
5
22
  timestamp: new Date().toISOString(),
@@ -6,6 +6,13 @@
6
6
  * It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
7
7
  */
8
8
 
9
+ // Mark this process as the test runner BEFORE loading any BEM code. Manager.init()
10
+ // auto-detects this and skips Firebase Functions / server / Sentry wiring (which
11
+ // can't run outside a real Functions runtime). This is what lets tests receive a
12
+ // fully-wired Manager + assistant in their context — no per-test stub.
13
+ process.env.BEM_TEST_RUNNER = '1';
14
+
15
+ const path = require('path');
9
16
  const TestRunner = require('./runner.js');
10
17
 
11
18
  async function main() {
@@ -33,10 +40,33 @@ async function main() {
33
40
  console.error('Warning: Could not initialize Firebase Admin:', error.message);
34
41
  }
35
42
 
43
+ // Boot a real Manager. With BEM_TEST_RUNNER set, init() loads libraries +
44
+ // resolves project config but skips the parts that need a Functions runtime
45
+ // (handler wiring, server boot, Sentry, admin.initializeApp re-init).
46
+ // The resulting Manager + assistant are passed into every test context, so
47
+ // tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
48
+ // like production code does — no hand-rolled stubs.
49
+ let Manager = null;
50
+ let assistant = null;
51
+ try {
52
+ const projectDir = testConfig.projectDir || process.cwd();
53
+ const BackendManager = require('../manager/index.js');
54
+ Manager = new BackendManager();
55
+ Manager.init(null, {
56
+ cwd: path.join(projectDir, 'functions'),
57
+ log: false,
58
+ });
59
+ assistant = Manager.Assistant({}, { functionName: 'bem-test-runner', accept: 'json' });
60
+ } catch (error) {
61
+ console.error('Warning: Could not initialize BEM Manager for tests:', error.message);
62
+ }
63
+
36
64
  // Create and run the test runner
37
65
  const runner = new TestRunner({
38
66
  ...testConfig,
39
67
  admin,
68
+ Manager,
69
+ assistant,
40
70
  });
41
71
 
42
72
  const results = await runner.run();
@@ -186,15 +186,13 @@ class TestRunner {
186
186
  if (response.success) {
187
187
  console.log(chalk.green('✓'));
188
188
 
189
- // Warn if TEST_EXTENDED_MODE mismatch between test runner and emulator
190
- const runnerExtended = !!process.env.TEST_EXTENDED_MODE;
189
+ // Report the live mode the emulator just confirmed. The test command
190
+ // writes `.temp/test-mode.json` before invoking us; the emulator's
191
+ // file-watcher mutates its `process.env.TEST_EXTENDED_MODE` to match;
192
+ // the health endpoint re-reads the file as a freshness guard. By
193
+ // construction these are equal — no mismatch warning needed.
191
194
  const emulatorExtended = !!response.data?.testExtendedMode;
192
-
193
- if (runnerExtended !== emulatorExtended) {
194
- console.log(chalk.red.bold(`\n ⚠️⚠️⚠️ TEST_EXTENDED_MODE mismatch (runner=${runnerExtended}, emulator=${emulatorExtended}) ⚠️⚠️⚠️`));
195
- console.log(chalk.red(' Both must match or tests will behave unexpectedly.'));
196
- console.log(chalk.red(` Restart with: ${runnerExtended ? '' : 'TEST_EXTENDED_MODE=true '}npx bm emulator\n`));
197
- }
195
+ console.log(chalk.gray(` Mode: ${emulatorExtended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
198
196
 
199
197
  return true;
200
198
  }
@@ -706,6 +704,11 @@ class TestRunner {
706
704
  pubsub,
707
705
  skip,
708
706
  admin: this.config.admin,
707
+ // Real BEM Manager + assistant, booted by run-tests.js with BEM_TEST_RUNNER=1.
708
+ // Tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
709
+ // like production code — no stubs.
710
+ Manager: this.config.Manager,
711
+ assistant: this.config.assistant,
709
712
  rules: this.rulesContext,
710
713
  config: this.config,
711
714
  };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Shared state file that lets the test command and the running emulator
3
+ * agree on a small set of env vars without coordinated shell flags.
4
+ *
5
+ * The test command writes this file pre-flight (before invoking the runner).
6
+ * The emulator process watches it and mutates the corresponding entries in
7
+ * `process.env` in place when it changes. All existing branch sites read
8
+ * `process.env.X` per call so the mutation is invisible to them — no
9
+ * code-branch refactor needed.
10
+ *
11
+ * File lives at `<consumerProject>/.temp/test-mode.json` — `.temp/` is the
12
+ * standard transient cache directory across UJM/BXM/EM/BEM consumer projects
13
+ * (sits at the repo root, gitignored by default).
14
+ *
15
+ * ## Allowlist
16
+ *
17
+ * `SYNCED_ENV_KEYS` is the explicit list of env vars allowed to flow from the
18
+ * test command into the emulator. Adding a new live-sync var = one-line addition.
19
+ *
20
+ * Why an allowlist (not "sync everything"):
21
+ * - Some env vars are process-specific (e.g. FIRESTORE_EMULATOR_HOST is only
22
+ * correct on the test runner, never on the emulator) and would break things
23
+ * if synced. The allowlist prevents that.
24
+ * - Sensitive values (API keys) shouldn't be silently overwritten on the
25
+ * emulator just because the test runner happens to have them set.
26
+ * - Keeps mutation explicit and reviewable.
27
+ *
28
+ * File format:
29
+ * {
30
+ * "env": {
31
+ * "TEST_EXTENDED_MODE": "true"
32
+ * },
33
+ * "updatedAt": "2026-05-14T..."
34
+ * }
35
+ *
36
+ * Values are strings to match `process.env` semantics. Empty string means
37
+ * "unset" — applyEnvFromFile() will `delete process.env[key]` when the value
38
+ * is empty, matching the way Node treats unset vs falsy env vars.
39
+ */
40
+ const path = require('path');
41
+ const jetpack = require('fs-jetpack');
42
+
43
+ const TEST_MODE_FILENAME = 'test-mode.json';
44
+ const TEMP_DIR_NAME = '.temp';
45
+
46
+ // Explicit allowlist of env vars that flow from the test command into the
47
+ // running emulator. Add a key here to make it live-syncable; nothing else
48
+ // flows through.
49
+ const SYNCED_ENV_KEYS = [
50
+ 'TEST_EXTENDED_MODE',
51
+ ];
52
+
53
+ /**
54
+ * Resolve the absolute path to the test-mode file for a given consumer project.
55
+ *
56
+ * @param {string} projectDir - Consumer project root (the directory that
57
+ * contains `firebase.json` / `functions/`).
58
+ * @returns {string} Absolute path to `<projectDir>/.temp/test-mode.json`.
59
+ */
60
+ function getTestModeFilePath(projectDir) {
61
+ return path.join(projectDir, TEMP_DIR_NAME, TEST_MODE_FILENAME);
62
+ }
63
+
64
+ /**
65
+ * Read the current test-mode payload from disk. Tolerant — returns `null`
66
+ * if the file is missing or unreadable, never throws.
67
+ *
68
+ * @param {string} projectDir
69
+ * @returns {{ env: Object<string, string>, updatedAt: string } | null}
70
+ */
71
+ function readTestMode(projectDir) {
72
+ const filePath = getTestModeFilePath(projectDir);
73
+
74
+ if (!jetpack.exists(filePath)) {
75
+ return null;
76
+ }
77
+
78
+ try {
79
+ const data = jetpack.read(filePath, 'json');
80
+ if (!data || typeof data !== 'object') {
81
+ return null;
82
+ }
83
+ return {
84
+ env: (data.env && typeof data.env === 'object') ? data.env : {},
85
+ updatedAt: data.updatedAt || null,
86
+ };
87
+ } catch (e) {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Write the desired env subset to disk. Atomic via fs-jetpack. Creates the
94
+ * `.temp/` directory if missing. Filters input through SYNCED_ENV_KEYS so
95
+ * only allowlisted keys are persisted, even if the caller passes extras.
96
+ *
97
+ * @param {string} projectDir
98
+ * @param {Object<string, string|undefined>} envInput - Map of env vars to sync.
99
+ * Keys outside SYNCED_ENV_KEYS are dropped.
100
+ * Empty/undefined values are persisted as ''
101
+ * (meaning "unset on receiving side").
102
+ * @returns {string} Absolute path of the written file (for logging).
103
+ */
104
+ function writeTestMode(projectDir, envInput) {
105
+ const filePath = getTestModeFilePath(projectDir);
106
+ const env = {};
107
+
108
+ for (const key of SYNCED_ENV_KEYS) {
109
+ const v = envInput?.[key];
110
+ env[key] = v == null ? '' : String(v);
111
+ }
112
+
113
+ const payload = {
114
+ env,
115
+ updatedAt: new Date().toISOString(),
116
+ };
117
+
118
+ jetpack.write(filePath, payload, { atomic: true });
119
+
120
+ return filePath;
121
+ }
122
+
123
+ /**
124
+ * Capture the allowlisted subset of `process.env` into a plain object.
125
+ * Convenience for callers that want to write "whatever I currently have".
126
+ *
127
+ * @param {NodeJS.ProcessEnv} [source=process.env]
128
+ * @returns {Object<string, string>}
129
+ */
130
+ function captureSyncedEnv(source) {
131
+ const src = source || process.env;
132
+ const out = {};
133
+
134
+ for (const key of SYNCED_ENV_KEYS) {
135
+ out[key] = src[key] == null ? '' : String(src[key]);
136
+ }
137
+
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * Apply a `data.env` payload to the current process's `process.env`. Used by
143
+ * the watcher inside the emulator process. Returns a list of `{key, was, now}`
144
+ * for any key that actually changed (caller can log these).
145
+ *
146
+ * Empty-string values in the payload are treated as "unset" — the
147
+ * corresponding `process.env[key]` is deleted. This matches Node semantics
148
+ * where `delete process.env.X` makes `process.env.X === undefined` and
149
+ * `!!process.env.X === false`.
150
+ *
151
+ * @param {{ env: Object<string, string> } | null} data
152
+ * @returns {Array<{ key: string, was: string|undefined, now: string|undefined }>}
153
+ */
154
+ function applyEnvFromFile(data) {
155
+ if (!data || !data.env) {
156
+ return [];
157
+ }
158
+
159
+ const changed = [];
160
+
161
+ for (const key of SYNCED_ENV_KEYS) {
162
+ if (!Object.prototype.hasOwnProperty.call(data.env, key)) {
163
+ continue;
164
+ }
165
+
166
+ const was = process.env[key];
167
+ const next = data.env[key];
168
+
169
+ if (next === '' || next == null) {
170
+ if (was != null) {
171
+ delete process.env[key];
172
+ changed.push({ key, was, now: undefined });
173
+ }
174
+ } else if (was !== next) {
175
+ process.env[key] = next;
176
+ changed.push({ key, was, now: next });
177
+ }
178
+ }
179
+
180
+ return changed;
181
+ }
182
+
183
+ module.exports = {
184
+ TEST_MODE_FILENAME,
185
+ TEMP_DIR_NAME,
186
+ SYNCED_ENV_KEYS,
187
+ getTestModeFilePath,
188
+ readTestMode,
189
+ writeTestMode,
190
+ captureSyncedEnv,
191
+ applyEnvFromFile,
192
+ };