backend-manager 5.6.3 → 5.7.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CLAUDE.md +4 -3
  3. package/PROGRESS.md +34 -0
  4. package/docs/ai-library.md +62 -11
  5. package/docs/cdp-debugging.md +44 -0
  6. package/docs/cli-output.md +22 -10
  7. package/docs/mcp.md +166 -43
  8. package/docs/test-framework.md +2 -2
  9. package/package.json +1 -1
  10. package/plans/mcp2.md +247 -0
  11. package/src/cli/commands/mcp.js +8 -2
  12. package/src/cli/commands/serve.js +155 -29
  13. package/src/cli/commands/setup-tests/base-test.js +8 -0
  14. package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
  15. package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
  16. package/src/cli/commands/setup-tests/index.js +4 -0
  17. package/src/cli/commands/setup-tests/java-installed.js +26 -0
  18. package/src/cli/commands/setup.js +2 -1
  19. package/src/cli/commands/test.js +13 -0
  20. package/src/cli/index.js +14 -0
  21. package/src/cli/utils/ui.js +27 -5
  22. package/src/manager/index.js +8 -3
  23. package/src/manager/libraries/ai/index.js +45 -1
  24. package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
  25. package/src/manager/libraries/ai/providers/anthropic.js +28 -49
  26. package/src/manager/libraries/ai/providers/claude-code.js +21 -47
  27. package/src/manager/libraries/ai/providers/openai.js +154 -19
  28. package/src/manager/libraries/ai/providers/test.js +242 -0
  29. package/src/manager/libraries/email/data/disposable-domains.json +465 -0
  30. package/src/mcp/client.js +48 -13
  31. package/src/mcp/handler.js +222 -69
  32. package/src/mcp/index.js +48 -18
  33. package/src/mcp/tools.js +150 -0
  34. package/src/mcp/utils.js +108 -0
  35. package/src/test/fixtures/firebase-project/firebase.json +1 -1
  36. package/src/test/test-accounts.js +31 -0
  37. package/test/ai/tools-live.js +170 -0
  38. package/test/email/marketing-lifecycle.js +10 -5
  39. package/test/helpers/ai-test-provider.js +202 -0
  40. package/test/helpers/ai-tools-format.js +350 -0
  41. package/test/mcp/discovery.js +53 -0
  42. package/test/mcp/oauth.js +161 -0
  43. package/test/mcp/protocol.js +268 -0
  44. package/test/mcp/roles.js +168 -0
  45. package/test/mcp/utils.js +245 -0
  46. package/test/routes/marketing/webhook.js +37 -33
  47. package/.claude/settings.local.json +0 -12
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Test: MCP utility functions
3
+ * Tests resolveAuthInfo, filterToolsByRole, loadConsumerTools, buildToolMap
4
+ *
5
+ * Run: npx mgr test bem:mcp/utils
6
+ */
7
+ const path = require('path');
8
+
9
+ module.exports = {
10
+ description: 'MCP utility functions',
11
+ type: 'group',
12
+
13
+ tests: [
14
+ // --- resolveAuthInfo ---
15
+
16
+ {
17
+ name: 'resolveAuthInfo: admin key returns admin role',
18
+ async run({ assert }) {
19
+ const { resolveAuthInfo } = require('../../src/mcp/utils.js');
20
+ const saved = process.env.BACKEND_MANAGER_KEY;
21
+
22
+ try {
23
+ process.env.BACKEND_MANAGER_KEY = 'test-admin-key';
24
+ const result = resolveAuthInfo('test-admin-key');
25
+
26
+ assert.equal(result.role, 'admin', 'Should be admin');
27
+ assert.equal(result.authType, 'adminKey', 'Should be adminKey type');
28
+ assert.equal(result.token, 'test-admin-key', 'Token should match');
29
+ } finally {
30
+ process.env.BACKEND_MANAGER_KEY = saved;
31
+ }
32
+ },
33
+ },
34
+
35
+ {
36
+ name: 'resolveAuthInfo: non-admin token returns user role',
37
+ async run({ assert }) {
38
+ const { resolveAuthInfo } = require('../../src/mcp/utils.js');
39
+ const result = resolveAuthInfo('some-user-api-key');
40
+
41
+ assert.equal(result.role, 'user', 'Should be user');
42
+ assert.equal(result.authType, 'userToken', 'Should be userToken type');
43
+ },
44
+ },
45
+
46
+ {
47
+ name: 'resolveAuthInfo: empty token returns public role',
48
+ async run({ assert }) {
49
+ const { resolveAuthInfo } = require('../../src/mcp/utils.js');
50
+ const result = resolveAuthInfo('');
51
+
52
+ assert.equal(result.role, 'public', 'Should be public');
53
+ assert.equal(result.authType, 'none', 'Should be none type');
54
+ assert.equal(result.token, '', 'Token should be empty');
55
+ },
56
+ },
57
+
58
+ {
59
+ name: 'resolveAuthInfo: null/undefined token returns public role',
60
+ async run({ assert }) {
61
+ const { resolveAuthInfo } = require('../../src/mcp/utils.js');
62
+
63
+ assert.equal(resolveAuthInfo(null).role, 'public', 'null should be public');
64
+ assert.equal(resolveAuthInfo(undefined).role, 'public', 'undefined should be public');
65
+ },
66
+ },
67
+
68
+ {
69
+ name: 'resolveAuthInfo: returns public when BACKEND_MANAGER_KEY is not set',
70
+ async run({ assert }) {
71
+ const { resolveAuthInfo } = require('../../src/mcp/utils.js');
72
+ const saved = process.env.BACKEND_MANAGER_KEY;
73
+
74
+ try {
75
+ delete process.env.BACKEND_MANAGER_KEY;
76
+ const result = resolveAuthInfo('any-token');
77
+
78
+ assert.equal(result.role, 'user', 'Non-empty token with no config key should be user');
79
+ } finally {
80
+ process.env.BACKEND_MANAGER_KEY = saved;
81
+ }
82
+ },
83
+ },
84
+
85
+ // --- filterToolsByRole ---
86
+
87
+ {
88
+ name: 'filterToolsByRole: admin sees all roles',
89
+ async run({ assert }) {
90
+ const { filterToolsByRole } = require('../../src/mcp/utils.js');
91
+ const tools = [
92
+ { name: 'a', role: 'admin' },
93
+ { name: 'b', role: 'user' },
94
+ { name: 'c', role: 'public' },
95
+ ];
96
+ const result = filterToolsByRole(tools, 'admin');
97
+
98
+ assert.equal(result.length, 3, 'Admin should see all 3');
99
+ },
100
+ },
101
+
102
+ {
103
+ name: 'filterToolsByRole: user sees user + public only',
104
+ async run({ assert }) {
105
+ const { filterToolsByRole } = require('../../src/mcp/utils.js');
106
+ const tools = [
107
+ { name: 'a', role: 'admin' },
108
+ { name: 'b', role: 'user' },
109
+ { name: 'c', role: 'public' },
110
+ ];
111
+ const result = filterToolsByRole(tools, 'user');
112
+
113
+ assert.equal(result.length, 2, 'User should see 2');
114
+ assert.ok(result.some((t) => t.name === 'b'), 'Should include user tool');
115
+ assert.ok(result.some((t) => t.name === 'c'), 'Should include public tool');
116
+ assert.ok(!result.some((t) => t.name === 'a'), 'Should exclude admin tool');
117
+ },
118
+ },
119
+
120
+ {
121
+ name: 'filterToolsByRole: public sees public only',
122
+ async run({ assert }) {
123
+ const { filterToolsByRole } = require('../../src/mcp/utils.js');
124
+ const tools = [
125
+ { name: 'a', role: 'admin' },
126
+ { name: 'b', role: 'user' },
127
+ { name: 'c', role: 'public' },
128
+ ];
129
+ const result = filterToolsByRole(tools, 'public');
130
+
131
+ assert.equal(result.length, 1, 'Public should see 1');
132
+ assert.equal(result[0].name, 'c', 'Should be the public tool');
133
+ },
134
+ },
135
+
136
+ {
137
+ name: 'filterToolsByRole: tools without role default to admin',
138
+ async run({ assert }) {
139
+ const { filterToolsByRole } = require('../../src/mcp/utils.js');
140
+ const tools = [{ name: 'no-role' }];
141
+
142
+ assert.equal(filterToolsByRole(tools, 'admin').length, 1, 'Admin should see role-less tool');
143
+ assert.equal(filterToolsByRole(tools, 'user').length, 0, 'User should not see role-less tool');
144
+ assert.equal(filterToolsByRole(tools, 'public').length, 0, 'Public should not see role-less tool');
145
+ },
146
+ },
147
+
148
+ {
149
+ name: 'filterToolsByRole: unknown role treated as public',
150
+ async run({ assert }) {
151
+ const { filterToolsByRole } = require('../../src/mcp/utils.js');
152
+ const tools = [
153
+ { name: 'a', role: 'admin' },
154
+ { name: 'b', role: 'user' },
155
+ { name: 'c', role: 'public' },
156
+ ];
157
+ const result = filterToolsByRole(tools, 'garbage');
158
+
159
+ assert.equal(result.length, 1, 'Unknown role should see public only');
160
+ },
161
+ },
162
+
163
+ // --- loadConsumerTools ---
164
+
165
+ {
166
+ name: 'loadConsumerTools: returns empty array when no cwd',
167
+ async run({ assert }) {
168
+ const { loadConsumerTools } = require('../../src/mcp/utils.js');
169
+
170
+ assert.equal(loadConsumerTools(null).length, 0, 'null cwd');
171
+ assert.equal(loadConsumerTools('').length, 0, 'empty cwd');
172
+ assert.equal(loadConsumerTools(undefined).length, 0, 'undefined cwd');
173
+ },
174
+ },
175
+
176
+ {
177
+ name: 'loadConsumerTools: returns empty array for non-existent directory',
178
+ async run({ assert }) {
179
+ const { loadConsumerTools } = require('../../src/mcp/utils.js');
180
+ const result = loadConsumerTools('/tmp/does-not-exist-12345');
181
+
182
+ assert.equal(result.length, 0, 'Should return empty array');
183
+ },
184
+ },
185
+
186
+ // --- buildToolMap ---
187
+
188
+ {
189
+ name: 'buildToolMap: consumer tools override built-ins with same name',
190
+ async run({ assert }) {
191
+ const { buildToolMap } = require('../../src/mcp/utils.js');
192
+ const builtin = [{ name: 'tool_a', description: 'original' }];
193
+ const consumer = [{ name: 'tool_a', description: 'override', _consumer: true }];
194
+
195
+ const map = buildToolMap(builtin, consumer);
196
+ assert.equal(map.get('tool_a').description, 'override', 'Consumer should override');
197
+ assert.equal(map.get('tool_a')._consumer, true, 'Should be marked as consumer');
198
+ },
199
+ },
200
+
201
+ {
202
+ name: 'buildToolMap: merges non-overlapping tools',
203
+ async run({ assert }) {
204
+ const { buildToolMap } = require('../../src/mcp/utils.js');
205
+ const builtin = [{ name: 'a' }, { name: 'b' }];
206
+ const consumer = [{ name: 'c' }];
207
+
208
+ const map = buildToolMap(builtin, consumer);
209
+ assert.equal(map.size, 3, 'Should have 3 tools total');
210
+ assert.ok(map.has('a'), 'Should have a');
211
+ assert.ok(map.has('b'), 'Should have b');
212
+ assert.ok(map.has('c'), 'Should have c');
213
+ },
214
+ },
215
+
216
+ // --- Real tools verification ---
217
+
218
+ {
219
+ name: 'all 19 built-in tools have a role assigned',
220
+ async run({ assert }) {
221
+ const tools = require('../../src/mcp/tools.js');
222
+
223
+ assert.equal(tools.length, 25, 'Should have 25 tools');
224
+
225
+ const missing = tools.filter((t) => !t.role);
226
+ assert.equal(missing.length, 0, `All tools should have roles, missing: ${missing.map((t) => t.name).join(', ')}`);
227
+ },
228
+ },
229
+
230
+ {
231
+ name: 'role distribution matches expected counts',
232
+ async run({ assert }) {
233
+ const tools = require('../../src/mcp/tools.js');
234
+
235
+ const admin = tools.filter((t) => t.role === 'admin');
236
+ const user = tools.filter((t) => t.role === 'user');
237
+ const pub = tools.filter((t) => t.role === 'public');
238
+
239
+ assert.equal(admin.length, 22, `Should have 22 admin tools, got ${admin.length}`);
240
+ assert.equal(user.length, 2, `Should have 2 user tools, got ${user.length}`);
241
+ assert.equal(pub.length, 1, `Should have 1 public tool, got ${pub.length}`);
242
+ },
243
+ },
244
+ ],
245
+ };
@@ -18,8 +18,12 @@
18
18
  * - Silent skip when email doesn't map to a user (shared SendGrid account scenario)
19
19
  * - Batched events processed independently
20
20
  * - Unsupported event types ignored
21
+ *
22
+ * All revoke-event tests target the dedicated `journey-webhook-revoke` account: they
23
+ * leave it with consent.marketing.status='revoked' (persistent side-effect data), which
24
+ * must never land on the shared `basic` account — revoked consent persists for the rest
25
+ * of the run and trips the email library's consent gate for later syncs of that account.
21
26
  */
22
- const { TEST_ACCOUNTS } = require('../../../src/test/test-accounts.js');
23
27
 
24
28
  // Helper — generate a unique sg_event_id per test
25
29
  function sgEventId(name) {
@@ -105,8 +109,8 @@ module.exports = {
105
109
  name: 'sendgrid-group-unsubscribe-writes-consent',
106
110
  auth: 'none',
107
111
  async run({ http, firestore, assert, accounts }) {
108
- const uid = accounts.basic.uid;
109
- const email = accounts.basic.email;
112
+ const uid = accounts['journey-webhook-revoke'].uid;
113
+ const email = accounts['journey-webhook-revoke'].email;
110
114
  const eventId = sgEventId('group-unsub');
111
115
  const eventTimestamp = Math.floor(Date.now() / 1000);
112
116
 
@@ -130,8 +134,8 @@ module.exports = {
130
134
  name: 'sendgrid-unsubscribe-event-handled',
131
135
  auth: 'none',
132
136
  async run({ http, firestore, assert, accounts }) {
133
- const uid = accounts.basic.uid;
134
- const email = accounts.basic.email;
137
+ const uid = accounts['journey-webhook-revoke'].uid;
138
+ const email = accounts['journey-webhook-revoke'].email;
135
139
  const eventId = sgEventId('unsub');
136
140
 
137
141
  const response = await http.as('none').post(
@@ -152,8 +156,8 @@ module.exports = {
152
156
  name: 'sendgrid-spamreport-event-handled',
153
157
  auth: 'none',
154
158
  async run({ http, firestore, assert, accounts }) {
155
- const uid = accounts.basic.uid;
156
- const email = accounts.basic.email;
159
+ const uid = accounts['journey-webhook-revoke'].uid;
160
+ const email = accounts['journey-webhook-revoke'].email;
157
161
  const eventId = sgEventId('spamreport');
158
162
 
159
163
  const response = await http.as('none').post(
@@ -173,8 +177,8 @@ module.exports = {
173
177
  name: 'sendgrid-hard-bounce-event-handled',
174
178
  auth: 'none',
175
179
  async run({ http, firestore, assert, accounts }) {
176
- const uid = accounts.basic.uid;
177
- const email = accounts.basic.email;
180
+ const uid = accounts['journey-webhook-revoke'].uid;
181
+ const email = accounts['journey-webhook-revoke'].email;
178
182
  const eventId = sgEventId('hard-bounce');
179
183
 
180
184
  // Only hard bounces (bounce_classification='Invalid Address') revoke consent.
@@ -195,8 +199,8 @@ module.exports = {
195
199
  name: 'sendgrid-dropped-hard-bounce-handled',
196
200
  auth: 'none',
197
201
  async run({ http, firestore, assert, accounts }) {
198
- const uid = accounts.basic.uid;
199
- const email = accounts.basic.email;
202
+ const uid = accounts['journey-webhook-revoke'].uid;
203
+ const email = accounts['journey-webhook-revoke'].email;
200
204
  const eventId = sgEventId('dropped');
201
205
 
202
206
  // 'dropped' follows the same classification filter as 'bounce'.
@@ -219,7 +223,7 @@ module.exports = {
219
223
  name: 'sendgrid-technical-bounce-ignored',
220
224
  auth: 'none',
221
225
  async run({ http, assert, accounts }) {
222
- const email = accounts.basic.email;
226
+ const email = accounts['journey-webhook-revoke'].email;
223
227
  const eventId = sgEventId('technical-bounce');
224
228
 
225
229
  // Technical bounces (DMARC, TLS, DNS) are sender-side issues — the recipient's
@@ -239,7 +243,7 @@ module.exports = {
239
243
  name: 'sendgrid-bounce-without-classification-ignored',
240
244
  auth: 'none',
241
245
  async run({ http, assert, accounts }) {
242
- const email = accounts.basic.email;
246
+ const email = accounts['journey-webhook-revoke'].email;
243
247
  const eventId = sgEventId('unclassified-bounce');
244
248
 
245
249
  // No bounce_classification — can't confirm a hard bounce, so skip.
@@ -258,7 +262,7 @@ module.exports = {
258
262
  name: 'sendgrid-delivered-event-ignored',
259
263
  auth: 'none',
260
264
  async run({ http, firestore, assert, accounts }) {
261
- const email = accounts.basic.email;
265
+ const email = accounts['journey-webhook-revoke'].email;
262
266
  const eventId = sgEventId('delivered');
263
267
 
264
268
  const response = await http.as('none').post(
@@ -276,7 +280,7 @@ module.exports = {
276
280
  name: 'sendgrid-open-event-ignored',
277
281
  auth: 'none',
278
282
  async run({ http, assert, accounts }) {
279
- const email = accounts.basic.email;
283
+ const email = accounts['journey-webhook-revoke'].email;
280
284
  const eventId = sgEventId('open');
281
285
 
282
286
  const response = await http.as('none').post(
@@ -317,8 +321,8 @@ module.exports = {
317
321
  name: 'sendgrid-batched-events-processed-independently',
318
322
  auth: 'none',
319
323
  async run({ http, firestore, assert, accounts }) {
320
- const uid = accounts.basic.uid;
321
- const email = accounts.basic.email;
324
+ const uid = accounts['journey-webhook-revoke'].uid;
325
+ const email = accounts['journey-webhook-revoke'].email;
322
326
  const e1 = sgEventId('batch-1');
323
327
  const e2 = sgEventId('batch-2');
324
328
  const e3 = sgEventId('batch-3');
@@ -348,8 +352,8 @@ module.exports = {
348
352
  name: 'sendgrid-duplicate-event-reprocessed-idempotently',
349
353
  auth: 'none',
350
354
  async run({ http, firestore, assert, accounts }) {
351
- const uid = accounts.basic.uid;
352
- const email = accounts.basic.email;
355
+ const uid = accounts['journey-webhook-revoke'].uid;
356
+ const email = accounts['journey-webhook-revoke'].email;
353
357
  const eventId = sgEventId('duplicate');
354
358
 
355
359
  // First delivery
@@ -380,8 +384,8 @@ module.exports = {
380
384
  name: 'sendgrid-event-without-eventId-processed',
381
385
  auth: 'none',
382
386
  async run({ http, firestore, assert, accounts }) {
383
- const uid = accounts.basic.uid;
384
- const email = accounts.basic.email;
387
+ const uid = accounts['journey-webhook-revoke'].uid;
388
+ const email = accounts['journey-webhook-revoke'].email;
385
389
 
386
390
  const response = await http.as('none').post(
387
391
  `backend-manager/marketing/webhook?provider=sendgrid&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
@@ -404,8 +408,8 @@ module.exports = {
404
408
  name: 'beehiiv-subscription-unsubscribed-writes-consent',
405
409
  auth: 'none',
406
410
  async run({ http, firestore, assert, accounts, config, skip }) {
407
- const uid = accounts.basic.uid;
408
- const email = accounts.basic.email;
411
+ const uid = accounts['journey-webhook-revoke'].uid;
412
+ const email = accounts['journey-webhook-revoke'].email;
409
413
  const eventId = `_test-bh-unsub-${Date.now()}`;
410
414
  const eventISO = new Date().toISOString();
411
415
  const publicationId = config.marketing?.newsletter?.publicationId;
@@ -439,8 +443,8 @@ module.exports = {
439
443
  name: 'beehiiv-subscription-deleted-handled',
440
444
  auth: 'none',
441
445
  async run({ http, firestore, assert, accounts, config }) {
442
- const uid = accounts.basic.uid;
443
- const email = accounts.basic.email;
446
+ const uid = accounts['journey-webhook-revoke'].uid;
447
+ const email = accounts['journey-webhook-revoke'].email;
444
448
  const eventId = `_test-bh-deleted-${Date.now()}`;
445
449
  const publicationId = config.marketing?.newsletter?.publicationId;
446
450
 
@@ -468,8 +472,8 @@ module.exports = {
468
472
  name: 'beehiiv-subscription-paused-handled',
469
473
  auth: 'none',
470
474
  async run({ http, firestore, assert, accounts, config }) {
471
- const uid = accounts.basic.uid;
472
- const email = accounts.basic.email;
475
+ const uid = accounts['journey-webhook-revoke'].uid;
476
+ const email = accounts['journey-webhook-revoke'].email;
473
477
  const eventId = `_test-bh-paused-${Date.now()}`;
474
478
  const publicationId = config.marketing?.newsletter?.publicationId;
475
479
 
@@ -499,13 +503,13 @@ module.exports = {
499
503
  // Send an event with a publication_id that does NOT match this brand's pub.
500
504
  // Simulates the shared-devbeans scenario where the parent forwarder fans
501
505
  // an event to brands that don't share the publication — they silent-skip.
502
- const email = accounts.basic.email;
506
+ const email = accounts['journey-webhook-revoke'].email;
503
507
  const eventId = `_test-bh-pubmismatch-${Date.now()}`;
504
508
 
505
509
  // Snapshot revokedAt BEFORE the request so we can prove the pub-mismatch
506
510
  // handler didn't write anything new. (The basic account may already have
507
511
  // a beehiiv-sourced revoke from a prior test that legitimately fired.)
508
- const beforeDoc = await firestore.get(`users/${accounts.basic.uid}`);
512
+ const beforeDoc = await firestore.get(`users/${accounts['journey-webhook-revoke'].uid}`);
509
513
  const beforeRevokedAt = beforeDoc?.consent?.marketing?.revokedAt || null;
510
514
 
511
515
  const response = await http.as('none').post(
@@ -527,7 +531,7 @@ module.exports = {
527
531
 
528
532
  // Reload the user doc and verify revokedAt is byte-equivalent to before —
529
533
  // pub-mismatch must not write a new revoke entry.
530
- const afterDoc = await firestore.get(`users/${accounts.basic.uid}`);
534
+ const afterDoc = await firestore.get(`users/${accounts['journey-webhook-revoke'].uid}`);
531
535
  const afterRevokedAt = afterDoc?.consent?.marketing?.revokedAt || null;
532
536
 
533
537
  assert.deepEqual(
@@ -568,7 +572,7 @@ module.exports = {
568
572
  auth: 'none',
569
573
  async run({ http, assert, accounts, config }) {
570
574
  // 'subscription.created' (new signup) is NOT a revoke — should be ignored.
571
- const email = accounts.basic.email;
575
+ const email = accounts['journey-webhook-revoke'].email;
572
576
  const publicationId = config.marketing?.newsletter?.publicationId;
573
577
  const eventId = `_test-bh-created-${Date.now()}`;
574
578
 
@@ -593,8 +597,8 @@ module.exports = {
593
597
  name: 'beehiiv-duplicate-event-reprocessed-idempotently',
594
598
  auth: 'none',
595
599
  async run({ http, firestore, assert, accounts, config, skip }) {
596
- const uid = accounts.basic.uid;
597
- const email = accounts.basic.email;
600
+ const uid = accounts['journey-webhook-revoke'].uid;
601
+ const email = accounts['journey-webhook-revoke'].email;
598
602
  const publicationId = config.marketing?.newsletter?.publicationId;
599
603
  const eventId = `_test-bh-dup-${Date.now()}`;
600
604
 
@@ -1,12 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(grep -B2 -A20 \"Generate a newsletter preview\" /tmp/bem-test-run-3.log | head -50)",
5
- "Bash(sed 's/\\\\x1b\\\\[[0-9;]*m//g')",
6
- "Read(//tmp/**)",
7
- "Bash(TEST_EXTENDED_MODE=1 npx mgr test 2>&1 | sed 's/\\\\x1b\\\\[[0-9;]*m//g' > /tmp/bem-run-6.log; grep -E \"passing|failing|skipped\" /tmp/bem-run-6.log | tail -5)",
8
- "Bash(awk -F'`' '{print $2}')",
9
- "Bash(TEST_EXTENDED_MODE=1 npx mgr test 2>&1 | sed 's/\\\\x1b\\\\[[0-9;]*m//g' > /tmp/bem-cleanup-run2.log; grep -E \"\\(passing|failing|skipped\\)\" /tmp/bem-cleanup-run2.log | tail -5)"
10
- ]
11
- }
12
- }