backend-manager 5.1.2 → 5.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.
- package/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
package/docs/testing.md
CHANGED
|
@@ -11,6 +11,87 @@ npx mgr test # Terminal 2 - runs tests
|
|
|
11
11
|
npx mgr test
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## Test Data Cleanup — at the START of every run
|
|
15
|
+
|
|
16
|
+
**Hard rule: all LOCAL cleanup happens BEFORE the suite runs, never after.** If a previous run was killed mid-execution (Ctrl-C, OOM, emulator crash), end-of-run cleanup would never fire and the next run would inherit polluted state — broken trial-eligibility checks, leftover dispute alerts, stale webhook docs, polluted marketing-provider lists. Pre-test cleanup makes every run idempotent regardless of how the last one died.
|
|
17
|
+
|
|
18
|
+
What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` and [src/test/runner.js](../src/test/runner.js) `setupAccounts()`):
|
|
19
|
+
|
|
20
|
+
1. **`meta/stats`** doc ensured (required for on-create batch writes).
|
|
21
|
+
2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
|
|
22
|
+
3. **Marketing providers** (SendGrid + Beehiiv) — leftover test contacts removed. Runs only under `TEST_EXTENDED_MODE`. Runs **before** test users are recreated so a killed run can't leave a contact pinned to an about-to-be-recreated uid.
|
|
23
|
+
4. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
|
|
24
|
+
- Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
|
|
25
|
+
- Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
|
|
26
|
+
5. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
|
|
27
|
+
6. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
|
|
28
|
+
|
|
29
|
+
### The one exception: third-party provider cleanup runs at start AND end
|
|
30
|
+
|
|
31
|
+
The `cleanupMarketingProviders` call also fires **after** the suite completes (extended mode only). Reason: pre-run cleanup catches what a crashed previous run left behind, but during a normal-completion run we'd still leak real SendGrid/Beehiiv contacts until the NEXT run cleaned them up. By running cleanup post-suite too, each extended-mode run leaves the provider lists in the same state it found them.
|
|
32
|
+
|
|
33
|
+
This is **specifically** for third-party state that lives outside the local emulator. **Do not use trailing cleanup for Firestore or Auth data** — those follow the start-only rule because we control the local state and pre-run cleanup is enough.
|
|
34
|
+
|
|
35
|
+
### When adding a new test that writes data
|
|
36
|
+
|
|
37
|
+
| If your test writes to... | Then... |
|
|
38
|
+
|---|---|
|
|
39
|
+
| A new Firestore collection (mixed test + prod data) | Add the collection name to `testDataCollections` in `src/test/test-accounts.js`. |
|
|
40
|
+
| A new Firestore collection (test-only data) | Add it to `testOnlyCollections` in `src/test/test-accounts.js`. |
|
|
41
|
+
| Realtime Database | Use a path under `_test/...` — already wiped pre-test. |
|
|
42
|
+
| Anywhere else | Use the `_test-` doc-id prefix so id-keyed pass-2 catches it. |
|
|
43
|
+
|
|
44
|
+
### Within-run state isolation is different
|
|
45
|
+
|
|
46
|
+
Per-test cleanup is still appropriate when a test sets up DB state that would pollute a **later test in the same run** — e.g. trial-eligibility's `try/finally` that removes the fake `payments-orders/_test-trial-eligibility-*` doc so the next sibling test sees a clean slate. Those stay in the test. They are *intra-run* state management, not *next-run* cleanup, and the distinction matters.
|
|
47
|
+
|
|
48
|
+
The rule: **never put cleanup at the END of a test file or suite for the purpose of preparing the next run** — for LOCAL state. If a test cleans up local Firestore/Auth data only to "leave no trace," that cleanup belongs in the runner's pre-test phase instead. Add the collection/namespace to the runner's wipe list and remove the trailing cleanup step. The third-party provider exception (see above) lives in the runner's post-suite hook, not in individual tests.
|
|
49
|
+
|
|
50
|
+
## Extended Mode (`TEST_EXTENDED_MODE`)
|
|
51
|
+
|
|
52
|
+
Several routes/handlers skip external API calls (SendGrid, Beehiiv, Stripe webhooks, dispute handlers, marketing libraries) when `process.env.TEST_EXTENDED_MODE` is unset, so unit tests don't fire real emails or webhook side effects. Set the flag to opt **in** to those side effects for a full end-to-end run.
|
|
53
|
+
|
|
54
|
+
**Live sync — no env coordination across terminals.** The flag flows automatically from the test command to the running emulator via a small shared state file at `<projectRoot>/.temp/test-mode.json`. The test command writes the file pre-flight; the emulator's function workers watch it via `fs.watch` and mutate their own `process.env.TEST_EXTENDED_MODE` in place. Effect: you only need to set the flag on **the test command**. The emulator follows.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Terminal 1 — start once, leave running. NO flag needed.
|
|
58
|
+
npx mgr emulator
|
|
59
|
+
|
|
60
|
+
# Terminal 2 — toggle freely between runs:
|
|
61
|
+
TEST_EXTENDED_MODE=true npx mgr test ... # runs in extended mode
|
|
62
|
+
npx mgr test ... # runs in normal mode
|
|
63
|
+
TEST_EXTENDED_MODE=true npx mgr test ... # back to extended
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The emulator log shows each flip, e.g.:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[test-mode] resolved TEST_EXTENDED_MODE=false (file present) ← worker boot
|
|
70
|
+
[test-mode] flip TEST_EXTENDED_MODE: (unset) → true ← test --extended fired
|
|
71
|
+
[test-mode] flip TEST_EXTENDED_MODE: true → (unset) ← test (no flag) fired
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The test command also confirms the mode in its own output (`Test mode: EXTENDED (real APIs)` pre-flight, `Mode: EXTENDED (real APIs)` after the health check). The runner's old "TEST_EXTENDED_MODE mismatch" warning is gone — mismatch is impossible by construction.
|
|
75
|
+
|
|
76
|
+
**Allowlist.** Only env vars listed in `SYNCED_ENV_KEYS` (`src/test/utils/test-mode-file.js`) flow through. Today: `TEST_EXTENDED_MODE`. Add a key there to make a new env var live-syncable across terminals. The allowlist exists to prevent accidentally syncing process-specific vars (`FIRESTORE_EMULATOR_HOST`) or sensitive ones (API keys).
|
|
77
|
+
|
|
78
|
+
**Preferred flow: set the flag on the test command.** Every `npx mgr test` invocation overwrites the shared state file with whatever flags it was called with, and the emulator follows live. This is the recommended pattern — start the emulator once with no flag, leave it running, control the mode from your test invocations.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Recommended
|
|
82
|
+
npx mgr emulator # boots in normal mode
|
|
83
|
+
TEST_EXTENDED_MODE=true npx mgr test ... # flips emulator to extended
|
|
84
|
+
npx mgr test ... # flips emulator back to normal
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Also supported: set the flag on the emulator command.** This still works as a boot default — the emulator command writes the file with whatever it was started with, so the very first test run (before any `npx mgr test` overrides it) sees that mode. Useful if you want to inspect the emulator in a particular mode before firing any tests, or if you script the emulator boot from CI. Just know that the next test command overrides whatever you set here.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Also works (boot default — overridden by next test command)
|
|
91
|
+
TEST_EXTENDED_MODE=true npx mgr emulator # boots in extended mode
|
|
92
|
+
npx mgr test ... # ← this still flips it back to normal
|
|
93
|
+
```
|
|
94
|
+
|
|
14
95
|
## Log Files
|
|
15
96
|
|
|
16
97
|
BEM CLI commands automatically save all output to log files in `functions/` while still streaming to the console:
|
package/package.json
CHANGED
|
@@ -8,16 +8,33 @@ const powertools = require('node-powertools');
|
|
|
8
8
|
const WatchCommand = require('./watch');
|
|
9
9
|
const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
|
|
10
10
|
const { EXTENDED_MODE_WARNING } = require('../../test/utils/extended-mode-warning');
|
|
11
|
+
const { writeTestMode, captureSyncedEnv } = require('../../test/utils/test-mode-file');
|
|
11
12
|
|
|
12
13
|
class EmulatorCommand extends BaseCommand {
|
|
13
14
|
async execute() {
|
|
14
15
|
this.log(chalk.cyan('\n Starting Firebase emulator (keep-alive mode)...\n'));
|
|
15
16
|
this.log(chalk.gray(' Emulator will stay running until you press Ctrl+C\n'));
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
+
// Boot-time: seed the shared state file with whatever this emulator was
|
|
19
|
+
// started with. Two flows are supported:
|
|
20
|
+
// - Recommended: start emulator without the flag, set TEST_EXTENDED_MODE
|
|
21
|
+
// on `npx mgr test` instead. The test command writes the file; the
|
|
22
|
+
// emulator's function workers watch it and flip live.
|
|
23
|
+
// - Also supported: start emulator with TEST_EXTENDED_MODE=true. We
|
|
24
|
+
// write the file here as a boot default. Useful for inspecting the
|
|
25
|
+
// emulator before any tests fire. Note: the next `npx mgr test`
|
|
26
|
+
// overwrites the file regardless of how the emulator booted.
|
|
27
|
+
{
|
|
28
|
+
const projectDir = this.main.firebaseProjectPath;
|
|
29
|
+
const envSubset = captureSyncedEnv(process.env);
|
|
30
|
+
writeTestMode(projectDir, envSubset);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Show the standard warning if the emulator boots in extended mode.
|
|
18
34
|
if (process.env.TEST_EXTENDED_MODE) {
|
|
19
35
|
this.log(chalk.yellow.bold(`\n ${EXTENDED_MODE_WARNING[0]}`));
|
|
20
36
|
EXTENDED_MODE_WARNING.slice(1).forEach((line) => this.log(chalk.yellow(` ${line}`)));
|
|
37
|
+
this.log(chalk.gray(` (Tip: you can also flip mode per-run by setting TEST_EXTENDED_MODE on \`npx mgr test\`.)`));
|
|
21
38
|
this.log('');
|
|
22
39
|
}
|
|
23
40
|
|
|
@@ -65,15 +82,47 @@ class EmulatorCommand extends BaseCommand {
|
|
|
65
82
|
: 'BEM_TESTING=true';
|
|
66
83
|
const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
|
|
67
84
|
|
|
68
|
-
// Set up log file in the project directory
|
|
85
|
+
// Set up log file in the project directory.
|
|
86
|
+
// We use a mutable `currentStream` so the test command can request a fresh log
|
|
87
|
+
// by touching emulator.log.reset — the watcher below detects it, closes the
|
|
88
|
+
// current stream, reopens with flags: 'w' (truncating cleanly from our process'
|
|
89
|
+
// perspective, no sparse-file issue), and deletes the sentinel.
|
|
69
90
|
const logPath = path.join(projectDir, 'functions', 'emulator.log');
|
|
70
|
-
const
|
|
91
|
+
const resetSentinelPath = `${logPath}.reset`;
|
|
71
92
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
72
93
|
|
|
94
|
+
let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
95
|
+
|
|
96
|
+
function writeToLog(data) {
|
|
97
|
+
if (currentStream && !currentStream.destroyed) {
|
|
98
|
+
currentStream.write(stripAnsi(data.toString()));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Clean up any stale sentinel from a prior crashed emulator run
|
|
103
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
|
|
104
|
+
|
|
105
|
+
// Watch for the test command's request to roll the log.
|
|
106
|
+
// Poll every 500ms — cheap, no fs.watch quirks across platforms.
|
|
107
|
+
const resetWatcher = setInterval(() => {
|
|
108
|
+
if (!fs.existsSync(resetSentinelPath)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const oldStream = currentStream;
|
|
114
|
+
currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
115
|
+
oldStream.end();
|
|
116
|
+
fs.unlinkSync(resetSentinelPath);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// Best-effort. If reset fails the test still runs, the log just won't be fresh.
|
|
119
|
+
}
|
|
120
|
+
}, 500);
|
|
121
|
+
|
|
73
122
|
// Write pre-emulator info to log file
|
|
74
123
|
if (process.env.TEST_EXTENDED_MODE) {
|
|
75
|
-
EXTENDED_MODE_WARNING.forEach((line) =>
|
|
76
|
-
|
|
124
|
+
EXTENDED_MODE_WARNING.forEach((line) => writeToLog(`${line}\n`));
|
|
125
|
+
writeToLog('\n');
|
|
77
126
|
}
|
|
78
127
|
|
|
79
128
|
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
@@ -89,18 +138,22 @@ class EmulatorCommand extends BaseCommand {
|
|
|
89
138
|
// Tee stdout to both console and log file (strip ANSI codes for clean log)
|
|
90
139
|
child.stdout.on('data', (data) => {
|
|
91
140
|
process.stdout.write(data);
|
|
92
|
-
|
|
141
|
+
writeToLog(data);
|
|
93
142
|
});
|
|
94
143
|
|
|
95
144
|
// Tee stderr to both console and log file (strip ANSI codes for clean log)
|
|
96
145
|
child.stderr.on('data', (data) => {
|
|
97
146
|
process.stderr.write(data);
|
|
98
|
-
|
|
147
|
+
writeToLog(data);
|
|
99
148
|
});
|
|
100
149
|
|
|
101
|
-
// Clean up log stream when child exits
|
|
150
|
+
// Clean up log stream + watcher when child exits
|
|
102
151
|
child.on('close', () => {
|
|
103
|
-
|
|
152
|
+
clearInterval(resetWatcher);
|
|
153
|
+
if (currentStream && !currentStream.destroyed) {
|
|
154
|
+
currentStream.end();
|
|
155
|
+
}
|
|
156
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
|
|
104
157
|
});
|
|
105
158
|
});
|
|
106
159
|
}
|
|
@@ -25,11 +25,63 @@ class ServeCommand extends BaseCommand {
|
|
|
25
25
|
// Start Stripe webhook forwarding in background
|
|
26
26
|
this.startStripeWebhookForwarding();
|
|
27
27
|
|
|
28
|
-
// Set up log file in the project directory
|
|
28
|
+
// Set up log file in the project directory.
|
|
29
|
+
// Mirrors the emulator.js pattern: the file is truncated on boot and on every
|
|
30
|
+
// hot reload. Two reset signals are honored:
|
|
31
|
+
// 1. Sentinel file (serve.log.reset) — used by the BEM watcher when source
|
|
32
|
+
// changes in backend-manager itself trigger a reload.
|
|
33
|
+
// 2. Reload marker on stdout (`Using node@22 from host.`) — catches reloads
|
|
34
|
+
// triggered by firebase serve's own internal watcher (any change inside
|
|
35
|
+
// the consumer's functions/ directory). MUST be the line firebase-tools
|
|
36
|
+
// prints at the START of each reload cycle, not somewhere in the middle.
|
|
37
|
+
// If we rolled mid-cycle (e.g. on "Loaded functions definitions"), the
|
|
38
|
+
// tail of the reload sequence still gets captured into the fresh log;
|
|
39
|
+
// but firebase serve sometimes only emits the trailing function-initialized
|
|
40
|
+
// lines on the first cycle (subsequent cycles route those elsewhere), so
|
|
41
|
+
// we'd end up with a near-empty log. Rolling at the START of the cycle
|
|
42
|
+
// lets us capture whatever firebase-tools does emit, complete-or-not.
|
|
29
43
|
const logPath = path.join(projectDir, 'functions', 'serve.log');
|
|
30
|
-
const
|
|
44
|
+
const resetSentinelPath = `${logPath}.reset`;
|
|
45
|
+
// Match any node version: "Using node@22 from host.", "Using node@20 from host.", etc.
|
|
46
|
+
const RELOAD_MARKER = /Using node@\d+ from host\./;
|
|
31
47
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
32
48
|
|
|
49
|
+
let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
50
|
+
let reloadCount = 0; // skip rolling on the first marker (initial boot, not a reload)
|
|
51
|
+
|
|
52
|
+
function rollLog() {
|
|
53
|
+
try {
|
|
54
|
+
const oldStream = currentStream;
|
|
55
|
+
currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
56
|
+
oldStream.end();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Best-effort. If roll fails, serve keeps running with the existing stream.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeToLog(data) {
|
|
63
|
+
if (currentStream && !currentStream.destroyed) {
|
|
64
|
+
currentStream.write(stripAnsi(data.toString()));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Clean up any stale sentinel from a prior crashed serve run
|
|
69
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
|
|
70
|
+
|
|
71
|
+
// Poll every 500ms for the reset sentinel — cheap, no fs.watch quirks
|
|
72
|
+
const resetWatcher = setInterval(() => {
|
|
73
|
+
if (!fs.existsSync(resetSentinelPath)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
rollLog();
|
|
79
|
+
fs.unlinkSync(resetSentinelPath);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Best-effort.
|
|
82
|
+
}
|
|
83
|
+
}, 500);
|
|
84
|
+
|
|
33
85
|
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
34
86
|
|
|
35
87
|
// Execute with tee to log file
|
|
@@ -42,21 +94,35 @@ class ServeCommand extends BaseCommand {
|
|
|
42
94
|
env: { ...process.env, FORCE_COLOR: '1' },
|
|
43
95
|
},
|
|
44
96
|
}, (child) => {
|
|
45
|
-
// Tee stdout to both console and log file (strip ANSI codes for clean log)
|
|
97
|
+
// Tee stdout to both console and log file (strip ANSI codes for clean log).
|
|
98
|
+
// Watch each chunk for the reload marker — when seen (after the initial boot),
|
|
99
|
+
// roll the log BEFORE writing this chunk so the marker becomes the first
|
|
100
|
+
// line of the fresh file.
|
|
46
101
|
child.stdout.on('data', (data) => {
|
|
47
102
|
process.stdout.write(data);
|
|
48
|
-
|
|
103
|
+
const text = data.toString();
|
|
104
|
+
if (RELOAD_MARKER.test(text)) {
|
|
105
|
+
reloadCount++;
|
|
106
|
+
if (reloadCount > 1) {
|
|
107
|
+
rollLog();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
writeToLog(data);
|
|
49
111
|
});
|
|
50
112
|
|
|
51
113
|
// Tee stderr to both console and log file (strip ANSI codes for clean log)
|
|
52
114
|
child.stderr.on('data', (data) => {
|
|
53
115
|
process.stderr.write(data);
|
|
54
|
-
|
|
116
|
+
writeToLog(data);
|
|
55
117
|
});
|
|
56
118
|
|
|
57
|
-
// Clean up log stream when child exits
|
|
119
|
+
// Clean up log stream + watcher when child exits
|
|
58
120
|
child.on('close', () => {
|
|
59
|
-
|
|
121
|
+
clearInterval(resetWatcher);
|
|
122
|
+
if (currentStream && !currentStream.destroyed) {
|
|
123
|
+
currentStream.end();
|
|
124
|
+
}
|
|
125
|
+
try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
|
|
60
126
|
});
|
|
61
127
|
});
|
|
62
128
|
} catch (error) {
|
package/src/cli/commands/test.js
CHANGED
|
@@ -6,6 +6,7 @@ const jetpack = require('fs-jetpack');
|
|
|
6
6
|
const JSON5 = require('json5');
|
|
7
7
|
const powertools = require('node-powertools');
|
|
8
8
|
const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
|
|
9
|
+
const { writeTestMode, captureSyncedEnv, SYNCED_ENV_KEYS } = require('../../test/utils/test-mode-file');
|
|
9
10
|
const EmulatorCommand = require('./emulator');
|
|
10
11
|
|
|
11
12
|
class TestCommand extends BaseCommand {
|
|
@@ -20,6 +21,23 @@ class TestCommand extends BaseCommand {
|
|
|
20
21
|
const projectDir = self.firebaseProjectPath;
|
|
21
22
|
const functionsDir = path.join(projectDir, 'functions');
|
|
22
23
|
|
|
24
|
+
// Pre-flight: write the allowlisted env subset to a shared state file
|
|
25
|
+
// (`<projectDir>/.temp/test-mode.json`). The running emulator watches this
|
|
26
|
+
// file and mutates its own `process.env` to match, eliminating the need
|
|
27
|
+
// to coordinate env vars across both terminals. The test command is the
|
|
28
|
+
// authoritative writer — whatever you pass here becomes the live mode
|
|
29
|
+
// within ~50ms.
|
|
30
|
+
//
|
|
31
|
+
// Allowlist lives in src/test/utils/test-mode-file.js (SYNCED_ENV_KEYS).
|
|
32
|
+
// Today: just TEST_EXTENDED_MODE. Add more keys there to make them
|
|
33
|
+
// live-syncable.
|
|
34
|
+
{
|
|
35
|
+
const envSubset = captureSyncedEnv(process.env);
|
|
36
|
+
writeTestMode(projectDir, envSubset);
|
|
37
|
+
const extended = !!process.env.TEST_EXTENDED_MODE;
|
|
38
|
+
this.log(chalk.gray(` Test mode: ${extended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
// Load emulator ports from firebase.json
|
|
24
42
|
const emulatorPorts = this.loadEmulatorPorts(projectDir);
|
|
25
43
|
|
|
@@ -33,7 +51,7 @@ class TestCommand extends BaseCommand {
|
|
|
33
51
|
// Use hosting URL for all API requests (rewrites to bm_api function)
|
|
34
52
|
const testConfig = {
|
|
35
53
|
...projectConfig,
|
|
36
|
-
|
|
54
|
+
apiUrl: `http://127.0.0.1:${emulatorPorts.hosting}`,
|
|
37
55
|
projectDir,
|
|
38
56
|
testPaths,
|
|
39
57
|
emulatorPorts,
|
|
@@ -167,12 +185,58 @@ class TestCommand extends BaseCommand {
|
|
|
167
185
|
return this.isPortInUse(emulatorPorts.functions);
|
|
168
186
|
}
|
|
169
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Signal the running emulator process to roll emulator.log.
|
|
190
|
+
*
|
|
191
|
+
* Mechanism: write a sentinel file at emulator.log.reset. The emulator command
|
|
192
|
+
* (src/cli/commands/emulator.js) polls for it and, on detection, closes its
|
|
193
|
+
* current write stream and reopens with flags: 'w' (truncating cleanly from its
|
|
194
|
+
* own perspective — avoids the sparse-file problem caused by external truncation).
|
|
195
|
+
*
|
|
196
|
+
* Waits up to 2s for the sentinel to be consumed. If it's still there after 2s
|
|
197
|
+
* the emulator isn't watching (probably running an older BEM or started outside
|
|
198
|
+
* `npx mgr emulator`); we delete the sentinel and proceed — tests still run, the
|
|
199
|
+
* log just won't be reset for this run.
|
|
200
|
+
*/
|
|
201
|
+
async requestEmulatorLogReset(projectDir) {
|
|
202
|
+
const logPath = path.join(projectDir, 'functions', 'emulator.log');
|
|
203
|
+
const sentinelPath = `${logPath}.reset`;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
fs.writeFileSync(sentinelPath, '');
|
|
207
|
+
} catch (e) {
|
|
208
|
+
return; // Can't write — skip, not fatal
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Poll for the emulator to consume the sentinel (it deletes the file when done)
|
|
212
|
+
const maxWaitMs = 2000;
|
|
213
|
+
const pollIntervalMs = 100;
|
|
214
|
+
const start = Date.now();
|
|
215
|
+
|
|
216
|
+
while (Date.now() - start < maxWaitMs) {
|
|
217
|
+
if (!fs.existsSync(sentinelPath)) {
|
|
218
|
+
return; // Emulator picked it up and rolled the log
|
|
219
|
+
}
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Timed out — emulator didn't see the sentinel. Clean up so we don't leave it behind.
|
|
224
|
+
try { fs.unlinkSync(sentinelPath); } catch (e) { /* ok */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
170
227
|
/**
|
|
171
228
|
* Run tests directly (emulator already running)
|
|
172
229
|
*/
|
|
173
230
|
async runTestsDirectly(testCommand, functionsDir, emulatorPorts) {
|
|
174
231
|
const projectDir = this.main.firebaseProjectPath;
|
|
175
232
|
|
|
233
|
+
// Ask the running emulator process to roll emulator.log so this test run gets a
|
|
234
|
+
// clean slate. We touch a sentinel file the emulator polls for (every ~500ms) and
|
|
235
|
+
// wait briefly for it to be consumed. If the emulator isn't watching (older BEM
|
|
236
|
+
// version, or not started via `npx mgr emulator`), we time out silently — the log
|
|
237
|
+
// just won't be fresh, tests still run normally.
|
|
238
|
+
await this.requestEmulatorLogReset(projectDir);
|
|
239
|
+
|
|
176
240
|
this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
|
|
177
241
|
this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
|
|
178
242
|
this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
|
|
@@ -53,13 +53,21 @@ class WatchCommand extends BaseCommand {
|
|
|
53
53
|
// So we must: 1) ensure file exists, 2) wait for FS to settle, 3) write new content
|
|
54
54
|
// --on-change-only: only run exec when files change, not on initial startup
|
|
55
55
|
// --delay 1: debounce multiple rapid changes into one trigger
|
|
56
|
+
//
|
|
57
|
+
// The exec also drops <log>.reset sentinels so the parent serve/emulator command
|
|
58
|
+
// rolls its log file cleanly on every hot reload (mirrors the emulator log-roll
|
|
59
|
+
// pattern used by the test runner). Sentinels are best-effort — if a parent
|
|
60
|
+
// command isn't watching, the file is harmless and gets cleaned up by the next
|
|
61
|
+
// boot's stale-sentinel sweep.
|
|
56
62
|
const triggerFile = config.triggerFile;
|
|
63
|
+
const serveLogResetPath = path.join(config.functionsDir, 'serve.log.reset');
|
|
64
|
+
const emulatorLogResetPath = path.join(config.functionsDir, 'emulator.log.reset');
|
|
57
65
|
const nodemon = spawn(nodemonPath, [
|
|
58
66
|
'--on-change-only',
|
|
59
67
|
'--delay', '1',
|
|
60
68
|
'--watch', config.bemSrcDir,
|
|
61
69
|
'--ext', 'js,json',
|
|
62
|
-
'--exec', `node -e "var f='${triggerFile}',fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'// init');require('child_process').execSync('sleep 0.1');}fs.writeFileSync(f,'// '+Date.now())" && echo " [BEM] Triggered hot reload"`,
|
|
70
|
+
'--exec', `node -e "var f='${triggerFile}',fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'// init');require('child_process').execSync('sleep 0.1');}fs.writeFileSync(f,'// '+Date.now());try{fs.writeFileSync('${serveLogResetPath}','');}catch(e){}try{fs.writeFileSync('${emulatorLogResetPath}','');}catch(e){}" && echo " [BEM] Triggered hot reload"`,
|
|
63
71
|
], {
|
|
64
72
|
stdio: 'inherit',
|
|
65
73
|
detached: false,
|
|
@@ -93,11 +101,15 @@ class WatchCommand extends BaseCommand {
|
|
|
93
101
|
jetpack.write(config.triggerFile, `// BEM reload trigger\n`);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
// Use nodemon to watch the BEM src directory and touch the trigger file on changes
|
|
104
|
+
// Use nodemon to watch the BEM src directory and touch the trigger file on changes.
|
|
105
|
+
// Also drop <log>.reset sentinels so any sibling serve/emulator command rolls its
|
|
106
|
+
// log file on each reload (mirrors the test runner's log-roll pattern).
|
|
107
|
+
const serveLogResetPath = path.join(config.functionsDir, 'serve.log.reset');
|
|
108
|
+
const emulatorLogResetPath = path.join(config.functionsDir, 'emulator.log.reset');
|
|
97
109
|
const nodemon = spawn(nodemonPath, [
|
|
98
110
|
'--watch', config.bemSrcDir,
|
|
99
111
|
'--ext', 'js,json',
|
|
100
|
-
'--exec', `touch "${config.triggerFile}" && echo " → Triggered hot reload"`,
|
|
112
|
+
'--exec', `touch "${config.triggerFile}" "${serveLogResetPath}" "${emulatorLogResetPath}" && echo " → Triggered hot reload"`,
|
|
101
113
|
], {
|
|
102
114
|
stdio: 'inherit',
|
|
103
115
|
cwd: config.bemDir,
|
package/src/defaults/CLAUDE.md
CHANGED
|
@@ -16,11 +16,13 @@ This project consumes **Backend Manager** (BEM) — a comprehensive framework fo
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
cd functions
|
|
19
|
-
npx mgr setup
|
|
20
|
-
npx mgr emulator
|
|
21
|
-
npx mgr watch
|
|
22
|
-
npx mgr deploy
|
|
23
|
-
npx mgr logs
|
|
19
|
+
npx mgr setup # validate config + scaffold defaults + run checks
|
|
20
|
+
npx mgr emulator # start Firebase emulators (auth/firestore/functions/database/storage)
|
|
21
|
+
npx mgr watch # auto-reload functions on file change
|
|
22
|
+
npx mgr deploy # deploy to Firebase
|
|
23
|
+
npx mgr logs:read # read Cloud Functions logs (also: logs:tail to stream)
|
|
24
|
+
npx mgr firestore:get # read a doc from Firestore (also: firestore:set / :query / :delete)
|
|
25
|
+
npx mgr auth:get # read an Auth user (also: auth:list / :delete / :set-claims)
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
|
|
@@ -94,14 +94,25 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
94
94
|
const nowISO = new Date().toISOString();
|
|
95
95
|
const nowUNIX = Math.round(Date.now() / 1000);
|
|
96
96
|
|
|
97
|
-
// Strip non-serializable fields out of the generator's return
|
|
98
|
-
// writing to Firestore.
|
|
99
|
-
//
|
|
100
|
-
|
|
97
|
+
// Strip non-serializable / oversized fields out of the generator's return
|
|
98
|
+
// before writing to Firestore.
|
|
99
|
+
// images: Buffer[] — not safe to persist
|
|
100
|
+
// mjml: raw template string — pollutes the doc, available in the GH archive
|
|
101
|
+
// structure: full JSON dump (5-10kb) — available via assets.markdownUrl + assets.htmlUrl
|
|
102
|
+
// contentMarkdown: large markdown blob — available via assets.markdownUrl
|
|
103
|
+
const {
|
|
104
|
+
images: _images,
|
|
105
|
+
mjml: _mjml,
|
|
106
|
+
structure: _structure,
|
|
107
|
+
contentMarkdown: _contentMarkdown,
|
|
108
|
+
assets,
|
|
109
|
+
meta,
|
|
110
|
+
...campaignSettings
|
|
111
|
+
} = generated;
|
|
101
112
|
|
|
102
113
|
await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
|
|
103
114
|
settings: campaignSettings,
|
|
104
|
-
assets: assets || null, // { folderUrl, htmlUrl, imageUrls, campaignId }
|
|
115
|
+
assets: assets || null, // { folderUrl, htmlUrl, markdownUrl, summaryUrl, imageUrls, beehiivPostId, tags, campaignId }
|
|
105
116
|
meta: meta || null, // tokens, cost, durations, source scores
|
|
106
117
|
type,
|
|
107
118
|
sendAt: data.sendAt,
|
|
@@ -115,11 +126,16 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
115
126
|
|
|
116
127
|
assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
|
|
117
128
|
if (assets?.htmlUrl) {
|
|
118
|
-
assistant.log(` HTML:
|
|
119
|
-
assistant.log(`
|
|
129
|
+
assistant.log(` HTML: ${assets.htmlUrl}`);
|
|
130
|
+
assistant.log(` Markdown: ${assets.markdownUrl || '(none)'}`);
|
|
131
|
+
assistant.log(` Summary: ${assets.summaryUrl || '(none)'}`);
|
|
132
|
+
assistant.log(` Folder: ${assets.folderUrl}`);
|
|
120
133
|
}
|
|
121
134
|
if (assets?.beehiivPostId) {
|
|
122
|
-
assistant.log(` Beehiiv:
|
|
135
|
+
assistant.log(` Beehiiv: draft post ${assets.beehiivPostId}`);
|
|
136
|
+
}
|
|
137
|
+
if (assets?.tags?.length) {
|
|
138
|
+
assistant.log(` Tags: ${assets.tags.join(', ')}`);
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
// Advance the recurring doc's sendAt to the next occurrence
|
|
@@ -147,6 +147,35 @@ const SCHEMA = {
|
|
|
147
147
|
page: { type: 'string', default: null, nullable: true },
|
|
148
148
|
},
|
|
149
149
|
},
|
|
150
|
+
consent: {
|
|
151
|
+
legal: {
|
|
152
|
+
status: { type: 'string', default: 'revoked' },
|
|
153
|
+
grantedAt: {
|
|
154
|
+
timestamp: { type: 'string', default: null, nullable: true },
|
|
155
|
+
timestampUNIX: { type: 'number', default: null, nullable: true },
|
|
156
|
+
source: { type: 'string', default: null, nullable: true },
|
|
157
|
+
ip: { type: 'string', default: null, nullable: true },
|
|
158
|
+
text: { type: 'string', default: null, nullable: true },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
marketing: {
|
|
162
|
+
status: { type: 'string', default: 'revoked' },
|
|
163
|
+
grantedAt: {
|
|
164
|
+
timestamp: { type: 'string', default: null, nullable: true },
|
|
165
|
+
timestampUNIX: { type: 'number', default: null, nullable: true },
|
|
166
|
+
source: { type: 'string', default: null, nullable: true },
|
|
167
|
+
ip: { type: 'string', default: null, nullable: true },
|
|
168
|
+
text: { type: 'string', default: null, nullable: true },
|
|
169
|
+
},
|
|
170
|
+
revokedAt: {
|
|
171
|
+
timestamp: { type: 'string', default: null, nullable: true },
|
|
172
|
+
timestampUNIX: { type: 'number', default: null, nullable: true },
|
|
173
|
+
source: { type: 'string', default: null, nullable: true },
|
|
174
|
+
ip: { type: 'string', default: null, nullable: true },
|
|
175
|
+
text: { type: 'string', default: null, nullable: true },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
150
179
|
};
|
|
151
180
|
|
|
152
181
|
/**
|